Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions server/src/demos/aislamiento.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { readFile, writeFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
import { createWorkspace } from "../engine/workspace.js";
Expand Down Expand Up @@ -106,7 +106,30 @@ async function main() {
const git = await dentro.exec("git --version", OPTS);
check("tiene git", git.stdout.includes("git version"), git.stdout.trim());

console.log("\n6. Al cerrar la sala, el contenedor desaparece");
console.log("\n6. El HOME de la sala vive FUERA del proyecto, y se puede escribir");
{
// El check que importa: si `<workspace>.home` no existiera antes del
// `docker run`, Docker lo habría creado como root y esto fallaría con
// EACCES. Y ese fallo no se ve como error: el agente lo esquiva y entrega
// trabajo a medias (pasó, ver el comentario de container.ts).
const escribe = await dentro.exec("touch \"$HOME/prueba\" && echo ok", OPTS);
check("se puede escribir en el HOME", escribe.stdout.trim() === "ok", escribe.stderr.trim());

const donde = await dentro.exec("echo $HOME", OPTS);
check("y NO está dentro del proyecto", !donde.stdout.includes("/work"), donde.stdout.trim());

// Y por lo tanto el workspace queda vacío para el agente: solo `.git`, que
// los generadores de proyecto ignoran. Con cualquier otra cosa ahí dentro se
// niegan a correr y el agente pierde media docena de comandos rodeándolo.
const enLaRaiz = await readdir(ws.dir);
check(
"el proyecto arranca vacío",
enLaRaiz.filter((f) => f !== ".git" && f !== "saludo.txt").length === 0,
enLaRaiz.join(" "),
);
}

console.log("\n7. Al cerrar la sala, el contenedor desaparece");
await stopContainer(roomId);
const muerto = await dentro.exec("echo sigue vivo", OPTS).catch(() => null);
check(
Expand Down
30 changes: 24 additions & 6 deletions server/src/engine/container.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { join } from "node:path";
import { mkdir } from "node:fs/promises";
import { KeyedMutex } from "./keyed-mutex.js";
import { homeDir } from "./workspace.js";

const execFileP = promisify(execFile);

Expand All @@ -25,6 +27,9 @@ const execFileP = promisify(execFile);
export const IMAGE_TAG = "multi-room:latest";
const CONTAINER_PREFIX = "multi-room-";

/** Dónde se ve el HOME de la sala desde dentro del contenedor. */
const HOME_INTERNO = "/home/multi";

/**
* Un arranque a la vez por sala. Crear un contenedor no es atómico (se consulta
* el estado y luego se corre `docker run`), así que dos llamadas simultáneas
Expand Down Expand Up @@ -105,6 +110,12 @@ export async function startContainer(
// Un contenedor parado con la config vieja no sirve: se rehace.
if (existing !== null) await removeContainer(name);

// El HOME, antes del `docker run` y desde Node: así queda con el dueño del
// proceso de Multi, que es el mismo usuario con el que corre el contenedor.
// Si se dejara para que lo cree Docker, saldría de root y npm no podría
// escribir en él.
await mkdir(homeDir(workspaceDir), { recursive: true });

// Los argumentos van en UNA sola lista porque abajo hay un reintento: con
// dos copias, cualquier arreglo que se hiciera aquí y no allá revivía en el
// segundo intento. Ya pasó con `--user`.
Expand All @@ -114,6 +125,8 @@ export async function startContainer(
"--name", name,
// El proyecto vive en el host; adentro se ve como /work.
"-v", `${workspaceDir}:/work`,
// Y su HOME al lado, montado aparte. Ver `homeDir` para el porqué.
"-v", `${homeDir(workspaceDir)}:${HOME_INTERNO}`,
"--workdir", "/work",
/**
* El contenedor corre como el MISMO usuario que el server de Multi.
Expand All @@ -138,13 +151,18 @@ export async function startContainer(
* instalar nada: sin esto, arreglar el dueño del workspace no alcanzaba y
* `npm install` seguía muriendo, ahora por el caché.
*
* Va dentro del propio workspace porque es lo único que con certeza le
* pertenece a quien corre Multi. Queda en `.multi-home`, con punto: los
* `.gitignore` que escriben los agentes suelen ignorar los ocultos, y de
* todos modos el del motor ya excluye lo pesado.
* Vive FUERA del workspace, en un hermano que Multi crea antes de arrancar
* esto (ver arriba). Estuvo dentro y había que sacarlo: mientras hubiera
* algo ahí, los generadores de proyecto se negaban a correr sobre una
* carpeta con archivos.
*
* Que Multi lo cree ANTES es lo que hace que funcione: un destino de
* montaje que no existe lo crea el demonio de Docker, y como root. El
* usuario del contenedor no podría escribir ahí y volveríamos al EACCES de
* arriba, que no se ve como error sino como trabajo a medias.
*/
"--env", "HOME=/work/.multi-home",
"--env", "npm_config_cache=/work/.multi-home/.npm",
"--env", `HOME=${HOME_INTERNO}`,
"--env", `npm_config_cache=${HOME_INTERNO}/.npm`,
// Puerto 0 = que Docker elija uno libre del host. Evita colisiones entre salas.
"-p", `0:${devPort}`,
"--memory", MEMORY_LIMIT,
Expand Down
24 changes: 19 additions & 5 deletions server/src/engine/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ export interface Workspace {
dir: string;
}

/**
* El HOME del contenedor de una sala, hermano de su workspace.
*
* Ahí van la caché de npm y la config de las herramientas que corren adentro
* (wrangler, por ejemplo). Fuera del workspace por dos razones:
*
* - El workspace tiene que estar VACÍO cuando el agente llega, o los generadores
* de proyecto se plantan a preguntar si continúan sobre un directorio con
* archivos, nadie les contesta y se cancelan. Le costaba al agente hasta doce
* comandos rodearlo, distinto cada vez.
* - Y una caché de npm no es parte del proyecto de nadie. Ahí dentro viajaba en
* lo que la gente se descarga.
*
* Mismo patrón que `<workspace>.adjuntos` y `<workspace>.turns.json`.
*/
export function homeDir(workspaceDir: string): string {
return `${workspaceDir}.home`;
}

/**
* Crea el workspace de una sala: una carpeta vacía con git.
*
Expand Down Expand Up @@ -101,11 +120,6 @@ async function ensureGitignore(dir: string): Promise<void> {
"# commit del turno.",
"*.tmp-*",
"",
"# El HOME del contenedor de la sala (container.ts). Es cache de npm, no",
"# proyecto: sin esto entra a los commits y viaja en el .zip que la gente",
"# se descarga.",
".multi-home/",
"",
"# La base de datos del proyecto. Los datos de aquí son de prueba: quien se",
"# lleva la app la quiere publicar con su base vacía, no con lo que se tecleó",
"# probando. Y en el historial estorban: es un binario que cambia entero en",
Expand Down
6 changes: 5 additions & 1 deletion server/src/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace, type Workspace } from "./engine/workspace.js";
import { createWorkspace, homeDir, type Workspace } from "./engine/workspace.js";
import { startPreview, detectLaunch, type Preview } from "./engine/preview.js";
import {
isDockerAvailable,
Expand Down Expand Up @@ -476,6 +476,10 @@ export async function deleteRoom(id: string): Promise<boolean> {
await rm(dir, { recursive: true, force: true }).catch(() => {});
await rm(`${dir}.adjuntos`, { recursive: true, force: true }).catch(() => {});
await rm(`${dir}.turns.json`, { force: true }).catch(() => {});
// El HOME del contenedor, con la caché de npm dentro. Antes vivía dentro del
// workspace y se iba con él; ahora hay que borrarlo a mano o cada sala que
// alguien borre deja cientos de megas en disco para siempre.
await rm(homeDir(dir), { recursive: true, force: true }).catch(() => {});
await rm(`${dir}.bookmarks.json`, { force: true }).catch(() => {});

await (await getStorage()).deleteRoom(id);
Expand Down
Loading