/** * Forum instance provisioner — async pipeline that creates a VPS, * configures DNS, installs Discourse, and verifies it's live. */ import type { SyncServer } from "../../../server/local-first/sync-server"; import { FORUM_DOC_ID, type ForumDoc, type StepStatus } from "../schemas"; import { createServer, getServer, deleteServer } from "./hetzner"; import { createDNSRecord, deleteDNSRecord } from "./dns"; import { generateCloudInit, type DiscourseConfig } from "./cloud-init"; function logStep( syncServer: SyncServer, instanceId: string, step: string, status: StepStatus, message: string, metadata: Record = {}, ) { const now = Date.now(); syncServer.changeDoc(FORUM_DOC_ID, `log ${step} ${status}`, (d) => { if (!d.provisionLogs[instanceId]) d.provisionLogs[instanceId] = []; const logs = d.provisionLogs[instanceId]; if (status === "running") { logs.push({ id: crypto.randomUUID(), step, status, message, metadata, startedAt: now, completedAt: 0, }); } else { // Find the running entry for this step and update it for (let i = logs.length - 1; i >= 0; i--) { if (logs[i].step === step && logs[i].status === "running") { logs[i].status = status; logs[i].message = message; logs[i].metadata = metadata; logs[i].completedAt = now; break; } } } }); } function updateInstance( syncServer: SyncServer, instanceId: string, fields: Partial<{ status: string; errorMessage: string; vpsId: string; vpsIp: string; dnsRecordId: string; sslProvisioned: boolean; provisionedAt: number; destroyedAt: number; }>, ) { syncServer.changeDoc(FORUM_DOC_ID, `update instance ${instanceId}`, (d) => { const inst = d.instances[instanceId]; if (!inst) return; for (const [key, val] of Object.entries(fields)) { (inst as any)[key] = val; } inst.updatedAt = Date.now(); }); } async function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } export async function provisionInstance(syncServer: SyncServer, instanceId: string) { const doc = syncServer.getDoc(FORUM_DOC_ID); const instance = doc?.instances[instanceId]; if (!instance) throw new Error("Instance not found"); updateInstance(syncServer, instanceId, { status: "provisioning" }); try { // Step 1: Create VPS logStep(syncServer, instanceId, "create_vps", "running", "Creating VPS..."); const config: DiscourseConfig = { hostname: instance.domain, adminEmail: instance.adminEmail, ...(instance.smtpConfig?.host ? { smtpHost: instance.smtpConfig.host as string, smtpPort: instance.smtpConfig.port as number, smtpUser: instance.smtpConfig.user as string, smtpPassword: instance.smtpConfig.password as string, } : {}), }; const userData = generateCloudInit(config); const { serverId, ip } = await createServer({ name: `discourse-${instance.domain.replace(/\./g, "-")}`, serverType: instance.size, region: instance.region, userData, }); updateInstance(syncServer, instanceId, { vpsId: serverId, vpsIp: ip }); logStep(syncServer, instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip }); // Step 2: Wait for boot logStep(syncServer, instanceId, "wait_ready", "running", "Waiting for VPS to boot..."); let booted = false; for (let i = 0; i < 60; i++) { await sleep(5000); const server = await getServer(serverId); if (server?.status === "running") { booted = true; break; } } if (!booted) { logStep(syncServer, instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes"); updateInstance(syncServer, instanceId, { status: "error", errorMessage: "VPS boot timeout" }); return; } logStep(syncServer, instanceId, "wait_ready", "success", "VPS is running"); // Step 3: Configure DNS logStep(syncServer, instanceId, "configure_dns", "running", "Configuring DNS..."); const subdomain = instance.domain.replace(".rforum.online", ""); const dns = await createDNSRecord(subdomain, ip); if (dns) { updateInstance(syncServer, instanceId, { dnsRecordId: dns.recordId }); logStep(syncServer, instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`); } else { logStep(syncServer, instanceId, "configure_dns", "skipped", "DNS configuration skipped — configure manually"); } // Step 4: Wait for Discourse install updateInstance(syncServer, instanceId, { status: "installing" }); logStep(syncServer, instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)..."); let installed = false; for (let i = 0; i < 60; i++) { await sleep(15000); try { const res = await fetch(`http://${ip}`, { redirect: "manual" }); if (res.status === 200 || res.status === 302) { installed = true; break; } } catch {} } if (!installed) { logStep(syncServer, instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes"); updateInstance(syncServer, instanceId, { status: "error", errorMessage: "Discourse install timeout" }); return; } logStep(syncServer, instanceId, "install_discourse", "success", "Discourse is responding"); // Step 5: Verify live updateInstance(syncServer, instanceId, { status: "configuring" }); logStep(syncServer, instanceId, "verify_live", "running", "Verifying Discourse is live..."); try { const res = await fetch(`https://${instance.domain}`, { redirect: "manual" }); if (res.status === 200 || res.status === 302) { updateInstance(syncServer, instanceId, { status: "active", sslProvisioned: true, provisionedAt: Date.now(), }); logStep(syncServer, instanceId, "verify_live", "success", "Forum is live with SSL!"); } else { updateInstance(syncServer, instanceId, { status: "active", provisionedAt: Date.now() }); logStep(syncServer, instanceId, "verify_live", "success", "Forum is live (SSL pending)"); } } catch { updateInstance(syncServer, instanceId, { status: "active", provisionedAt: Date.now() }); logStep(syncServer, instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)"); } } catch (e: any) { console.error("[Forum] Provisioning error:", e); updateInstance(syncServer, instanceId, { status: "error", errorMessage: e.message }); logStep(syncServer, instanceId, "unknown", "error", e.message); } } export async function destroyInstance(syncServer: SyncServer, instanceId: string) { const doc = syncServer.getDoc(FORUM_DOC_ID); const instance = doc?.instances[instanceId]; if (!instance) return; updateInstance(syncServer, instanceId, { status: "destroying" }); if (instance.vpsId) { await deleteServer(instance.vpsId); } if (instance.dnsRecordId) { await deleteDNSRecord(instance.dnsRecordId); } updateInstance(syncServer, instanceId, { status: "destroyed", destroyedAt: Date.now() }); }