bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

multi-instance-init.js (12899B)


      1 const fs = require("fs");
      2 const path = require("path");
      3 const { spawnSync } = require("child_process");
      4 
      5 const ROOT = path.join(__dirname, "..");
      6 const MULTI_DIR = path.join(ROOT, "multi_instance");
      7 const DEFAULT_CONFIG_PATH = path.join(MULTI_DIR, "instances.json");
      8 const DEFAULT_EXAMPLE_PATH = path.join(MULTI_DIR, "instances.example.json");
      9 
     10 function log(msg) {
     11   console.log(`[multi-init] ${msg}`);
     12 }
     13 
     14 function warn(msg) {
     15   console.warn(`[multi-init] WARN: ${msg}`);
     16 }
     17 
     18 function fail(msg) {
     19   console.error(`[multi-init] ERROR: ${msg}`);
     20   process.exit(1);
     21 }
     22 
     23 function parseArgs() {
     24   const args = process.argv.slice(2);
     25   const out = {};
     26   for (const raw of args) {
     27     const a = String(raw || "");
     28     if (!a.startsWith("--")) continue;
     29     const eq = a.indexOf("=");
     30     if (eq > -1) {
     31       out[a.slice(2, eq)] = a.slice(eq + 1);
     32       continue;
     33     }
     34     out[a.slice(2)] = "1";
     35   }
     36   return out;
     37 }
     38 
     39 function expandHome(p) {
     40   const value = String(p || "").trim();
     41   if (!value.startsWith("~")) return value;
     42   const home = process.env.HOME || process.env.USERPROFILE || "";
     43   if (!home) return value;
     44   if (value === "~") return home;
     45   if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(home, value.slice(2));
     46   return value;
     47 }
     48 
     49 function readJson(filePath) {
     50   try {
     51     return JSON.parse(fs.readFileSync(filePath, "utf8"));
     52   } catch (e) {
     53     fail(`Failed to read JSON: ${filePath} (${e?.message || e})`);
     54   }
     55 }
     56 
     57 function writeText(filePath, body) {
     58   fs.mkdirSync(path.dirname(filePath), { recursive: true });
     59   fs.writeFileSync(filePath, body, "utf8");
     60 }
     61 
     62 function displayPath(filePath) {
     63   const abs = path.resolve(filePath);
     64   const rel = path.relative(process.cwd(), abs);
     65   if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/");
     66   return abs.replace(/\\/g, "/");
     67 }
     68 
     69 function envValue(v) {
     70   const raw = String(v ?? "");
     71   if (!raw) return "";
     72   if (/^[A-Za-z0-9._:@/+,-]+$/.test(raw)) return raw;
     73   return `"${raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
     74 }
     75 
     76 function looksLikeHost(h) {
     77   const host = String(h || "").trim().toLowerCase();
     78   if (!host) return false;
     79   if (host.includes("://")) return false;
     80   if (host.length > 253) return false;
     81   if (!/^[a-z0-9.-]+$/.test(host)) return false;
     82   if (host.startsWith(".") || host.endsWith(".") || host.includes("..")) return false;
     83   return host.includes(".");
     84 }
     85 
     86 function normalizeId(s) {
     87   return String(s || "")
     88     .trim()
     89     .toLowerCase()
     90     .replace(/[^a-z0-9-]/g, "-")
     91     .replace(/-+/g, "-")
     92     .replace(/^-|-$/g, "")
     93     .slice(0, 32);
     94 }
     95 
     96 function templateConfig() {
     97   return {
     98     projectName: "bzl-multi",
     99     networkName: "bzl_multi_net",
    100     cloudflared: {
    101       enabled: true,
    102       tunnel: "bzl",
    103       routeDns: false,
    104       overwriteDns: true,
    105       configPath: "~/.cloudflared/config.yml",
    106       credentialsFile: "~/.cloudflared/TUNNEL-UUID.json"
    107     },
    108     instances: [
    109       {
    110         id: "official",
    111         hostname: "chat.example.com",
    112         hostPort: 3301,
    113         registrationCode: "replace-with-random-code",
    114         env: {
    115           SESSION_TTL_MS: "2592000000",
    116           DEFAULT_TTL_MS: "3600000"
    117         }
    118       },
    119       {
    120         id: "staging",
    121         hostname: "staging.example.com",
    122         hostPort: 3302,
    123         registrationCode: "replace-with-another-code",
    124         env: {}
    125       }
    126     ]
    127   };
    128 }
    129 
    130 function ensureConfigFile(configPath, examplePath) {
    131   const template = `${JSON.stringify(templateConfig(), null, 2)}\n`;
    132   if (!fs.existsSync(examplePath)) {
    133     writeText(examplePath, template);
    134     log(`Wrote example config: ${displayPath(examplePath)}`);
    135   }
    136   if (!fs.existsSync(configPath)) {
    137     writeText(configPath, template);
    138     fail(
    139       `Created ${displayPath(configPath)}. Edit hostnames/ports/registration codes, then run this command again.`
    140     );
    141   }
    142 }
    143 
    144 function validateConfig(raw) {
    145   const projectName = normalizeId(raw?.projectName || "bzl-multi") || "bzl-multi";
    146   const networkName = normalizeId(raw?.networkName || "bzl_multi_net").replace(/-/g, "_") || "bzl_multi_net";
    147   const cf = raw?.cloudflared && typeof raw.cloudflared === "object" ? raw.cloudflared : {};
    148   const cloudflared = {
    149     enabled: Boolean(cf.enabled),
    150     tunnel: String(cf.tunnel || "bzl").trim(),
    151     routeDns: Boolean(cf.routeDns),
    152     overwriteDns: cf.overwriteDns == null ? true : Boolean(cf.overwriteDns),
    153     configPath: String(cf.configPath || "~/.cloudflared/config.yml").trim(),
    154     credentialsFile: String(cf.credentialsFile || "~/.cloudflared/TUNNEL-UUID.json").trim()
    155   };
    156 
    157   const instancesRaw = Array.isArray(raw?.instances) ? raw.instances : [];
    158   if (!instancesRaw.length) fail("Config must include at least one instance.");
    159 
    160   const ids = new Set();
    161   const hostnames = new Set();
    162   const ports = new Set();
    163   const instances = [];
    164 
    165   for (const [index, row] of instancesRaw.entries()) {
    166     const id = normalizeId(row?.id);
    167     if (!id) fail(`instances[${index}].id is required (letters/numbers/dashes).`);
    168     if (ids.has(id)) fail(`Duplicate instance id: ${id}`);
    169     ids.add(id);
    170 
    171     const hostname = String(row?.hostname || "").trim().toLowerCase();
    172     if (!looksLikeHost(hostname)) fail(`instances[${index}] (${id}) has invalid hostname: ${hostname}`);
    173     if (hostnames.has(hostname)) fail(`Duplicate hostname: ${hostname}`);
    174     hostnames.add(hostname);
    175 
    176     const hostPort = Number(row?.hostPort || 0);
    177     if (!Number.isInteger(hostPort) || hostPort < 1024 || hostPort > 65535) {
    178       fail(`instances[${index}] (${id}) hostPort must be an integer between 1024 and 65535.`);
    179     }
    180     if (ports.has(hostPort)) fail(`Duplicate hostPort: ${hostPort}`);
    181     ports.add(hostPort);
    182 
    183     const envRaw = row?.env && typeof row.env === "object" ? row.env : {};
    184     const env = {};
    185     for (const [key, value] of Object.entries(envRaw)) {
    186       const k = String(key || "").trim().toUpperCase();
    187       if (!k || !/^[A-Z0-9_]+$/.test(k)) continue;
    188       env[k] = String(value ?? "");
    189     }
    190 
    191     instances.push({
    192       id,
    193       serviceName: `bzl_${id.replace(/-/g, "_")}`,
    194       volumeName: `${projectName}_${id.replace(/-/g, "_")}_data`,
    195       hostname,
    196       hostPort,
    197       registrationCode: String(row?.registrationCode || "").trim(),
    198       env
    199     });
    200   }
    201 
    202   if (cloudflared.enabled && !cloudflared.tunnel) fail("cloudflared.tunnel is required when cloudflared.enabled=true.");
    203   if (cloudflared.enabled && !cloudflared.configPath) fail("cloudflared.configPath is required when cloudflared.enabled=true.");
    204   if (cloudflared.enabled && !cloudflared.credentialsFile) {
    205     warn("cloudflared.credentialsFile is empty; update it before starting cloudflared.");
    206   }
    207 
    208   return { projectName, networkName, cloudflared, instances };
    209 }
    210 
    211 function renderEnvFile(instance) {
    212   const lines = [
    213     `# Generated by scripts/multi-instance-init.js`,
    214     `# Instance: ${instance.id} (${instance.hostname})`,
    215     "",
    216     "PORT=3000",
    217     "HOST=0.0.0.0",
    218     `REGISTRATION_CODE=${envValue(instance.registrationCode)}`
    219   ];
    220   const keys = Object.keys(instance.env).sort();
    221   if (keys.length) {
    222     lines.push("", "# Extra per-instance env overrides");
    223     for (const key of keys) lines.push(`${key}=${envValue(instance.env[key])}`);
    224   }
    225   lines.push("");
    226   return lines.join("\n");
    227 }
    228 
    229 function renderCompose(cfg) {
    230   const lines = ["services:"];
    231   for (const inst of cfg.instances) {
    232     lines.push(
    233       `  ${inst.serviceName}:`,
    234       `    build:`,
    235       `      context: ..`,
    236       `      dockerfile: Dockerfile`,
    237       `    image: bzl:latest`,
    238       `    container_name: ${inst.serviceName}`,
    239       `    restart: unless-stopped`,
    240       `    env_file:`,
    241       `      - ./env/${inst.id}.env`,
    242       `    ports:`,
    243       `      - "127.0.0.1:${inst.hostPort}:3000"`,
    244       `    volumes:`,
    245       `      - ${inst.volumeName}:/app/data`,
    246       `    networks:`,
    247       `      - ${cfg.networkName}`
    248     );
    249   }
    250   lines.push("", "volumes:");
    251   for (const inst of cfg.instances) lines.push(`  ${inst.volumeName}:`);
    252   lines.push("", "networks:", `  ${cfg.networkName}:`, `    name: ${cfg.networkName}`, "");
    253   return lines.join("\n");
    254 }
    255 
    256 function renderCloudflaredConfig(cfg) {
    257   const lines = [
    258     `# Generated by scripts/multi-instance-init.js`,
    259     `tunnel: ${cfg.cloudflared.tunnel}`,
    260     `credentials-file: ${expandHome(cfg.cloudflared.credentialsFile)}`,
    261     `ingress:`
    262   ];
    263   for (const inst of cfg.instances) {
    264     lines.push(`  - hostname: ${inst.hostname}`, `    service: http://127.0.0.1:${inst.hostPort}`);
    265   }
    266   lines.push(`  - service: http_status:404`, "");
    267   return lines.join("\n");
    268 }
    269 
    270 function renderChecklist(cfg, composePath) {
    271   const lines = [
    272     "# Multi-instance DNS / deployment checklist",
    273     "",
    274     `Compose file: ${displayPath(composePath)}`,
    275     "",
    276     "## 1) Bring up all instances",
    277     "```bash",
    278     `docker compose -f ${displayPath(composePath)} up -d --build --remove-orphans`,
    279     "```",
    280     "",
    281     "## 2) Validate each local instance responds",
    282     "```bash"
    283   ];
    284   for (const inst of cfg.instances) lines.push(`curl -fsS http://127.0.0.1:${inst.hostPort}/api/health`);
    285   lines.push("```", "");
    286   if (cfg.cloudflared.enabled) {
    287     lines.push(
    288       "## 3) Cloudflare DNS routing",
    289       "",
    290       "Run these once per hostname (safe to re-run with overwrite):",
    291       "```bash"
    292     );
    293     for (const inst of cfg.instances) {
    294       const overwrite = cfg.cloudflared.overwriteDns ? "--overwrite-dns " : "";
    295       lines.push(`cloudflared tunnel route dns ${overwrite}${cfg.cloudflared.tunnel} ${inst.hostname}`);
    296     }
    297     lines.push(
    298       "```",
    299       "",
    300       "## 4) Start/restart the tunnel",
    301       "```bash",
    302       `cloudflared tunnel run ${cfg.cloudflared.tunnel}`,
    303       "```",
    304       "",
    305       "## 5) DNS sanity reminders",
    306       "- Verify each hostname resolves to Cloudflare tunnel (proxied CNAME).",
    307       "- Keep SSL/TLS mode in Cloudflare set to Full (strict preferred).",
    308       "- Ensure your tunnel credentials file matches the tunnel UUID in config.",
    309       "- If a route changed recently, allow DNS propagation time and re-test `/api/health` through the hostname."
    310     );
    311   } else {
    312     lines.push("## 3) DNS sanity reminders", "- Point each hostname to your reverse proxy/tunnel endpoint.", "- Ensure TLS termination/proxy forwards to the mapped host ports above.");
    313   }
    314   lines.push("");
    315   return lines.join("\n");
    316 }
    317 
    318 function runCommand(cmd, args, cwd) {
    319   const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false });
    320   if (result.error) throw result.error;
    321   return result.status || 0;
    322 }
    323 
    324 function maybeRouteDns(cfg, forceRouteFlag) {
    325   if (!cfg.cloudflared.enabled) return;
    326   if (!(cfg.cloudflared.routeDns || forceRouteFlag)) return;
    327 
    328   log("Applying cloudflared DNS routes...");
    329   for (const inst of cfg.instances) {
    330     const args = ["tunnel", "route", "dns"];
    331     if (cfg.cloudflared.overwriteDns) args.push("--overwrite-dns");
    332     args.push(cfg.cloudflared.tunnel, inst.hostname);
    333     try {
    334       const code = runCommand("cloudflared", args, ROOT);
    335       if (code !== 0) warn(`cloudflared route failed (${inst.hostname}), exit code ${code}.`);
    336     } catch (e) {
    337       warn(`cloudflared route failed (${inst.hostname}): ${e?.message || e}`);
    338     }
    339   }
    340 }
    341 
    342 function main() {
    343   const args = parseArgs();
    344   const configPath = args.config
    345     ? path.resolve(process.cwd(), String(args.config))
    346     : DEFAULT_CONFIG_PATH;
    347   const examplePath = args.example
    348     ? path.resolve(process.cwd(), String(args.example))
    349     : DEFAULT_EXAMPLE_PATH;
    350   const forceRouteDns = args["route-dns"] === "1";
    351 
    352   fs.mkdirSync(MULTI_DIR, { recursive: true });
    353   ensureConfigFile(configPath, examplePath);
    354 
    355   const cfg = validateConfig(readJson(configPath));
    356   const envDir = path.join(path.dirname(configPath), "env");
    357   const composePath = path.join(path.dirname(configPath), "docker-compose.yml");
    358   const checklistPath = path.join(path.dirname(configPath), "DNS_CHECKLIST.md");
    359 
    360   for (const inst of cfg.instances) {
    361     const envPath = path.join(envDir, `${inst.id}.env`);
    362     writeText(envPath, renderEnvFile(inst));
    363     log(`Wrote ${displayPath(envPath)}`);
    364   }
    365 
    366   writeText(composePath, renderCompose(cfg));
    367   log(`Wrote ${displayPath(composePath)}`);
    368 
    369   writeText(checklistPath, renderChecklist(cfg, composePath));
    370   log(`Wrote ${displayPath(checklistPath)}`);
    371 
    372   if (cfg.cloudflared.enabled) {
    373     const cfPath = path.resolve(expandHome(cfg.cloudflared.configPath));
    374     writeText(cfPath, renderCloudflaredConfig(cfg));
    375     log(`Wrote cloudflared config: ${cfPath}`);
    376   }
    377 
    378   maybeRouteDns(cfg, forceRouteDns);
    379 
    380   console.log("");
    381   console.log("Next:");
    382   console.log(`  1) docker compose -f ${displayPath(composePath)} up -d --build --remove-orphans`);
    383   if (cfg.cloudflared.enabled) {
    384     console.log(`  2) cloudflared tunnel run ${cfg.cloudflared.tunnel}`);
    385   }
    386   console.log(`  3) Review ${displayPath(checklistPath)} for DNS validation reminders.`);
    387 }
    388 
    389 main();