Ctrl K
ComunidadGuías…

src/services/discovery.js

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

Archivo original ↗Preguntar a KiBot
import { validateDockerName } from "../domain/validation.js";
import { describeVpsNetworkSupport } from "../domain/vps-network.js";

const DOCKER_VERSION_COMMAND =
  "docker version --format '{{.Server.Version}}'";
const COMPOSE_VERSION_COMMAND = "docker compose version --short";
const RUNNING_CONTAINERS_COMMAND =
  "docker ps --filter status=running --format '{{json .}}'";
// Static and read-only. Docker prints each container's two Compose labels by
// name, so nothing is parsed out of its comma-joined label column.
const COMPOSE_LABELS_COMMAND =
  "docker ps --all --format '{{.ID}}|{{.Label \"com.docker.compose.project\"}}|{{.Label \"com.docker.compose.service\"}}'";
// The service name in n8n's own Compose file for its sandbox stack.
const N8N_SANDBOX_SERVICE = "sandbox-api";
const DEFAULT_BRIDGE_OPTION = "com.docker.network.bridge.default_bridge";
const CONTAINER_ID = /^[a-f0-9]{12,64}$/u;
const IMAGE_ID = /^(?:sha256:)?[a-f0-9]{12,64}$/u;
const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,254}$/u;
const COMPOSE_PROJECT = /^[a-z0-9][a-z0-9_-]{0,127}$/u;
const NONE_FOUND_MESSAGE =
  "No running container from the official n8n image (n8nio/n8n) was found. Relmio looks at running Docker containers only. Start n8n in Docker on this server, then check again.";

function isOfficialN8nImage(image) {
  return /(?:^|\/)n8nio\/n8n(?:[:@]|$)/.test(image);
}

async function runReadOnly(remote, command, label) {
  const result = await remote.exec(command);
  if (result.code !== 0) {
    throw new Error(`${label} failed. Check Docker access on the VPS.`);
  }
  return result.stdout.trim();
}

function firstDockerName(names) {
  if (typeof names !== "string") return null;
  for (const name of names.split(",")) {
    try { return validateDockerName(name); } catch { /* try the next name */ }
  }
  return null;
}

function runningContainers(output) {
  if (typeof output !== "string" || output.trim() === "") {
    return [];
  }
  try {
    return output
      .trim()
      .split("\n")
      .map((line) => JSON.parse(line))
      .filter(
        (container) =>
          container.State === "running" && typeof container.Image === "string",
      );
  } catch {
    throw new Error(
      "The installer could not understand Docker's container list.",
    );
  }
}

// After an image tag moves, Docker lists a container that still runs the old
// image by image ID. `images` maps such a container's ID to the image name it
// was created from. `compose` holds the Compose labels read by name.
export function parseDockerPsOutput(output, { images, compose } = {}) {
  return runningContainers(output).flatMap((container) => {
    const id = String(container.ID);
    const image = IMAGE_ID.test(container.Image) ? images?.get(id) : container.Image;
    const name = typeof image === "string" && isOfficialN8nImage(image)
      ? firstDockerName(container.Names) : null;
    if (name === null) return [];
    const composeProject = compose?.projects.get(id) ?? null;
    return [{
      id,
      image,
      name,
      state: container.State,
      composeProject,
      n8nSandboxPresent: composeProject !== null && compose.sandboxProjects.has(composeProject),
    }];
  });
}

export function createInspectContainerImagesCommand(containerIds) {
  if (!Array.isArray(containerIds) || containerIds.length === 0 ||
      containerIds.some((id) => typeof id !== "string" || !CONTAINER_ID.test(id))) {
    throw new TypeError("Docker container IDs are invalid.");
  }
  return `docker container inspect --format '{{.Id}}|{{.Config.Image}}' ${containerIds.join(" ")}`;
}

export function parseContainerImagesOutput(output, containerIds) {
  const images = new Map();
  for (const line of typeof output === "string" ? output.split("\n") : []) {
    const [fullId, image, ...rest] = line.trim().split("|");
    if (rest.length > 0 || !CONTAINER_ID.test(fullId ?? "") || !IMAGE_REFERENCE.test(image ?? "")) continue;
    const id = containerIds.find((candidate) => fullId.startsWith(candidate));
    if (id !== undefined) images.set(id, image);
  }
  return images;
}

export function parseComposeLabelsOutput(output) {
  const projects = new Map();
  const sandboxProjects = new Set();
  for (const line of typeof output === "string" ? output.split("\n") : []) {
    const parts = line.trim().split("|");
    if (parts.length !== 3 || !CONTAINER_ID.test(parts[0]) || !COMPOSE_PROJECT.test(parts[1])) continue;
    projects.set(parts[0], parts[1]);
    if (parts[2] === N8N_SANDBOX_SERVICE) sandboxProjects.add(parts[1]);
  }
  return { projects, sandboxProjects };
}

export async function discoverN8n(remote) {
  const dockerVersion = await runReadOnly(
    remote,
    DOCKER_VERSION_COMMAND,
    "Docker check",
  );
  const composeVersion = await runReadOnly(
    remote,
    COMPOSE_VERSION_COMMAND,
    "Docker Compose check",
  );
  const containerOutput = await runReadOnly(
    remote,
    RUNNING_CONTAINERS_COMMAND,
    "n8n discovery",
  );
  const compose = parseComposeLabelsOutput(await runReadOnly(
    remote,
    COMPOSE_LABELS_COMMAND,
    "n8n discovery",
  ));
  const imageIdContainers = runningContainers(containerOutput)
    .filter((container) => IMAGE_ID.test(container.Image) && CONTAINER_ID.test(String(container.ID)))
    .map((container) => String(container.ID));
  // One inspect, only for containers listed by image ID. A container that went
  // away in between makes Docker exit non-zero; the rest still resolve.
  const images = imageIdContainers.length === 0
    ? new Map()
    : parseContainerImagesOutput(
      (await remote.exec(createInspectContainerImagesCommand(imageIdContainers))).stdout,
      imageIdContainers,
    );
  const containers = parseDockerPsOutput(containerOutput, { images, compose });

  return {
    dockerVersion,
    composeVersion,
    containers,
    ...(containers.length === 0 ? { noneFoundMessage: NONE_FOUND_MESSAGE } : {}),
  };
}

export function createInspectNetworksCommand(containerName) {
  const safeName = validateDockerName(containerName);
  return `docker inspect ${safeName} --format '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}'`;
}

export function createInspectNetworkDetailsCommand(networkNames) {
  if (!Array.isArray(networkNames) || networkNames.length === 0) {
    throw new TypeError("Docker network names are invalid.");
  }
  return `docker network inspect --format '{{json .Name}}|{{json .Driver}}|{{json .Internal}}|{{json (index .Options "${DEFAULT_BRIDGE_OPTION}")}}' ${networkNames.map(validateDockerName).join(" ")}`;
}

// Marks networks on which a Relmio container certainly cannot work. Docker's
// predefined networks are known by name alone; `Internal` needs the inspect.
// A failed or unreadable inspect leaves a network unmarked instead of refused.
export function parseNetworkSupportOutput(output, networkNames) {
  const records = new Map(networkNames.map((name) => [name, { Name: name }]));
  for (const line of typeof output === "string" ? output.split("\n") : []) {
    const parts = line.trim().split("|");
    if (parts.length !== 4) continue;
    try {
      const [name, driver, internal, defaultBridge] = parts.map((part) => JSON.parse(part));
      if (records.has(name)) {
        records.set(name, {
          Name: name, Driver: driver, Internal: internal,
          Options: { [DEFAULT_BRIDGE_OPTION]: defaultBridge },
        });
      }
    } catch { /* this network keeps its name-only record */ }
  }
  return Object.fromEntries(networkNames.map((name) =>
    [name, describeVpsNetworkSupport(records.get(name), { outbound: true })]));
}

export function createInspectN8nEnabledModulesCommand(containerName) {
  const safeName = validateDockerName(containerName);
  return `docker inspect ${safeName} --format '{{range .Config.Env}}{{if eq (index (split . "=") 0) "N8N_ENABLED_MODULES"}}configured|{{println (join (slice (split . "=") 1) "=")}}{{end}}{{end}}'`;
}

export function parseN8nEnabledModulesOutput(output) {
  if (typeof output !== "string" || output.length > 4 * 1024) {
    throw new Error("The n8n AI Assistant prerequisite could not be verified.");
  }
  const lines = output.trim().split("\n").filter(Boolean);
  if (lines.length === 0) return { status: "missing" };
  if (lines.length !== 1 || !lines[0].startsWith("configured|")) {
    throw new Error("The n8n AI Assistant prerequisite could not be verified.");
  }
  const modules = lines[0]
    .slice("configured|".length)
    .split(",")
    .map((entry) => entry.trim())
    .filter(Boolean);
  return { status: modules.includes("instance-ai") ? "enabled" : "configured" };
}

export async function discoverNetworks(remote, containerName) {
  const command = createInspectNetworksCommand(containerName);
  const enabledModulesCommand = createInspectN8nEnabledModulesCommand(containerName);
  const [output, enabledModulesOutput] = await Promise.all([
    runReadOnly(remote, command, "Docker network discovery"),
    runReadOnly(remote, enabledModulesCommand, "n8n AI Assistant prerequisite discovery"),
  ]);
  const networks = [
    ...new Set(
      output
        .split("\n")
        .map((name) => name.trim())
        .filter(Boolean)
        .map(validateDockerName),
    ),
  ];
  const details = networks.length === 0
    ? { code: 1 }
    : await remote.exec(createInspectNetworkDetailsCommand(networks));
  const support = parseNetworkSupportOutput(details.code === 0 ? details.stdout : "", networks);
  const usable = networks.filter((name) => support[name].supported);
  const preferred = usable.length > 0 ? usable : networks;

  return {
    networks,
    recommended: preferred.includes("proxy") ? "proxy" : (preferred[0] ?? null),
    instanceAi: parseN8nEnabledModulesOutput(enabledModulesOutput),
    support,
  };
}