Files
zitadel/deploy/compose/tests/wiring.spec.ts
T
bac224c56d chore: improve docker compose template, tests and docs (#11593)
Replaces the single-file `docker-compose.yaml` quickstart with a
production-aware, Traefik-based compose pack in `deploy/compose/`. The
pack covers the full arc from a 2-minute localhost quickstart to a
hardened homelab or semi-production deployment.

### What's in the pack

**Stack**: Traefik (proxy) → ZITADEL API (Go `:8080`) + ZITADEL Login
(Next.js `:3000`) → PostgreSQL

All HTTP/gRPC routing is handled by Traefik via Docker labels — no
manual proxy config needed. The Login V2 UI is enabled by default. Login
URLs are derived automatically from `ZITADEL_DOMAIN`,
`ZITADEL_EXTERNALPORT`, and `ZITADEL_PUBLIC_SCHEME` — no separate URL
variables needed.

**Compose files**

| File | Purpose |
|------|---------|
| `docker-compose.yml` | Base stack — works standalone. Uses explicit
`name: zitadel` network for reliable Traefik service discovery. |
| `docker-compose.mode-letsencrypt.yml` | TLS overlay: ACME HTTP
challenge |
| `docker-compose.mode-external-tls.yml` | TLS overlay: upstream LB/CDN
terminates TLS. Uses `forwardedHeaders.trustedIPs` (configurable via
`TRAEFIK_TRUSTED_IPS`) instead of `insecure=true`. |
| `docker-compose.mode-local-tls.yml` | TLS overlay: self-signed certs
for LAN |
| `docker-compose.prodlike.yml` | Splits init / setup / start for
controlled upgrades |
| `docker-compose.test.yml` | CI overlay: swaps images to locally-built
`:local` tags |

**Optional profiles**: `cache` (Redis), `observability` (OpenTelemetry
Collector)

### Build infra

- New `@zitadel/api:pack` and `@zitadel/login:pack` Nx targets build
local Docker images (`zitadel/zitadel:local`,
`zitadel/zitadel-login:local`) for use in CI and local testing
- `apps/api/Dockerfile` now accepts a `BINARY` build arg so local and
release builds share the same image

### Testing

- New `@zitadel/compose` Nx project with targets: `test-config`
(validates all overlay combinations using `--quiet`), `test-run` (starts
full stack with local images), `test-e2e` (Playwright wiring + protocol
matrix tests through Traefik), `test-full` (end-to-end: build → start →
test → teardown), `stop`
- **`@zitadel/compose` is explicitly excluded from `nx affected` in CI
for now** — the full stack smoke test requires a Docker daemon and
significant resources. The intent is to add a dedicated
`compose_smoke_test` CI job in a follow-up. The targets can be run
locally with `pnpm nx run @zitadel/compose:test-full`.

### Documentation

- **`compose.mdx`**: Complete rewrite with a staged structure (Stage 1
Quickstart → Stage 2 Homelab → Stage 3 Beyond Compose). Documents TLS
modes, profiles, secrets hardening, ExternalDomain/Port/Secure
invariant, upgrades, and the path to Kubernetes
- **New `requirements.mdx`**: Lists supported PostgreSQL versions
(14–18), Redis (standalone), Docker Compose v2.x, and reverse proxy h2c
requirements
- **`reverse_proxy.mdx`**: Added intro covering h2c requirements, TLS
modes table, and Login UI routing split
- **`troubleshooting.mdx`**: New sections for container restarts on
upgrade, FIRSTINSTANCE env vars not taking effect, and diagnosing
unhealthy containers
- **`caddy/index.mdx`**: Known issue and workaround for the `TE:
trailers` header hang
- Removed the old
`apps/docs/content/self-hosting/deploy/docker-compose.yaml` embedded in
the docs

### Breaking change

The old `apps/docs/content/self-hosting/deploy/docker-compose.yaml` file
is deleted. The getting-started docs page
(`/self-hosting/deploy/compose`) now points to the new pack via a `curl
| tar` download command.

---

### Checklist

- [x] `deploy/compose/` smoke test passes end-to-end locally (`pnpm nx
run @zitadel/compose:test-full`)
- [x] Docs build passes (`pnpm nx run @zitadel/docs:build`)
- [ ] Follow-up issue created to add `compose_smoke_test` CI job

---------

Co-authored-by: Mridang Agarwalla <[email protected]>
2026-02-24 16:33:12 -08:00

559 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Compose stack wiring tests — per-service reachability through Traefik
*
* PURPOSE: verify that every service and protocol is correctly routed through
* the Traefik reverse proxy. These are INFRASTRUCTURE tests, not feature tests.
* They answer: "is this service alive and reachable through the proxy?"
*
* Services covered:
* - Login UI (zitadel-login, Next.js, /ui/v2/login)
* - Console (zitadel-api, Angular SPA, /ui/console)
* - OIDC (zitadel-api, /.well-known/openid-configuration + /oauth/v2/keys)
* - SAML (zitadel-api, /saml/v2/metadata)
* - Root redirect (Traefik replacepath middleware → /ui/v2/login/)
*
* API protocol × transport matrix (all via Traefik catch-all, priority 100):
* V1 (AdminService/Healthz — unauthenticated, expects success):
* - REST json HTTP/1.1 /admin/v1/healthz
* - REST json h2c /admin/v1/healthz
* - gRPC-web proto HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC-web json HTTP/1.1 /zitadel.admin.v1.AdminService/Healthz
* - gRPC proto h2c /zitadel.admin.v1.AdminService/Healthz
* - gRPC json h2c /zitadel.admin.v1.AdminService/Healthz
*
* V2 (SessionService/ListSessions — requires auth, expects 401):
* Three access styles, each × HTTP/1.1 and h2c:
*
* 1. REST-style path application/json
* POST /v2/sessions (gRPC-gateway REST mapping)
*
* 2. gRPC-style path application/json (Connect unary JSON)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/json = Connect unary.
* Note: application/connect+json is streaming-only (Connect spec).
*
* 3. gRPC-style path application/proto (Connect unary proto)
* POST /zitadel.session.v2.SessionService/ListSessions
* Handled by the Connect handler; application/proto = Connect unary.
* Note: application/connect+proto is streaming-only (Connect spec).
*
* 4. gRPC-style path application/grpc+proto / application/grpc+json (gRPC native, h2c only)
* POST /zitadel.session.v2.SessionService/ListSessions
*
* For end-to-end browser login flow (proves Traefik → Login → API → DB chain):
* see smoke.spec.ts
*
* For feature-level login tests (MFA, OIDC flows, IdPs, etc.):
* see apps/login/acceptance/ — run via @zitadel/login:test-acceptance
*/
import { expect, test, type APIRequestContext, type TestInfo } from "@playwright/test";
import http2 from "node:http2";
import { Buffer } from "node:buffer";
// ---------------------------------------------------------------------------
// HTTP/2 (h2c) helpers
// ---------------------------------------------------------------------------
type H2Response = {
status: number;
headers: Record<string, string>;
body: Buffer;
trailers: Record<string, string>;
};
function h2Request(
baseUrl: string,
method: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return new Promise((resolve, reject) => {
const client = http2.connect(baseUrl);
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`h2c ${method} ${path} timed out`));
}, 30_000);
client.once("error", (err) => {
clearTimeout(timeout);
reject(err);
});
const req = client.request({
":method": method,
":path": path,
":scheme": "http",
...reqHeaders,
});
if (body !== undefined) req.write(body);
req.end();
let status = 0;
const chunks: Buffer[] = [];
const trailers: Record<string, string> = {};
const responseHeaders: Record<string, string> = {};
req.on("response", (hdrs) => {
status = Number(hdrs[":status"]);
for (const [k, v] of Object.entries(hdrs)) {
if (!k.startsWith(":")) responseHeaders[k] = String(v);
}
});
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("trailers", (thdrs) => {
for (const [k, v] of Object.entries(thdrs)) {
trailers[k] = String(v);
}
});
req.on("end", () => {
clearTimeout(timeout);
client.close();
resolve({
status,
headers: responseHeaders,
body: Buffer.concat(chunks),
trailers,
});
});
req.on("error", (err) => {
clearTimeout(timeout);
client.close();
reject(err);
});
});
}
function h2Post(
baseUrl: string,
path: string,
reqHeaders: Record<string, string>,
body?: Buffer | string,
): Promise<H2Response> {
return h2Request(baseUrl, "POST", path, reqHeaders, body);
}
function h2Get(
baseUrl: string,
path: string,
reqHeaders: Record<string, string> = {},
): Promise<H2Response> {
return h2Request(baseUrl, "GET", path, reqHeaders);
}
// ---------------------------------------------------------------------------
// gRPC framing helpers (RFC: https://grpc.github.io/grpc/core/md_doc_PROTOCOL-HTTP2.html)
// Frame: [compressed (1 byte)] [message length (4 bytes BE)] [message bytes]
// ---------------------------------------------------------------------------
/** Wrap proto bytes in a gRPC length-prefixed frame. */
function grpcFrame(protoBytes: Buffer = Buffer.alloc(0)): Buffer {
const frame = Buffer.allocUnsafe(5 + protoBytes.length);
frame[0] = 0; // compressed-flag = false
frame.writeUInt32BE(protoBytes.length, 1);
protoBytes.copy(frame, 5);
return frame;
}
/** Parse the first gRPC response frame and return the proto body bytes. */
function parseGrpcFrame(buf: Buffer): Buffer {
if (buf.length < 5) throw new Error(`gRPC response frame too short (${buf.length} bytes)`);
const len = buf.readUInt32BE(1);
if (buf.length < 5 + len) throw new Error(`gRPC response frame truncated: expected ${5 + len} bytes, got ${buf.length}`);
return buf.subarray(5, 5 + len);
}
/** Parse gRPC-web inline trailers from the response body.
* After the data frame(s), gRPC-web appends a trailer frame with flag 0x80. */
function parseGrpcWebTrailers(buf: Buffer): Record<string, string> {
// Skip past data frames to find the trailer frame (flag byte 0x80)
let offset = 0;
while (offset < buf.length) {
if (offset + 5 > buf.length) {
throw new Error(`Truncated gRPC-web frame at offset ${offset}: need 5 header bytes but only ${buf.length - offset} remain`);
}
const flag = buf[offset];
const len = buf.readUInt32BE(offset + 1);
if (flag === 0x80) {
// Trailer frame: ASCII key-value pairs separated by \r\n
const text = buf.subarray(offset + 5, offset + 5 + len).toString("utf8");
const trailers: Record<string, string> = {};
for (const line of text.split("\r\n")) {
const colon = line.indexOf(":");
if (colon > 0) {
trailers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
}
}
return trailers;
}
offset += 5 + len;
}
return {};
}
// ---------------------------------------------------------------------------
// Soft-fail wrapper — allows new/experimental permutations to warn on failure
// without breaking CI. Tests marked knownWorking: false log a warning instead
// of hard-failing.
// ---------------------------------------------------------------------------
function softTest(
label: string,
knownWorking: boolean,
testFn: (args: { request: APIRequestContext; testInfo: TestInfo }) => Promise<void>,
) {
if (knownWorking) {
test(label, async ({ request }, testInfo) => {
await testFn({ request, testInfo });
});
} else {
test(label, async ({ request }, testInfo) => {
try {
await testFn({ request, testInfo });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
testInfo.annotations.push({
type: "fixme",
description: `[soft-fail] ${msg}`,
});
// eslint-disable-next-line no-console
console.warn(`⚠ SOFT-FAIL: ${label}\n ${msg}`);
}
});
}
}
// ---------------------------------------------------------------------------
// Protocol × transport permutation types
// ---------------------------------------------------------------------------
type Transport = "http1" | "h2c";
type Protocol = "rest" | "grpc-web" | "grpc" | "connectrpc";
type Encoding = "proto" | "json";
type ApiPermutation = {
protocol: Protocol;
encoding: Encoding;
transport: Transport;
/** Whether this permutation is already known to work (hard-fail on regression). */
knownWorking: boolean;
};
// ---------------------------------------------------------------------------
// V1 API permutations — AdminService/Healthz (unauthenticated, expects 200/OK)
// ---------------------------------------------------------------------------
const V1_GRPC_PATH = "/zitadel.admin.v1.AdminService/Healthz";
const V1_REST_PATH = "/admin/v1/healthz";
const v1Permutations: ApiPermutation[] = [
// REST
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC-web (HTTP/1.1 only — grpc-web is designed for browser HTTP/1.1)
{ protocol: "grpc-web", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "grpc-web", encoding: "json", transport: "http1", knownWorking: true },
// gRPC native (h2c only — requires HTTP/2)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// V2 API permutations — SessionService/ListSessions (requires auth → 401)
// ---------------------------------------------------------------------------
const V2_GRPC_PATH = "/zitadel.session.v2.SessionService/ListSessions";
const V2_REST_PATH = "/v2/sessions";
const v2Permutations: ApiPermutation[] = [
// REST (gRPC-gateway)
{ protocol: "rest", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "rest", encoding: "json", transport: "h2c", knownWorking: true },
// ConnectRPC (v2 only — v1 does not register Connect handlers)
{ protocol: "connectrpc", encoding: "proto", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "http1", knownWorking: true },
{ protocol: "connectrpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "connectrpc", encoding: "json", transport: "h2c", knownWorking: true },
// gRPC native (h2c only)
{ protocol: "grpc", encoding: "proto", transport: "h2c", knownWorking: true },
{ protocol: "grpc", encoding: "json", transport: "h2c", knownWorking: true },
];
// ---------------------------------------------------------------------------
// Tests — service-level reachability (non-API)
// ---------------------------------------------------------------------------
// The base URL comes from playwright.config.ts (PROXY_HTTP_PUBLISHED_PORT, default 8888).
// We resolve it here so h2Post()/h2Get() can connect to the same host.
const BASE =
process.env.PLAYWRIGHT_BASE_URL ??
`http://localhost:${process.env.PROXY_HTTP_PUBLISHED_PORT ?? "8888"}`;
test.describe("traefik routing", () => {
test("GET / → redirects to /ui/v2/login/", async ({ request }) => {
// Playwright follows redirects by default; verify the final destination.
// Note: Next.js normalises /ui/v2/login/ → /ui/v2/login (strips trailing slash).
const resp = await request.get("/");
expect(resp.url()).toContain("/ui/v2/login");
expect(resp.status()).toBe(200);
});
});
test.describe("login ui", () => {
test("GET /ui/v2/login/healthy → 200", async ({ request }) => {
const resp = await request.get("/ui/v2/login/healthy");
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);
expect(await resp.text()).toContain("<html");
});
});
test.describe("console", () => {
test("GET /ui/console/ → 200 HTML (Angular SPA shell)", async ({ request }) => {
const resp = await request.get("/ui/console/");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("<html");
});
});
test.describe("oidc", () => {
test("GET /.well-known/openid-configuration → valid discovery document", async ({ request }) => {
const resp = await request.get("/.well-known/openid-configuration");
expect(resp.status()).toBe(200);
const doc = await resp.json();
expect(doc).toHaveProperty("issuer");
expect(doc).toHaveProperty("authorization_endpoint");
expect(doc).toHaveProperty("token_endpoint");
expect(doc).toHaveProperty("jwks_uri");
});
test("GET /oauth/v2/keys → JWKS with keys array", async ({ request }) => {
const resp = await request.get("/oauth/v2/keys");
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body).toHaveProperty("keys");
expect(Array.isArray(body.keys)).toBe(true);
});
});
test.describe("saml", () => {
test("GET /saml/v2/metadata → EntityDescriptor XML", async ({ request }) => {
const resp = await request.get("/saml/v2/metadata");
expect(resp.status()).toBe(200);
expect(await resp.text()).toContain("EntityDescriptor");
});
});
test.describe("api v1 rest extras", () => {
// Additional REST tests beyond the matrix: all three v1 services + /api alias.
for (const svc of ["management", "admin", "auth"]) {
test(`GET /${svc}/v1/healthz → 200`, async ({ request }) => {
const resp = await request.get(`/${svc}/v1/healthz`);
expect(resp.status()).toBe(200);
});
}
test("GET /api/admin/v1/healthz via /api alias → 200 (tests Traefik strip-prefix middleware)", async ({ request }) => {
const resp = await request.get("/api/admin/v1/healthz");
expect(resp.status()).toBe(200);
});
});
// ---------------------------------------------------------------------------
// API V1 protocol × transport matrix
//
// Endpoint: AdminService/Healthz (unauthenticated)
// Expected: success (REST → 200, gRPC → grpc-status 0, gRPC-web → grpc-status 0)
// ---------------------------------------------------------------------------
test.describe("api v1 protocol matrix", () => {
for (const p of v1Permutations) {
const label = `v1 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.get(V1_REST_PATH);
expect(resp.status()).toBe(200);
} else {
const resp = await h2Get(BASE, V1_REST_PATH);
expect(resp.status).toBe(200);
}
break;
}
// ----- gRPC-web (HTTP/1.1) -----
case "grpc-web": {
const contentType =
p.encoding === "proto"
? "application/grpc-web+proto"
: "application/grpc-web+json";
// For json encoding, Healthz request is empty → JSON body is '{}'
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await request.post(V1_GRPC_PATH, {
headers: {
"content-type": contentType,
"x-grpc-web": "1",
},
data: reqBody,
});
expect(resp.status()).toBe(200);
// For proto encoding: parse inline trailers and assert grpc-status 0.
// For json encoding: the Go gRPC server has no JSON codec registered and
// returns no well-formed gRPC-web trailer frame. HTTP 200 alone proves
// the request was routed to the backend by Traefik.
if (p.encoding === "proto") {
const buf = Buffer.from(await resp.body());
const trailers = parseGrpcWebTrailers(buf);
expect(trailers["grpc-status"]).toBe("0");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V1_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
expect(resp.status).toBe(200);
// For proto encoding: assert OK (grpc-status 0).
// For json encoding: the Go gRPC server has no JSON codec registered,
// so it returns INTERNAL (13). We only assert the status is defined,
// which proves the request was routed to the backend.
if (p.encoding === "proto") {
expect(resp.trailers["grpc-status"]).toBe("0");
} else {
expect(resp.trailers["grpc-status"]).toBeDefined();
}
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});
// ---------------------------------------------------------------------------
// API V2 protocol × transport matrix
//
// Endpoint: SessionService/ListSessions (requires auth)
// Expected: 401 / UNAUTHENTICATED
// - REST (gRPC-gateway): HTTP 401, body.code === 16
// - ConnectRPC unary: HTTP 401, body.code === "unauthenticated"
// - gRPC native: HTTP 200, grpc-status trailer === "16"
// ---------------------------------------------------------------------------
test.describe("api v2 protocol matrix", () => {
for (const p of v2Permutations) {
const label = `v2 ${p.protocol} ${p.encoding} ${p.transport}`;
softTest(label, p.knownWorking, async ({ request }) => {
switch (p.protocol) {
// ----- REST (gRPC-gateway) -----
case "rest": {
if (p.transport === "http1") {
const resp = await request.post(V2_REST_PATH, {
headers: { "content-type": "application/json" },
data: "{}",
});
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe(16); // UNAUTHENTICATED
} else {
const resp = await h2Post(BASE, V2_REST_PATH, {
"content-type": "application/json",
}, "{}");
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe(16);
}
break;
}
// ----- Connect protocol unary (v2 only — v1 does not register Connect handlers) -----
// Per https://connectrpc.com/docs/multi-protocol/:
// application/json → Connect unary JSON (gRPC-style path)
// application/proto → Connect unary proto (gRPC-style path)
// application/connect+json and application/connect+proto are STREAMING-only.
// Connect unary uses no gRPC length-prefix framing; body is plain JSON or proto bytes.
// Error response format: { "code": "unauthenticated", ... }
case "connectrpc": {
const contentType =
p.encoding === "proto"
? "application/proto"
: "application/json";
// For connect+json, send a JSON body; for connect+proto, send empty bytes
const reqBody = p.encoding === "json" ? "{}" : Buffer.alloc(0);
if (p.transport === "http1") {
const resp = await request.post(V2_GRPC_PATH, {
headers: { "content-type": contentType },
data: reqBody,
});
// ConnectRPC returns the HTTP status directly
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.code).toBe("unauthenticated");
} else {
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
}, reqBody);
expect(resp.status).toBe(401);
const body = JSON.parse(resp.body.toString("utf8"));
expect(body.code).toBe("unauthenticated");
}
break;
}
// ----- gRPC native (h2c) -----
case "grpc": {
const contentType =
p.encoding === "proto"
? "application/grpc+proto"
: "application/grpc+json";
const reqBody =
p.encoding === "proto"
? grpcFrame()
: grpcFrame(Buffer.from("{}"));
const resp = await h2Post(BASE, V2_GRPC_PATH, {
"content-type": contentType,
te: "trailers",
}, reqBody);
// gRPC always returns HTTP 200; error is in grpc-status trailer
expect(resp.status).toBe(200);
expect(resp.trailers["grpc-status"]).toBe("16"); // UNAUTHENTICATED
break;
}
default:
throw new Error(`unexpected protocol: ${p.protocol}`);
}
});
}
});