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();