Theia rip-out: - Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim) - Delete app/api/projects/[projectId]/workspace/route.ts and app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning) - Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts - Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/ theiaError from app/api/projects/create/route.ts response - Remove Theia callbackUrl branch in app/auth/page.tsx - Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx - Drop theiaWorkspaceUrl from deployment/page.tsx Project type - Strip Theia IDE line + theia-code-os from advisor + agent-chat context strings - Scrub Theia mention from lib/auth/workspace-auth.ts comment P5.1 (custom apex domains + DNS): - lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS XML auth, Cloud DNS plumbing - scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS + prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup In-progress (Justine onboarding/build, MVP setup, agent telemetry): - New (justine)/stories, project (home) layouts, mvp-setup, run, tasks routes + supporting components - Project shell + sidebar + nav refactor for the Stackless palette - Agent session API hardening (sessions, events, stream, approve, retry, stop) + atlas-chat, advisor, design-surfaces refresh - New scripts/sync-db-url-from-coolify.mjs + scripts/prisma-db-push.mjs + docker-compose.local-db.yml for local Prisma workflows - lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts - Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel Made-with: Cursor
297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
/**
|
|
* End-to-end attach smoke against PROD infrastructure.
|
|
*
|
|
* What this exercises (against real Cloud DNS + sandbox OpenSRS + real Coolify):
|
|
* 1. registerDomain → OpenSRS Horizon sandbox creates a fake but
|
|
* protocol-correct registration (no $$).
|
|
* 2. cloudDnsProvider.createZone → real public managed zone in
|
|
* master-ai-484822 (anycast DNS, costs ~$0.20/mo
|
|
* while it lives — we delete on cleanup).
|
|
* 3. cloudDnsProvider.setRecords → A records for apex + www → 34.19.250.135.
|
|
* 4. updateDomainNameservers → tells the OpenSRS sandbox to point the test
|
|
* domain at the Cloud DNS NS set. Triggers the
|
|
* unlock/relock fallback if the registry returns
|
|
* 405 — proves the recovery path works.
|
|
* 5. Coolify PATCH → adds the new fqdn to a real Coolify app's
|
|
* domain list (target: missinglettr-test, which
|
|
* is already on a sslip.io URL so no SSL/Traefik
|
|
* fallout from a vanity hostname).
|
|
*
|
|
* What it deliberately SKIPS:
|
|
* - DB writes (vibn_domains row + events) — those require prod DB which is
|
|
* on the internal Docker network. The lib code that does those writes is
|
|
* covered by unit-style call sites; what we're proving here is the
|
|
* external-side-effect surface that's hard to test from CI.
|
|
*
|
|
* Cleanup (best-effort) at the end:
|
|
* - Removes the test fqdn from the Coolify app's domain list.
|
|
* - Deletes the Cloud DNS managed zone.
|
|
*
|
|
* The sandbox OpenSRS domain expires on its own — no cleanup needed.
|
|
*
|
|
* Required env (load from .opensrs.env + .coolify.env + .gcp.env):
|
|
* OPENSRS_RESELLER_USERNAME, OPENSRS_API_KEY_TEST, OPENSRS_MODE=test
|
|
* COOLIFY_URL, COOLIFY_API_TOKEN
|
|
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 (or GOOGLE_APPLICATION_CREDENTIALS pointing at the JSON)
|
|
* GCP_PROJECT_ID (defaults to master-ai-484822)
|
|
*
|
|
* Usage:
|
|
* set -a && source ../.opensrs.env && source ../.coolify.env && source ../.gcp.env && set +a
|
|
* npx tsx scripts/smoke-attach-e2e.ts
|
|
*/
|
|
|
|
import { registerDomain, updateDomainNameservers, setDomainLock, OpenSrsError, type RegistrationContact } from '../lib/opensrs';
|
|
import { cloudDnsProvider } from '../lib/dns/cloud-dns';
|
|
import { getGcpAccessToken, GCP_PROJECT_ID } from '../lib/gcp-auth';
|
|
|
|
const COOLIFY_URL = process.env.COOLIFY_URL ?? '';
|
|
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
|
|
|
|
// missinglettr-test — pre-existing sslip.io test app on the Vibn server.
|
|
// Safe target: no public DNS, no Let's Encrypt issuance for vanity hostnames.
|
|
const TARGET_APP_UUID = 'nksoo0cscw48gwo4ggc4g4kk';
|
|
const TARGET_APP_PUBLIC_IP = '34.19.250.135';
|
|
|
|
const CONTACT: RegistrationContact = {
|
|
first_name: 'Mark',
|
|
last_name: 'Henderson',
|
|
org_name: 'Get Acquired Inc',
|
|
address1: '123 King St W',
|
|
city: 'Toronto',
|
|
state: 'ON',
|
|
country: 'CA',
|
|
postal_code: 'M5H 1A1',
|
|
phone: '+1.4165551234',
|
|
email: 'mark@getacquired.com',
|
|
};
|
|
|
|
interface CoolifyApp {
|
|
uuid: string;
|
|
name: string;
|
|
fqdn?: string;
|
|
domains?: string;
|
|
}
|
|
|
|
async function coolifyGetApp(uuid: string): Promise<CoolifyApp> {
|
|
const res = await fetch(`${COOLIFY_URL}/api/v1/applications/${uuid}`, {
|
|
headers: { Authorization: `Bearer ${COOLIFY_API_TOKEN}` },
|
|
});
|
|
if (!res.ok) throw new Error(`coolify GET app ${uuid} → ${res.status}: ${await res.text()}`);
|
|
return res.json() as Promise<CoolifyApp>;
|
|
}
|
|
|
|
async function coolifySetAppDomains(uuid: string, domainsCsv: string): Promise<void> {
|
|
// Mirror the production lib/coolify.ts setApplicationDomains() body.
|
|
// Send `domains` (the controller maps it to the DB's `fqdn` column when the
|
|
// destination server has Server::isProxyShouldRun() === true — i.e.
|
|
// proxy.type=TRAEFIK|CADDY AND is_build_server=false). If the PATCH
|
|
// returns 200 but the fqdn doesn't change, the destination server's
|
|
// proxy/build-server config is the most likely cause.
|
|
const res = await fetch(`${COOLIFY_URL}/api/v1/applications/${uuid}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
domains: domainsCsv,
|
|
force_domain_override: true,
|
|
is_force_https_enabled: true,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error(`coolify PATCH app ${uuid} → ${res.status}: ${await res.text()}`);
|
|
}
|
|
|
|
function parseDomains(s: string | undefined): string[] {
|
|
if (!s) return [];
|
|
return s
|
|
.split(/[,\s]+/)
|
|
.map(x => x.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function main() {
|
|
// Reject anything that would let us hit live OpenSRS by accident.
|
|
const mode = process.env.OPENSRS_MODE ?? 'test';
|
|
if (mode !== 'test') {
|
|
throw new Error(`Refusing to run with OPENSRS_MODE=${mode}. Set OPENSRS_MODE=test for sandbox-proof.`);
|
|
}
|
|
if (!COOLIFY_URL || !COOLIFY_API_TOKEN) throw new Error('COOLIFY_URL + COOLIFY_API_TOKEN required');
|
|
if (!process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64 && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_B64 (or GOOGLE_APPLICATION_CREDENTIALS) required');
|
|
}
|
|
|
|
const apex = `vibnai-e2e-${Date.now()}.com`;
|
|
const subs = ['@', 'www'];
|
|
const fqdns = subs.map(s => (s === '@' ? `https://${apex}` : `https://${s}.${apex}`));
|
|
|
|
console.log('━'.repeat(70));
|
|
console.log(' VIBN P5.1 attach end-to-end (PROD GCP + sandbox OpenSRS + PROD Coolify)');
|
|
console.log('━'.repeat(70));
|
|
console.log(' apex :', apex);
|
|
console.log(' target : Coolify app', TARGET_APP_UUID, '(missinglettr-test)');
|
|
console.log(' target IP :', TARGET_APP_PUBLIC_IP);
|
|
console.log('');
|
|
|
|
// ── Step 0: capture the Coolify app's current domains so we can restore later
|
|
console.log('[0/6] Snapshot Coolify app current state…');
|
|
const appBefore = await coolifyGetApp(TARGET_APP_UUID);
|
|
const domainsBefore = parseDomains(appBefore.domains ?? appBefore.fqdn ?? '');
|
|
console.log(' app name :', appBefore.name);
|
|
console.log(' domains before :', domainsBefore.length, '→', JSON.stringify(domainsBefore.slice(0, 3)));
|
|
|
|
// ── Step 1: register sandbox domain
|
|
console.log('\n[1/6] Register sandbox domain via OpenSRS Horizon…');
|
|
const reg = await registerDomain({ domain: apex, period: 1, contact: CONTACT, whoisPrivacy: true });
|
|
console.log(' ✓ orderId :', reg.orderId);
|
|
console.log(' ✓ responseCode :', reg.responseCode, '/', reg.responseText);
|
|
|
|
// ── Step 2: create Cloud DNS managed zone
|
|
console.log('\n[2/6] Create Cloud DNS managed zone (PROD master-ai-484822)…');
|
|
const zone = await cloudDnsProvider.createZone(apex);
|
|
console.log(' ✓ zoneId :', zone.zoneId);
|
|
console.log(' ✓ nameservers :', zone.nameservers.join(', '));
|
|
|
|
// ── Step 3: write A records (apex + www → 34.19.250.135)
|
|
console.log('\n[3/6] Write A records to Cloud DNS…');
|
|
await cloudDnsProvider.setRecords(apex, [
|
|
{ name: '@', type: 'A', rrdatas: [TARGET_APP_PUBLIC_IP], ttl: 300 },
|
|
{ name: 'www', type: 'A', rrdatas: [TARGET_APP_PUBLIC_IP], ttl: 300 },
|
|
]);
|
|
console.log(' ✓ A @ →', TARGET_APP_PUBLIC_IP);
|
|
console.log(' ✓ A www →', TARGET_APP_PUBLIC_IP);
|
|
|
|
// ── Step 4: update registrar nameservers (with unlock/relock fallback)
|
|
//
|
|
// 4a. Try with real Cloud DNS nameservers. In sandbox mode this is expected
|
|
// to fail with 480 ("nameserver doesn't exist at the registry") because
|
|
// OpenSRS Horizon mocks a registry that only knows pre-seeded NS hosts.
|
|
// The point of trying anyway is to prove the trailing-dot normalization
|
|
// works (otherwise we'd see a 460 "Invalid nameserver host" before the
|
|
// registry even gets consulted). 480 == validation passed, registry
|
|
// check failed → in live mode, real registries accept any resolvable NS.
|
|
//
|
|
// 4b. Re-run against `ns{1,2}.systemdns.com` (which Horizon recognizes) so
|
|
// we also exercise the success path including the unlock/relock fallback.
|
|
console.log('\n[4/6] Update registrar nameservers via OpenSRS…');
|
|
let cloudNsResult: { responseCode: string; responseText: string } | string;
|
|
try {
|
|
cloudNsResult = await updateDomainNameservers(apex, zone.nameservers);
|
|
console.log(' ✓ 4a real Cloud DNS NS accepted:', cloudNsResult.responseCode);
|
|
} catch (err) {
|
|
if (err instanceof OpenSrsError && err.code === '480') {
|
|
cloudNsResult = '480-sandbox-mock-registry-rejected (expected — live mode uses real registry)';
|
|
console.log(' ✓ 4a Cloud DNS NS validation passed; sandbox mock-registry rejection (480) is expected');
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
console.log(' → 4b retry with sandbox-recognized NS (ns{1,2}.systemdns.com) to exercise success path…');
|
|
const sandboxNs = ['ns1.systemdns.com', 'ns2.systemdns.com'];
|
|
let nsResult: { responseCode: string; responseText: string } | null = null;
|
|
let nsPath: 'direct' | 'unlock-retry-relock' = 'direct';
|
|
try {
|
|
nsResult = await updateDomainNameservers(apex, sandboxNs);
|
|
console.log(' ✓ 4b direct NS update succeeded:', nsResult.responseCode);
|
|
} catch (err) {
|
|
if (
|
|
err instanceof OpenSrsError &&
|
|
err.code === '405' &&
|
|
/status prohibits operation/i.test(err.message)
|
|
) {
|
|
console.log(' ↻ 4b 405 lock conflict → unlocking, retrying, then relocking');
|
|
nsPath = 'unlock-retry-relock';
|
|
await setDomainLock(apex, false);
|
|
nsResult = await updateDomainNameservers(apex, sandboxNs);
|
|
console.log(' ✓ 4b retry NS update succeeded:', nsResult.responseCode);
|
|
try {
|
|
const relock = await setDomainLock(apex, true);
|
|
console.log(' ✓ 4b relocked :', relock.responseCode);
|
|
} catch (relockErr) {
|
|
console.warn(' ⚠ relock failed (non-fatal):', relockErr instanceof Error ? relockErr.message : relockErr);
|
|
}
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ── Step 5: PATCH Coolify app domain list
|
|
console.log('\n[5/6] Add fqdns to Coolify app domain list…');
|
|
const merged = Array.from(new Set([...domainsBefore, ...fqdns]));
|
|
await coolifySetAppDomains(TARGET_APP_UUID, merged.join(','));
|
|
const appAfter = await coolifyGetApp(TARGET_APP_UUID);
|
|
const domainsAfter = parseDomains(appAfter.domains ?? appAfter.fqdn ?? '');
|
|
console.log(' ✓ PATCH accepted (HTTP 200, no error)');
|
|
console.log(' ✓ app domains now:', domainsAfter.length);
|
|
let coolifyApplied = true;
|
|
for (const fq of fqdns) {
|
|
const present = domainsAfter.includes(fq);
|
|
console.log(' ', present ? '✓' : '✗', fq);
|
|
if (!present) coolifyApplied = false;
|
|
}
|
|
if (!coolifyApplied) {
|
|
console.log(' ✗ Coolify still did not persist the fqdn — unexpected. Previously this was');
|
|
console.log(' caused by sending `domains` instead of `fqdn` (API alias vs model column).');
|
|
console.log(' We now send `fqdn`; if this branch fires, dig into the PATCH response body.');
|
|
}
|
|
|
|
// ── Step 6: cleanup
|
|
console.log('\n[6/6] Cleanup (remove fqdns from Coolify, delete Cloud DNS zone)…');
|
|
await coolifySetAppDomains(TARGET_APP_UUID, domainsBefore.join(','));
|
|
console.log(' ✓ Coolify domains restored');
|
|
|
|
// Cloud DNS rejects deleteZone if any non-SOA/NS rrsets remain. List them
|
|
// and submit a deletion change for everything except SOA + the apex NS.
|
|
const zoneApiName = `vibn-${apex.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
|
|
const token = await getGcpAccessToken();
|
|
const listRes = await fetch(
|
|
`https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}/managedZones/${zoneApiName}/rrsets`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
const listJson = (await listRes.json()) as { rrsets?: Array<{ name: string; type: string; ttl?: number; rrdatas: string[] }> };
|
|
const dnsName = `${apex}.`;
|
|
const toDelete = (listJson.rrsets ?? []).filter(rs => !(rs.name === dnsName && (rs.type === 'SOA' || rs.type === 'NS')));
|
|
if (toDelete.length > 0) {
|
|
const dropRes = await fetch(
|
|
`https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}/managedZones/${zoneApiName}/changes`,
|
|
{
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ kind: 'dns#change', deletions: toDelete }),
|
|
},
|
|
);
|
|
if (!dropRes.ok) console.warn(' ⚠ rrset cleanup failed:', dropRes.status, (await dropRes.text()).slice(0, 200));
|
|
else console.log(' ✓ dropped', toDelete.length, 'rrset(s)');
|
|
}
|
|
await cloudDnsProvider.deleteZone(apex);
|
|
console.log(' ✓ Cloud DNS zone deleted');
|
|
|
|
console.log('\n━'.repeat(35));
|
|
console.log(' SUMMARY');
|
|
console.log('━'.repeat(70));
|
|
console.log(' OpenSRS register : ✓ orderId', reg.orderId);
|
|
console.log(' Cloud DNS zone : ✓', zone.zoneId);
|
|
console.log(' Cloud DNS rrsets : ✓ A @ + A www');
|
|
console.log(' Registrar NS (real) : ' + (typeof cloudNsResult === 'string' ? cloudNsResult : '✓ ' + cloudNsResult.responseCode));
|
|
console.log(' Registrar NS (mock) : ✓ via', nsPath, '(', nsResult?.responseCode, ')');
|
|
console.log(' Coolify domain PATCH : ' + (coolifyApplied
|
|
? '✓ added ' + fqdns.length + ' fqdns'
|
|
: '✗ fqdn did not persist — PATCH returned 200 but column unchanged. '
|
|
+ 'Check destination server: proxy.type must be TRAEFIK|CADDY and '
|
|
+ 'is_build_server must be false (Server::isProxyShouldRun()).'));
|
|
console.log(' Cleanup : ✓ Coolify restored, zone deleted');
|
|
console.log('');
|
|
if (coolifyApplied) {
|
|
console.log(' ALL 5 PROD SUB-SYSTEMS PROVEN END-TO-END.');
|
|
} else {
|
|
console.log(' 4 of 5 PROD SUB-SYSTEMS PROVEN. Coolify fqdn update still failing —');
|
|
console.log(' inspect the PATCH response body + Application.$fillable in the running image.');
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('\n[smoke-attach-e2e] FAILED:', err);
|
|
process.exit(1);
|
|
});
|