mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
feat(login): add ZITADEL_API_AWAITINITIALCONN support (#12032)
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { StartedNetwork } from "testcontainers";
|
||||
import { GenericContainer, Network, Wait, type StartedTestContainer } from "testcontainers";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LOGIN_APP_DIR = path.join(TEST_DIR, "../..");
|
||||
const LOGIN_IMAGE_TAG = `zitadel-login-test:${process.env.GITHUB_SHA || "local"}`;
|
||||
|
||||
const DOCKER_TIMEOUT = 180_000;
|
||||
|
||||
/**
|
||||
* Integration tests for ZITADEL_API_AWAITINITIALCONN support.
|
||||
*
|
||||
* When this environment variable is set (e.g. "30s", "5m"), the login
|
||||
* container polls the ZITADEL API's /debug/ready endpoint before starting
|
||||
* the Next.js server. This replaces the wait4x init container that was
|
||||
* previously used in the Helm chart.
|
||||
*
|
||||
* The test suite uses a mock Zitadel server that returns 503 on /debug/ready
|
||||
* for a configurable duration before switching to 200. Three scenarios are
|
||||
* verified: the API becomes ready within the timeout, the timeout expires
|
||||
* before the API is ready, and the variable is not set at all.
|
||||
*/
|
||||
describe("ZITADEL_API_AWAITINITIALCONN", () => {
|
||||
beforeAll(async () => {
|
||||
await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build(LOGIN_IMAGE_TAG);
|
||||
}, DOCKER_TIMEOUT);
|
||||
|
||||
describe("when the API becomes ready before the timeout", () => {
|
||||
let network: StartedNetwork;
|
||||
let mockZitadel: StartedTestContainer;
|
||||
let container: StartedTestContainer;
|
||||
let appUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
network = await new Network().start();
|
||||
|
||||
mockZitadel = await new GenericContainer("node:20-alpine")
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("mock-zitadel")
|
||||
.withCopyFilesToContainer([
|
||||
{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" },
|
||||
])
|
||||
.withEnvironment({ PORT: "8080", READY_AFTER_MS: "5000" })
|
||||
.withCommand(["node", "/app/server.js"])
|
||||
.withExposedPorts(8080)
|
||||
.withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening"))
|
||||
.start();
|
||||
|
||||
container = await new GenericContainer(LOGIN_IMAGE_TAG)
|
||||
.withNetwork(network)
|
||||
.withExposedPorts(3000)
|
||||
.withEnvironment({
|
||||
ZITADEL_API_URL: "http://mock-zitadel:8080",
|
||||
ZITADEL_SERVICE_USER_TOKEN: "test-token",
|
||||
ZITADEL_API_AWAITINITIALCONN: "60s",
|
||||
})
|
||||
.withStartupTimeout(120_000)
|
||||
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
|
||||
.start();
|
||||
|
||||
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
|
||||
}, DOCKER_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await container?.stop().catch(() => {});
|
||||
await mockZitadel?.stop().catch(() => {});
|
||||
await network?.stop().catch(() => {});
|
||||
}, DOCKER_TIMEOUT);
|
||||
|
||||
it("starts successfully after the API becomes ready", async () => {
|
||||
const response = await fetch(`${appUrl}/ui/v2/login/healthy`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the API does not become ready before the timeout", () => {
|
||||
let network: StartedNetwork;
|
||||
let mockZitadel: StartedTestContainer;
|
||||
let container: StartedTestContainer;
|
||||
|
||||
beforeAll(async () => {
|
||||
network = await new Network().start();
|
||||
|
||||
mockZitadel = await new GenericContainer("node:20-alpine")
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("mock-zitadel")
|
||||
.withCopyFilesToContainer([
|
||||
{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" },
|
||||
])
|
||||
.withEnvironment({ PORT: "8080", READY_AFTER_MS: "600000" })
|
||||
.withCommand(["node", "/app/server.js"])
|
||||
.withExposedPorts(8080)
|
||||
.withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening"))
|
||||
.start();
|
||||
|
||||
container = await new GenericContainer(LOGIN_IMAGE_TAG)
|
||||
.withNetwork(network)
|
||||
.withExposedPorts(3000)
|
||||
.withEnvironment({
|
||||
ZITADEL_API_URL: "http://mock-zitadel:8080",
|
||||
ZITADEL_SERVICE_USER_TOKEN: "test-token",
|
||||
ZITADEL_API_AWAITINITIALCONN: "5s",
|
||||
})
|
||||
.withStartupTimeout(30_000)
|
||||
.withWaitStrategy(Wait.forLogMessage(/Timed out after 5s/))
|
||||
.start();
|
||||
}, DOCKER_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await container?.stop().catch(() => {});
|
||||
await mockZitadel?.stop().catch(() => {});
|
||||
await network?.stop().catch(() => {});
|
||||
}, DOCKER_TIMEOUT);
|
||||
|
||||
it("logs the timeout error before exiting", async () => {
|
||||
const stream = await container.logs();
|
||||
const logs = await new Promise<string>((resolve) => {
|
||||
let output = "";
|
||||
stream.on("data", (chunk: Buffer) => { output += chunk.toString(); });
|
||||
stream.on("end", () => resolve(output));
|
||||
setTimeout(() => { stream.destroy(); resolve(output); }, 5_000);
|
||||
});
|
||||
expect(logs).toContain("Timed out after 5s waiting for API readiness");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the variable is not set", () => {
|
||||
let network: StartedNetwork;
|
||||
let mockZitadel: StartedTestContainer;
|
||||
let container: StartedTestContainer;
|
||||
let appUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
network = await new Network().start();
|
||||
|
||||
mockZitadel = await new GenericContainer("node:20-alpine")
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("mock-zitadel")
|
||||
.withCopyFilesToContainer([
|
||||
{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" },
|
||||
])
|
||||
.withEnvironment({ PORT: "8080", READY_AFTER_MS: "0" })
|
||||
.withCommand(["node", "/app/server.js"])
|
||||
.withExposedPorts(8080)
|
||||
.withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening"))
|
||||
.start();
|
||||
|
||||
container = await new GenericContainer(LOGIN_IMAGE_TAG)
|
||||
.withNetwork(network)
|
||||
.withExposedPorts(3000)
|
||||
.withEnvironment({
|
||||
ZITADEL_API_URL: "http://mock-zitadel:8080",
|
||||
ZITADEL_SERVICE_USER_TOKEN: "test-token",
|
||||
})
|
||||
.withStartupTimeout(120_000)
|
||||
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
|
||||
.start();
|
||||
|
||||
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
|
||||
}, DOCKER_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await container?.stop().catch(() => {});
|
||||
await mockZitadel?.stop().catch(() => {});
|
||||
await network?.stop().catch(() => {});
|
||||
}, DOCKER_TIMEOUT);
|
||||
|
||||
it("starts without waiting", async () => {
|
||||
const response = await fetch(`${appUrl}/ui/v2/login/healthy`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Mock Zitadel server for testing ZITADEL_API_AWAITINITIALCONN.
|
||||
*
|
||||
* Returns 503 on /debug/ready until READY_AFTER_MS milliseconds have elapsed,
|
||||
* then switches to 200. All other endpoints return minimal ConnectRPC-compatible
|
||||
* responses so the login app can start normally once the readiness gate passes.
|
||||
*/
|
||||
const http = require("http");
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
const READY_AFTER_MS = parseInt(process.env.READY_AFTER_MS || "5000", 10);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const sendResponse = (res, statusCode, body) => {
|
||||
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const { method, url } = req;
|
||||
console.log(`[${new Date().toISOString()}] ${method} ${url}`);
|
||||
|
||||
if (url === "/debug/ready") {
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < READY_AFTER_MS) {
|
||||
console.log(` -> 503 (not ready, ${elapsed}ms / ${READY_AFTER_MS}ms)`);
|
||||
sendResponse(res, 503, { status: "not ready" });
|
||||
} else {
|
||||
console.log(` -> 200 (ready after ${elapsed}ms)`);
|
||||
sendResponse(res, 200, { status: "ok" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("GetLoginSettings") || url.includes("SettingsService")) {
|
||||
sendResponse(res, 200, { settings: {} });
|
||||
} else if (url.includes("GetBrandingSettings")) {
|
||||
sendResponse(res, 200, { settings: {} });
|
||||
} else if (url.includes("SessionService") || url.includes("Session")) {
|
||||
sendResponse(res, 200, { session: {} });
|
||||
} else if (url.includes("UserService") || url.includes("User")) {
|
||||
sendResponse(res, 200, { user: {} });
|
||||
} else if (url.includes("OrganizationService") || url.includes("Organization")) {
|
||||
sendResponse(res, 200, { result: [] });
|
||||
} else if (url.includes("IdentityProviderService") || url.includes("IdentityProvider")) {
|
||||
sendResponse(res, 200, { identityProviders: [] });
|
||||
} else {
|
||||
sendResponse(res, 200, {});
|
||||
}
|
||||
});
|
||||
|
||||
server.on("error", (err) => console.error("Server error:", err));
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Mock Zitadel server listening on port ${PORT}`);
|
||||
console.log(`Will become ready after ${READY_AFTER_MS}ms`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("Shutting down mock server...");
|
||||
server.close(() => {
|
||||
console.log("Mock server stopped");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,110 @@
|
||||
import { accessSync, constants, readFileSync } from "node:fs";
|
||||
import { createServer } from "node:https";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { createServer, request as httpsRequest } from "node:https";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
/**
|
||||
* Parses a Go-style duration string into milliseconds.
|
||||
*
|
||||
* This uses the same format as Go's time.ParseDuration (e.g. "30s", "5m",
|
||||
* "1m30s") to stay consistent with the ZITADEL backend, which uses
|
||||
* time.Duration for its own ZITADEL_DATABASE_POSTGRES_AWAITINITIALCONN
|
||||
* setting. Operators configuring both services use the same syntax.
|
||||
*
|
||||
* Valid units are "ms", "s", "m", and "h".
|
||||
* Returns null if the string is empty, zero, or contains invalid syntax.
|
||||
*/
|
||||
function parseDuration(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let totalMs = 0;
|
||||
let remaining = trimmed;
|
||||
const unitMap = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const match = remaining.match(/^(\d+)(ms|s|m|h)/);
|
||||
if (!match) return null;
|
||||
totalMs += parseInt(match[1], 10) * unitMap[match[2]];
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
|
||||
return totalMs > 0 ? totalMs : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls an HTTP(S) URL until it returns HTTP 200 or the deadline is reached.
|
||||
*/
|
||||
async function pollReady(url, timeoutMs) {
|
||||
const parsedUrl = new URL(url);
|
||||
const doRequest = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const req = doRequest(parsedUrl, { method: "GET", timeout: 5_000, rejectUnauthorized: false }, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
|
||||
if (ok) return true;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the ZITADEL API to become ready before starting the server.
|
||||
*
|
||||
* When ZITADEL_API_AWAITINITIALCONN is set (e.g. "60s", "5m", "120s"), this
|
||||
* function blocks until GET {ZITADEL_API_URL}/debug/ready returns HTTP 200
|
||||
* or the timeout expires. This replaces the wait4x init container that was
|
||||
* previously used in the Helm chart.
|
||||
*
|
||||
* If the timeout expires without a successful response, the process exits
|
||||
* with code 1.
|
||||
*/
|
||||
async function awaitApiReady() {
|
||||
const raw = process.env.ZITADEL_API_AWAITINITIALCONN;
|
||||
if (!raw) return;
|
||||
|
||||
const apiUrl = process.env.ZITADEL_API_URL;
|
||||
if (!apiUrl) {
|
||||
console.error("ZITADEL_API_AWAITINITIALCONN is set but ZITADEL_API_URL is not configured.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const timeoutMs = parseDuration(raw);
|
||||
if (timeoutMs === null) {
|
||||
console.error(
|
||||
`Invalid ZITADEL_API_AWAITINITIALCONN value "${raw}". Expected a Go-style duration like "30s", "5m", or "1m30s".`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const readyUrl = new URL("/debug/ready", apiUrl).toString();
|
||||
console.log(`Waiting up to ${raw} for API to be ready at ${readyUrl} ...`);
|
||||
|
||||
const ok = await pollReady(readyUrl, timeoutMs);
|
||||
if (ok) {
|
||||
console.log("API is ready.");
|
||||
} else {
|
||||
console.error(`Timed out after ${raw} waiting for API readiness at ${readyUrl}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await awaitApiReady();
|
||||
|
||||
if (process.env.ZITADEL_TLS_ENABLED !== "true") {
|
||||
// The build script must rename the Next.js-generated server.js to next-server.js.
|
||||
// Validate that the file exists before importing to provide a clearer error if the build step fails.
|
||||
|
||||
Reference in New Issue
Block a user