rspace-online/modules/forum/lib/provisioner.ts

174 lines
6.3 KiB
TypeScript

/**
* 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<string, unknown> = {},
) {
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<string, unknown>) {
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() });
}