mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user