188 lines
9.1 KiB
TypeScript
188 lines
9.1 KiB
TypeScript
|
|
import { spawn } from 'node:child_process';
|
||
|
|
import { chmod, mkdir, mkdtemp, readFile, readlink, writeFile } from 'node:fs/promises';
|
||
|
|
import { resolve } from 'node:path';
|
||
|
|
import { setTimeout } from 'node:timers/promises';
|
||
|
|
import { bootstrap } from './setup.ts';
|
||
|
|
|
||
|
|
const root = resolve(import.meta.dirname, '..');
|
||
|
|
const serve = process.argv.includes('--serve');
|
||
|
|
const frontend = process.env.MAIL_IMAGE;
|
||
|
|
if (!frontend) throw new Error('MAIL_IMAGE must identify the built static frontend image');
|
||
|
|
const ports = serve
|
||
|
|
? { http: 8082, https: 8443, issuer: 18881, management: 18882, imap: 18143, smtp: 18587 }
|
||
|
|
: { http: 18082, https: 18443, issuer: 19881, management: 19882, imap: 19143, smtp: 19587 };
|
||
|
|
const issuer = `http://localhost:${ports.issuer}`;
|
||
|
|
const origins = [`http://localhost:${ports.http}`, `https://localhost:${ports.https}`];
|
||
|
|
const runRoot = resolve(root, '../../scratchpad/mail-keys/runs');
|
||
|
|
await mkdir(runRoot, { recursive: true });
|
||
|
|
const runtime = await mkdtemp(`${runRoot}/${serve ? 'preview' : 'run'}-`);
|
||
|
|
await chmod(runtime, 0o755);
|
||
|
|
const pod = `${serve ? 'mail-view' : 'mail-keys'}-${process.pid}`;
|
||
|
|
const images = {
|
||
|
|
db: 'docker.io/library/postgres:17.11-alpine@sha256:266d3c257594e195b309ae9d1805e54c788f1d7b375f56bf6c367aac6c917e7b',
|
||
|
|
idp: 'ghcr.io/zitadel/zitadel:v4.20.0@sha256:9ea0e9ecfdc43115da871242e40a0320f2ece864c7cd8b5d41a863a7f4902499',
|
||
|
|
mail: 'ghcr.io/stalwartlabs/stalwart:v0.16.15@sha256:4f926193e5dd9ceb1e24ba48160702310381b12e51972c2fb0cc9de020388136',
|
||
|
|
frontend,
|
||
|
|
};
|
||
|
|
await writeFile(`${runtime}/images.json`, JSON.stringify(images, null, 2));
|
||
|
|
|
||
|
|
async function command(args: string[], quiet = false): Promise<string> {
|
||
|
|
return invoke('podman', args, quiet);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function invoke(program: string, args: string[], quiet = false, env = process.env): Promise<string> {
|
||
|
|
return new Promise((accept, reject) => {
|
||
|
|
const child = spawn(program, args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||
|
|
let output = '';
|
||
|
|
child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); if (!quiet) process.stdout.write(chunk); });
|
||
|
|
child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); if (!quiet) process.stdout.write(chunk); });
|
||
|
|
child.on('error', reject);
|
||
|
|
child.on('close', (code) => {
|
||
|
|
if (code === 0) accept(output.trim());
|
||
|
|
else reject(new Error(`${program} ${args[0]} failed (${code})${quiet ? `: ${output}` : ''}`));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function certificate(): Promise<void> {
|
||
|
|
await new Promise<void>((accept, reject) => {
|
||
|
|
const child = spawn('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
||
|
|
'-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost',
|
||
|
|
'-keyout', `${runtime}/tls.key`, '-out', `${runtime}/tls.crt`], { stdio: 'ignore' });
|
||
|
|
child.on('error', reject);
|
||
|
|
child.on('close', code => code === 0 ? accept() : reject(new Error(`Synthetic certificate failed (${code})`)));
|
||
|
|
});
|
||
|
|
await chmod(`${runtime}/tls.key`, 0o644);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function ready(name: string, check: () => Promise<unknown>): Promise<void> {
|
||
|
|
const deadline = Date.now() + 90_000;
|
||
|
|
while (Date.now() < deadline) {
|
||
|
|
try { await check(); return; } catch { }
|
||
|
|
if ((await command(['inspect', '--format', '{{.State.Running}}', `${pod}-${name}`], true)) !== 'true') {
|
||
|
|
throw new Error(`${name} exited during initialization`);
|
||
|
|
}
|
||
|
|
await setTimeout(500);
|
||
|
|
}
|
||
|
|
throw new Error(`${name} did not become ready in 90 seconds`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const store = {
|
||
|
|
'@type': 'PostgreSql', host: '127.0.0.1', port: 5432, database: 'stalwart',
|
||
|
|
authUsername: 'postgres', authSecret: { '@type': 'EnvironmentVariable', variableName: 'SYNTHETIC_DB' },
|
||
|
|
useTls: false, allowInvalidCerts: false,
|
||
|
|
};
|
||
|
|
await writeFile(`${runtime}/store.json`, JSON.stringify(store));
|
||
|
|
await writeFile(`${runtime}/zitadel.yaml`, `
|
||
|
|
Port: ${ports.issuer}
|
||
|
|
ExternalDomain: localhost
|
||
|
|
ExternalPort: ${ports.issuer}
|
||
|
|
ExternalSecure: false
|
||
|
|
TLS:
|
||
|
|
Enabled: false
|
||
|
|
Database:
|
||
|
|
postgres:
|
||
|
|
Host: 127.0.0.1
|
||
|
|
Port: 5432
|
||
|
|
Database: zitadel
|
||
|
|
User:
|
||
|
|
Username: zitadel
|
||
|
|
Password: synthetic-zitadel
|
||
|
|
SSL:
|
||
|
|
Mode: disable
|
||
|
|
Admin:
|
||
|
|
Username: postgres
|
||
|
|
Password: synthetic-db
|
||
|
|
SSL:
|
||
|
|
Mode: disable
|
||
|
|
DefaultInstance:
|
||
|
|
Features:
|
||
|
|
LoginV2:
|
||
|
|
Required: false
|
||
|
|
FirstInstance:
|
||
|
|
PatPath: /fixture/bootstrap.pat
|
||
|
|
Org:
|
||
|
|
Name: Synthetic
|
||
|
|
Machine:
|
||
|
|
Machine:
|
||
|
|
Username: bootstrap
|
||
|
|
Name: Synthetic fixture setup
|
||
|
|
Pat:
|
||
|
|
ExpirationDate: 2030-01-01T00:00:00Z
|
||
|
|
Human:
|
||
|
|
UserName: admin@synthetic.test
|
||
|
|
Password: Synthetic-Admin-42!
|
||
|
|
PasswordChangeRequired: false
|
||
|
|
`);
|
||
|
|
|
||
|
|
const mount = `${runtime}:/fixture:Z`;
|
||
|
|
let keep = false;
|
||
|
|
try {
|
||
|
|
const publish = [[ports.issuer, ports.issuer], [ports.management, 18882], [ports.http, 8082], [ports.https, 8443], [ports.imap, 18143], [ports.smtp, 18587]].flatMap(([host, internal]) => ['-p', `127.0.0.1:${host}:${internal}`]);
|
||
|
|
await command(['pod', 'create', '--name', pod, ...publish]);
|
||
|
|
await command(['run', '-d', '--pod', pod, '--name', `${pod}-db`, '-e', 'POSTGRES_PASSWORD=synthetic-db', images.db]);
|
||
|
|
await ready('db', () => command(['exec', `${pod}-db`, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres'], true));
|
||
|
|
await command(['exec', `${pod}-db`, 'createdb', '-U', 'postgres', 'stalwart']);
|
||
|
|
await command(['run', '-d', '--pod', pod, '--name', `${pod}-idp`, '--user', '0', '-v', mount, images.idp,
|
||
|
|
'start-from-init', '--masterkey', '01234567890123456789012345678901', '--tlsMode', 'disabled',
|
||
|
|
'--config', '/fixture/zitadel.yaml', '--steps', '/fixture/zitadel.yaml']);
|
||
|
|
await command(['run', '-d', '--pod', pod, '--name', `${pod}-mail`, '-v', mount,
|
||
|
|
'-e', 'SYNTHETIC_DB=synthetic-db', '-e', 'STALWART_RECOVERY_MODE=true',
|
||
|
|
'-e', 'STALWART_RECOVERY_MODE_PORT=18882', '-e', 'STALWART_RECOVERY_ADMIN=synthetic-admin:synthetic-admin',
|
||
|
|
images.mail, '--config', '/fixture/store.json']);
|
||
|
|
await ready('idp', async () => {
|
||
|
|
const response = await fetch(`${issuer}/debug/ready`);
|
||
|
|
if (!response.ok) throw new Error('Zitadel not ready');
|
||
|
|
const token = (await readFile(`${runtime}/bootstrap.pat`, 'utf8')).trim();
|
||
|
|
const management = await fetch(`${issuer}/management/v1/orgs/me`, {
|
||
|
|
headers: { Authorization: `Bearer ${token}` },
|
||
|
|
});
|
||
|
|
if (!management.ok) throw new Error('Zitadel management not ready');
|
||
|
|
});
|
||
|
|
await ready('mail', async () => {
|
||
|
|
const response = await fetch(`http://localhost:${ports.management}/healthz/live`);
|
||
|
|
if (!response.ok) throw new Error('Stalwart not ready');
|
||
|
|
});
|
||
|
|
await certificate();
|
||
|
|
await bootstrap(runtime, store, { issuer, origins, management: ports.management });
|
||
|
|
await command(['stop', '--time', '5', `${pod}-mail`]);
|
||
|
|
await command(['rm', `${pod}-mail`]);
|
||
|
|
await command(['run', '-d', '--pod', pod, '--name', `${pod}-mail`, '-v', mount,
|
||
|
|
'-e', 'SYNTHETIC_DB=synthetic-db', images.mail, '--config', '/fixture/store.json']);
|
||
|
|
await ready('mail', async () => {
|
||
|
|
const response = await fetch(`http://localhost:${ports.management}/healthz/live`);
|
||
|
|
if (!response.ok) throw new Error('Stalwart not ready');
|
||
|
|
});
|
||
|
|
await command(['run', '-d', '--pod', pod, '--name', `${pod}-frontend`,
|
||
|
|
'-v', `${runtime}:/run/config:ro`, '-v', `${runtime}:/run/tls:ro`, images.frontend]);
|
||
|
|
await ready('frontend', async () => { const response = await fetch(`${origins[0]}/account/config.json`); if (!response.ok) throw new Error('Frontend not ready'); });
|
||
|
|
const hostNs = await readlink('/proc/self/ns/net');
|
||
|
|
const podNs = await command(['exec', `${pod}-mail`, '/bin/sh', '-c', 'readlink /proc/self/ns/net'], true);
|
||
|
|
await writeFile(`${runtime}/fixture.json`, JSON.stringify({ runtime, pod, ports, issuer, origins, hostNs, podNs, frontend }, null, 2));
|
||
|
|
if (serve) {
|
||
|
|
const url = `${origins[0]}/account/`;
|
||
|
|
const response = await fetch(url);
|
||
|
|
if (!response.ok) throw new Error(`Preview endpoint returned HTTP ${response.status}`);
|
||
|
|
const preview = { url, pod, runtime, stop: `podman pod rm --force --time 2 ${pod}` };
|
||
|
|
await writeFile(`${runtime}/preview.json`, JSON.stringify(preview, null, 2));
|
||
|
|
keep = true;
|
||
|
|
console.log(JSON.stringify(preview, null, 2));
|
||
|
|
} else {
|
||
|
|
const cache = resolve(root, '../../scratchpad/mail-keys/cache');
|
||
|
|
await mkdir(`${runtime}/tmp`, { recursive: true });
|
||
|
|
const result = await invoke(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', '--config', 'tests/playwright.ts'], false, { ...process.env, MAIL_RUN: runtime, TMPDIR: `${runtime}/tmp`, XDG_CACHE_HOME: `${cache}/xdg`, PLAYWRIGHT_BROWSERS_PATH: `${cache}/browsers` });
|
||
|
|
await writeFile(`${runtime}/playwright.log`, result);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
for (const name of ['idp', 'mail', 'db', 'frontend']) {
|
||
|
|
try { await writeFile(`${runtime}/${name}.log`, await command(['logs', '--tail', '150', `${pod}-${name}`], true)); } catch { }
|
||
|
|
}
|
||
|
|
if (keep) {
|
||
|
|
console.log(`Preview remains running; evidence: ${runtime}`);
|
||
|
|
} else {
|
||
|
|
await command(['pod', 'rm', '--force', '--time', '2', pod]);
|
||
|
|
const names = await command(['ps', '-a', '--filter', `pod=${pod}`, '--format', '{{.Names}}'], true);
|
||
|
|
if (names) throw new Error(`Fixture containers remain: ${names}`);
|
||
|
|
console.log(`Fixture stopped; evidence: ${runtime}`);
|
||
|
|
}
|
||
|
|
}
|