/** * Forum instance provisioner — async pipeline that creates a VPS, * configures DNS, installs Discourse, and verifies it's live. */ import { sql } from "../../../shared/db/pool"; import { createServer, getServer, deleteServer } from "./hetzner"; import { createDNSRecord, deleteDNSRecord } from "./dns"; import { generateCloudInit, type DiscourseConfig } from "./cloud-init"; type StepStatus = "running" | "success" | "error" | "skipped"; async function logStep( instanceId: string, step: string, status: StepStatus, message: string, metadata: Record = {}, ) { if (status === "running") { await sql.unsafe( `INSERT INTO rforum.provision_logs (instance_id, step, status, message, metadata) VALUES ($1, $2, $3, $4, $5::jsonb)`, [instanceId, step, status, message, JSON.stringify(metadata)], ); } else { await sql.unsafe( `UPDATE rforum.provision_logs SET status = $1, message = $2, metadata = $3::jsonb, completed_at = NOW() WHERE instance_id = $4 AND step = $5 AND status = 'running'`, [status, message, JSON.stringify(metadata), instanceId, step], ); } } async function updateInstance(instanceId: string, fields: Record) { const sets: string[] = []; const params: unknown[] = []; let idx = 1; for (const [key, val] of Object.entries(fields)) { sets.push(`${key} = $${idx}`); params.push(val); idx++; } sets.push("updated_at = NOW()"); params.push(instanceId); await sql.unsafe(`UPDATE rforum.instances SET ${sets.join(", ")} WHERE id = $${idx}`, params); } async function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } export async function provisionInstance(instanceId: string) { const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]); if (!instance) throw new Error("Instance not found"); await updateInstance(instanceId, { status: "provisioning" }); try { // Step 1: Create VPS await logStep(instanceId, "create_vps", "running", "Creating VPS..."); const config: DiscourseConfig = { hostname: instance.domain, adminEmail: instance.admin_email, ...(instance.smtp_config?.host ? { smtpHost: instance.smtp_config.host, smtpPort: instance.smtp_config.port, smtpUser: instance.smtp_config.user, smtpPassword: instance.smtp_config.password, } : {}), }; const userData = generateCloudInit(config); const { serverId, ip } = await createServer({ name: `discourse-${instance.domain.replace(/\./g, "-")}`, serverType: instance.size, region: instance.region, userData, }); await updateInstance(instanceId, { vps_id: serverId, vps_ip: ip }); await logStep(instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip }); // Step 2: Wait for boot await logStep(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) { await logStep(instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes"); await updateInstance(instanceId, { status: "error", error_message: "VPS boot timeout" }); return; } await logStep(instanceId, "wait_ready", "success", "VPS is running"); // Step 3: Configure DNS await logStep(instanceId, "configure_dns", "running", "Configuring DNS..."); const subdomain = instance.domain.replace(".rforum.online", ""); const dns = await createDNSRecord(subdomain, ip); if (dns) { await updateInstance(instanceId, { dns_record_id: dns.recordId }); await logStep(instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`); } else { await logStep(instanceId, "configure_dns", "skipped", "DNS configuration skipped — configure manually"); } // Step 4: Wait for Discourse install await updateInstance(instanceId, { status: "installing" }); await logStep(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) { await logStep(instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes"); await updateInstance(instanceId, { status: "error", error_message: "Discourse install timeout" }); return; } await logStep(instanceId, "install_discourse", "success", "Discourse is responding"); // Step 5: Verify live await updateInstance(instanceId, { status: "configuring" }); await logStep(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) { await updateInstance(instanceId, { status: "active", ssl_provisioned: true, provisioned_at: new Date().toISOString(), }); await logStep(instanceId, "verify_live", "success", "Forum is live with SSL!"); } else { await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() }); await logStep(instanceId, "verify_live", "success", "Forum is live (SSL pending)"); } } catch { await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() }); await logStep(instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)"); } } catch (e: any) { console.error("[Forum] Provisioning error:", e); await updateInstance(instanceId, { status: "error", error_message: e.message }); await logStep(instanceId, "unknown", "error", e.message); } } export async function destroyInstance(instanceId: string) { const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]); if (!instance) return; await updateInstance(instanceId, { status: "destroying" }); if (instance.vps_id) { await deleteServer(instance.vps_id); } if (instance.dns_record_id) { await deleteDNSRecord(instance.dns_record_id); } await updateInstance(instanceId, { status: "destroyed", destroyed_at: new Date().toISOString() }); }