174 lines
6.3 KiB
TypeScript
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() });
|
|
}
|