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

201 lines
6.8 KiB
TypeScript

/**
* 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<string, unknown> = {},
) {
const now = Date.now();
syncServer.changeDoc<ForumDoc>(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<ForumDoc>(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<ForumDoc>(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<ForumDoc>(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() });
}