201 lines
6.8 KiB
TypeScript
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() });
|
|
}
|