feat(login): add readiness probe endpoint (#11828)

# Which Problems Are Solved

The login v2 container has no way to verify upstream Zitadel API
connectivity before accepting traffic. The existing `/healthy` endpoint
always returns 200 regardless of whether the backend is reachable. This
means Kubernetes routes user traffic to pods that cannot actually serve
login requests, causing silent failures where the root layout swallows
gRPC errors and renders pages with default settings.

# How the Problems Are Solved

A new `/ui/v2/login/ready` endpoint creates a gRPC client using the same
auth and transport path as real requests, calls
`SettingsService.getGeneralSettings()`, and returns `200 OK` on success
or `503 Service unavailable` on failure. This verifies both network
connectivity and authentication credentials to the upstream Zitadel API.

Kubernetes supports readiness probes natively, so this endpoint can be
configured as a readiness probe to gate traffic until the login
container can connect to Zitadel. Docker Compose does not have a
readiness probe concept, so the compose healthcheck and
`healthcheck.mjs` default remain unchanged and continue using
`/healthy`.

The Docker HEALTHCHECK in the Dockerfile has been updated to use
`/ready` so that the image-level health status reflects actual upstream
connectivity. This will also allow us to simplify the Helm chart by
dropping the `wait4x` init containers that currently poll the Zitadel
API before starting the login pod, since the readiness probe makes them
redundant.

# Additional Changes

The `/ready` endpoint has been added to the OTEL HTTP instrumentation
ignore list to avoid noisy probe traces. Unit tests, a wiring spec
integration test, and an OTEL span filtering test have been added for
the new endpoint. The `# TODO: Check healthy, not ready` comment has
been removed from the Dockerfile. The `/healthy` endpoint is kept as-is
for liveness probes.

# Additional Context

The Helm chart changes to use `/ready` for readiness and startup probes
and to remove the `wait-for-zitadel` init container will be done in a
follow-up PR in the `zitadel-charts` repository.
This commit is contained in:
Mridang Agarwalla
2026-03-12 15:52:58 +11:00
committed by GitHub
parent 4586708c06
commit db8f475a02
6 changed files with 137 additions and 3 deletions
+1 -2
View File
@@ -18,7 +18,6 @@ ENV HOSTNAME="::" \
OTEL_SERVICE_NAME="zitadel-login" \
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
# TODO: Check healthy, not ready
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/node", "/app/healthcheck.mjs"]
CMD ["/usr/local/bin/node", "/app/healthcheck.mjs", "/ui/v2/login/ready"]
ENTRYPOINT ["/app/entrypoint.sh", "node", "apps/login/server.js"]
@@ -173,6 +173,7 @@ describe("OpenTelemetry Integration", () => {
await fetch(`${appUrl}/ui/v2/login/otel-test`);
await fetch(`${appUrl}/ui/v2/login/loginname`, { redirect: "manual" });
await fetch(`${appUrl}/ui/v2/login/healthy`);
await fetch(`${appUrl}/ui/v2/login/ready`);
await fetch(`${appUrl}/ui/v2/login/this-does-not-exist-404`);
await new Promise((r) => setTimeout(r, 3000));
@@ -311,6 +312,29 @@ describe("OpenTelemetry Integration", () => {
expect(healthySpans).toHaveLength(0);
}, TEST_TIMEOUT);
it("HTTP instrumentation excludes /ready", () => {
const tracesContent = readTraces();
const traceObjects = tracesContent
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
const allResourceSpans = traceObjects.flatMap((t) => t.resourceSpans || []);
const httpSpans = allResourceSpans
.flatMap((rs: { scopeSpans: Array<{ scope: { name: string }; spans: unknown[] }> }) => rs.scopeSpans)
.filter((ss: { scope: { name: string } }) => ss.scope.name === "@opentelemetry/instrumentation-http")
.flatMap((ss: { spans: unknown[] }) => ss.spans);
const readySpans = httpSpans.filter(
(s: { attributes?: Array<{ key: string; value: { stringValue?: string } }> }) =>
s.attributes?.some(
(a: { key: string; value: { stringValue?: string } }) =>
a.key === "http.target" && a.value.stringValue?.includes("/ready"),
),
);
expect(readySpans).toHaveLength(0);
}, TEST_TIMEOUT);
it("includes /otel-test in traces", () => {
const traces = readTraces();
expect(traces).toContain("otel-test");
+73
View File
@@ -0,0 +1,73 @@
import { createServiceForHost } from "@/lib/service";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
vi.mock("@/lib/service", () => ({
createServiceForHost: vi.fn(),
}));
vi.mock("@/lib/logger", () => ({
createLogger: () => ({ error: vi.fn() }),
}));
describe("GET /ready", () => {
let savedApiUrl: string | undefined;
beforeEach(() => {
savedApiUrl = process.env.ZITADEL_API_URL;
process.env.ZITADEL_API_URL = "http://localhost:8080";
});
afterEach(() => {
if (savedApiUrl === undefined) {
delete process.env.ZITADEL_API_URL;
} else {
process.env.ZITADEL_API_URL = savedApiUrl;
}
vi.restoreAllMocks();
});
test("should return 200 when gRPC call succeeds", async () => {
vi.mocked(createServiceForHost).mockResolvedValue({
getGeneralSettings: vi.fn().mockResolvedValue({ allowedLanguages: ["en"] }),
} as any);
const response = await GET();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain");
expect(await response.text()).toBe("OK");
});
test("should return 503 when gRPC call fails", async () => {
vi.mocked(createServiceForHost).mockResolvedValue({
getGeneralSettings: vi.fn().mockRejectedValue(new Error("connection refused")),
} as any);
const response = await GET();
expect(response.status).toBe(503);
expect(response.headers.get("Content-Type")).toBe("text/plain");
expect(await response.text()).toBe("Service unavailable");
});
test("should return 503 when authentication fails", async () => {
vi.mocked(createServiceForHost).mockRejectedValue(new Error("No authentication credentials found"));
const response = await GET();
expect(response.status).toBe(503);
expect(response.headers.get("Content-Type")).toBe("text/plain");
expect(await response.text()).toBe("Service unavailable");
});
test("should return 503 when ZITADEL_API_URL is not set", async () => {
delete process.env.ZITADEL_API_URL;
const response = await GET();
expect(response.status).toBe(503);
expect(response.headers.get("Content-Type")).toBe("text/plain");
expect(await response.text()).toBe("Service unavailable");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { createLogger } from "@/lib/logger";
import { createServiceForHost } from "@/lib/service";
import { Client } from "@zitadel/client";
import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb";
import { NextResponse } from "next/server";
const logger = createLogger("readiness");
export async function GET() {
if (!process.env.ZITADEL_API_URL) {
return new NextResponse("Service unavailable", {
status: 503,
headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
});
}
try {
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, {
baseUrl: process.env.ZITADEL_API_URL,
});
await settingsService.getGeneralSettings({});
return new NextResponse("OK", {
status: 200,
headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
});
} catch (e) {
logger.error("Readiness check failed", { error: e });
return new NextResponse("Service unavailable", {
status: 503,
headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
});
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ export async function registerNode(): Promise<LoggerProvider | null> {
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-http": {
ignoreIncomingRequestHook: (req) =>
["/healthy", "/_next/", "/favicon.ico", "/__nextjs", "/metrics"].some((p) => req.url?.includes(p)),
["/healthy", "/ready", "/_next/", "/favicon.ico", "/__nextjs", "/metrics"].some((p) => req.url?.includes(p)),
},
"@opentelemetry/instrumentation-fs": { enabled: false },
"@opentelemetry/instrumentation-winston": { disableLogSending: false },
+5
View File
@@ -310,6 +310,11 @@ test.describe("login ui", () => {
expect(resp.status()).toBe(200);
});
test("GET /ui/v2/login/ready → 200", async ({ request }) => {
const resp = await request.get("/ui/v2/login/ready");
expect(resp.status()).toBe(200);
});
test("GET /ui/v2/login/ → 200 HTML", async ({ request }) => {
const resp = await request.get("/ui/v2/login/");
expect(resp.status()).toBe(200);