diff --git a/apps/login/Dockerfile b/apps/login/Dockerfile index 4d34cf80c..dc51b39df 100644 --- a/apps/login/Dockerfile +++ b/apps/login/Dockerfile @@ -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"] diff --git a/apps/login/dockerized/otel/otel.integration.test.ts b/apps/login/dockerized/otel/otel.integration.test.ts index 2eec51d81..0c28f05d0 100644 --- a/apps/login/dockerized/otel/otel.integration.test.ts +++ b/apps/login/dockerized/otel/otel.integration.test.ts @@ -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"); diff --git a/apps/login/src/app/ready/route.test.ts b/apps/login/src/app/ready/route.test.ts new file mode 100644 index 000000000..792644fb7 --- /dev/null +++ b/apps/login/src/app/ready/route.test.ts @@ -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"); + }); +}); diff --git a/apps/login/src/app/ready/route.ts b/apps/login/src/app/ready/route.ts new file mode 100644 index 000000000..e608a732c --- /dev/null +++ b/apps/login/src/app/ready/route.ts @@ -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 = 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" }, + }); + } +} diff --git a/apps/login/src/instrumentation.node.ts b/apps/login/src/instrumentation.node.ts index 77aa6169f..6872e72d0 100644 --- a/apps/login/src/instrumentation.node.ts +++ b/apps/login/src/instrumentation.node.ts @@ -26,7 +26,7 @@ export async function registerNode(): Promise { 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 }, diff --git a/deploy/compose/tests/wiring.spec.ts b/deploy/compose/tests/wiring.spec.ts index cbf7475ca..398c3f042 100644 --- a/deploy/compose/tests/wiring.spec.ts +++ b/deploy/compose/tests/wiring.spec.ts @@ -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);