Ctrl K
ComunidadGuías…

scripts/local-stack-ci.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// CI checks for the one-click local n8n stack.
//   node scripts/local-stack-ci.mjs compose-config
//     Renders the stack files for private, Code Sandbox + SearXNG and ngrok setups and runs this
//     machine's `docker compose config` on them. Needs only the Docker CLI with Compose, so it also
//     runs on Windows runners, which cannot start Linux containers.
//   node scripts/local-stack-ci.mjs live
//     Linux only: installs the private stack with Code Sandbox through the real installer, checks
//     n8n's health, adds an add-on credential to n8n, then removes the stack.
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { createAssistantSecrets } from "../src/domain/assistant-templates.js";
import {
  createLocalN8nStackInstallation,
  createLocalN8nStackPlan,
  LOCAL_N8N_STACK_REMOVE_CONFIRMATION,
} from "../src/domain/local-n8n-stack.js";
import {
  getLocalN8nStackStatus,
  installLocalN8nStack,
  removeLocalN8nStack,
} from "../src/services/local-n8n-stack-installer.js";
import { importLocalStackN8nCredential, localStackCredentialId } from "../src/services/local-n8n-stack-credentials.js";
import {
  createLocalN8nStackComposeFile,
  createLocalN8nStackEnv,
  createNgrokConfig,
  createNgrokTrafficPolicy,
  createSearxngSettings,
} from "../src/templates/local-n8n-stack/index.js";

const DOCKER_HOST = process.platform === "win32"
  ? "npipe:////./pipe/dockerDesktopLinuxEngine"
  : "unix:///var/run/docker.sock";

function docker(args, options = {}) {
  return execFileSync("docker", args, { encoding: "utf8", maxBuffer: 1 << 26, ...options });
}

async function composeConfig() {
  // `$` and `"` exercise the .env escaping that Compose must read back exactly.
  const ngrokAuthtoken = "ci$token_with$dollars\"and-quote";
  const cases = [
    { publicAccess: "none", assistantMode: "disabled" },
    { publicAccess: "none", assistantMode: "sandbox-with-searxng" },
    { publicAccess: "ngrok", assistantMode: "sandbox" },
  ];
  for (const setup of cases) {
    const ngrok = setup.publicAccess === "ngrok";
    const plan = createLocalN8nStackPlan({
      dockerHost: DOCKER_HOST, publicAccess: setup.publicAccess,
      ngrokHostname: ngrok ? "relmio-ci.ngrok-free.app" : null, n8nPort: 5679,
      timezone: "UTC", assistantMode: setup.assistantMode,
    });
    const installation = createLocalN8nStackInstallation({ plan, randomBytes });
    const secrets = ngrok
      ? { ngrokAuthtoken, basicAuthUsername: "relmio-ci", basicAuthPassword: "long-ci-password-$" }
      : undefined;
    const runtimeSecrets = {
      n8nEncryptionKey: randomBytes(32).toString("hex"),
      ...(setup.assistantMode === "disabled" ? {} : createAssistantSecrets({
        randomBytes, includeSearxng: setup.assistantMode === "sandbox-with-searxng",
      })),
    };
    const directory = await mkdtemp(join(tmpdir(), "relmio stack ci "));
    try {
      await mkdir(join(directory, ".runtime"));
      await writeFile(join(directory, ".env"), createLocalN8nStackEnv({ installation, secrets, runtimeSecrets }));
      await writeFile(join(directory, "docker-compose.yml"), createLocalN8nStackComposeFile({ installation }));
      if (ngrok) {
        await writeFile(join(directory, "ngrok.yml"), createNgrokConfig());
        await writeFile(join(directory, ".runtime", "traffic-policy.yml"),
          createNgrokTrafficPolicy({ username: secrets.basicAuthUsername, password: secrets.basicAuthPassword }));
      }
      if (setup.assistantMode === "sandbox-with-searxng") {
        await writeFile(join(directory, ".runtime", "searxng-settings.yml"), createSearxngSettings());
      }
      const config = JSON.parse(docker([
        "compose", "--project-name", installation.projectName, "--env-file", ".env",
        "--file", "docker-compose.yml", "config", "--format", "json",
      ], { cwd: directory }));
      const services = Object.keys(config.services).sort();
      if (Boolean(config.services.ngrok) !== ngrok) throw new Error(`ngrok service presence is wrong for ${setup.publicAccess}`);
      // `compose config` prints every `$` doubled so its output stays valid Compose; undo that before comparing.
      if (ngrok && config.services.ngrok.environment.NGROK_AUTHTOKEN.replaceAll("$$", "$") !== ngrokAuthtoken) {
        throw new Error("Compose did not read the escaped ngrok token back exactly.");
      }
      if (ngrok && config.services.ngrok.ports?.length) throw new Error("The ngrok agent API must not be published.");
      const n8nEnv = config.services.n8n.environment;
      if (!ngrok && (n8nEnv.N8N_HOST !== "localhost" || n8nEnv.N8N_SECURE_COOKIE !== "false")) {
        throw new Error("The private stack does not use the localhost n8n settings.");
      }
      const certs = config.services["relmio-sandbox-certs"];
      if (setup.assistantMode !== "disabled" && (certs?.entrypoint?.[0] !== "sh" || certs?.entrypoint?.[1] !== "-c")) {
        throw new Error("The Code Sandbox certificate job must override the image entrypoint.");
      }
      console.log(`compose config ok: ${setup.publicAccess} + ${setup.assistantMode} -> ${services.join(", ")}`);
    } finally {
      await rm(directory, { recursive: true, force: true });
    }
  }
}

async function waitForHealthz(url, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try { if ((await fetch(url)).ok) return; } catch { /* n8n is still starting */ }
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
  throw new Error(`${url} did not become healthy.`);
}

async function readMarker(homeDirectory) {
  try {
    return JSON.parse(await readFile(join(homeDirectory, ".relmio", "local", "n8n-stack", ".managed-by-relmio.json"), "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") return null;
    throw error;
  }
}

async function live() {
  if (process.platform !== "linux") throw new Error("The live stack check runs on Linux CI only.");
  const homeDirectory = await mkdtemp(join(tmpdir(), "relmio-stack-home-"));
  const plan = createLocalN8nStackPlan({
    dockerHost: DOCKER_HOST, publicAccess: "none", ngrokHostname: null, n8nPort: 5679,
    timezone: "UTC", assistantMode: "sandbox",
  });
  let liveError = null;
  try {
    const result = await installLocalN8nStack({ plan, secrets: undefined, homeDirectory });
    console.log(`installed: ${result.localUrl}`);
    await waitForHealthz("http://127.0.0.1:5679/healthz", 60_000);
    const status = await getLocalN8nStackStatus({ homeDirectory });
    if (status.state !== "healthy") throw new Error(`stack status is ${status.state}`);
    const marker = await readMarker(homeDirectory);
    const credential = await importLocalStackN8nCredential({
      marker, credential: { feature: "local-model", apiKey: "local-only" }, dockerHost: DOCKER_HOST, homeDirectory,
    });
    if (credential.state !== "created") throw new Error(`credential import ${credential.state}`);
    const again = await importLocalStackN8nCredential({
      marker, credential: { feature: "local-model", apiKey: "local-only" }, dockerHost: DOCKER_HOST, homeDirectory,
    });
    if (again.state !== "updated") throw new Error(`second import ${again.state}`);
    const id = localStackCredentialId(marker.installId, "local-model");
    const exported = docker(["exec", `${marker.projectName}-n8n-1`, "n8n", "export:credentials", `--id=${id}`]);
    if (!exported.includes(id) || !exported.includes("Relmio local model")) throw new Error("n8n does not list the imported credential.");
    console.log(`credential in n8n: ${id} Relmio local model`);
  } catch (error) {
    liveError = error;
  }
  // A marker left on disk means Docker resources may exist, even after a failed install.
  let cleanupError = null;
  try {
    const marker = await readMarker(homeDirectory);
    if (marker) {
      await removeLocalN8nStack({ confirmation: LOCAL_N8N_STACK_REMOVE_CONFIRMATION, homeDirectory });
      const left = docker(["ps", "--all", "--quiet", "--filter", "label=io.relmio.target=local-n8n-stack",
        "--filter", `label=com.docker.compose.project=${marker.projectName}`]).trim();
      if (left) throw new Error(`Stack ${marker.projectName} containers remained after removal.`);
      console.log("stack removed");
    }
    await rm(homeDirectory, { recursive: true, force: true });
  } catch (error) {
    cleanupError = error;
    console.error(`Kept ${homeDirectory} for inspection.`);
  }
  const errors = [liveError, cleanupError].filter(Boolean);
  if (errors.length) throw new AggregateError(errors, errors.map((error) => error.message).join(" | "));
}

const mode = process.argv[2];
if (mode === "compose-config") await composeConfig();
else if (mode === "live") await live();
else throw new Error("Usage: node scripts/local-stack-ci.mjs compose-config|live");