diff --git a/.env.example b/.env.example index ecfeafd..acd89c2 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,7 @@ SESSION_SECRET=change-me-too # Random string (min 32 chars) used to derive the AES-256-GCM key that encrypts # installation env vars and bearer tokens at rest MASTER_KEY=change-me-to-a-random-string-of-at-least-32-characters + +# Resource caps applied to every installed MCP container (same limit for all, no per-installation override in V1) +CONTAINER_MEMORY_MB=512 +CONTAINER_CPUS=1 diff --git a/README.md b/README.md index fae4b1c..5495167 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,27 @@ the manager itself must run as a container attached to `mcp-net` to reach the MC containers it manages — running it directly on the host only works for routes that don't need to reach a sibling container (e.g. `/health`, `/api/auth`). +## Tests + +```sh +npm test +``` + +Runs the `apps/server` unit test suite (vitest): the secrets encryption round-trip, the +Nixpacks runtime-version detection, the admin-session middleware, and the installations +state machine against a temporary SQLite DB. `.gitea/workflows/ci.yml` runs the same build +and test steps on every push, provided this repo's Gitea instance has Actions enabled with +a runner. + ## Known limitations - Nixpacks needs a dependency manifest (`requirements.txt`/`pyproject.toml` for Python, `package.json` for Node) in the installed repo. Repos without one will fail to build; the full build log is always visible in the UI to diagnose this. -- No per-container resource limits, image garbage collection, or multi-user support yet. +- Every installed container gets the same memory/CPU caps (`CONTAINER_MEMORY_MB`, + `CONTAINER_CPUS`), no per-installation override yet. +- No multi-user support — single operator only. - Builds run one at a time. + +See `LESSONS.md` for operational gotchas found while building and running this (Nixpacks +runtime-version mismatches, Podman networking, SELinux, etc.). diff --git a/apps/server/src/config/env.ts b/apps/server/src/config/env.ts index ccc3eed..cc95ba5 100644 --- a/apps/server/src/config/env.ts +++ b/apps/server/src/config/env.ts @@ -14,4 +14,7 @@ export const config = { publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`, adminPassword: process.env.ADMIN_PASSWORD, sessionSecret: process.env.SESSION_SECRET, + // Per-container resource caps, applied to every installation (no per-installation override in V1). + containerMemoryMb: Number(process.env.CONTAINER_MEMORY_MB ?? 512), + containerCpus: Number(process.env.CONTAINER_CPUS ?? 1), }; diff --git a/apps/server/src/docker/containers.ts b/apps/server/src/docker/containers.ts index e94ee6a..04a15c4 100644 --- a/apps/server/src/docker/containers.ts +++ b/apps/server/src/docker/containers.ts @@ -39,6 +39,8 @@ export async function runInstallationContainer(installation: Installation): Prom HostConfig: { RestartPolicy: { Name: "unless-stopped" }, NetworkMode: config.mcpNetworkName, + Memory: config.containerMemoryMb * 1024 * 1024, + NanoCpus: Math.round(config.containerCpus * 1e9), }, }); @@ -46,6 +48,24 @@ export async function runInstallationContainer(installation: Installation): Prom return container.id; } +export async function getImageId(imageTag: string): Promise { + try { + const info = await docker.getImage(imageTag).inspect(); + return info.Id; + } catch { + return undefined; + } +} + +/** Best-effort removal: leaves the image alone if it's still referenced (e.g. by a running container). */ +export async function removeImageIfUnused(imageId: string): Promise { + try { + await docker.getImage(imageId).remove({ force: false }); + } catch { + // still in use or already gone — nothing to do + } +} + export async function stopInstallationContainer(containerName: string): Promise { await removeContainerIfExists(containerName); } diff --git a/docker-compose.yml b/docker-compose.yml index e26f27f..29c6590 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD} SESSION_SECRET: ${SESSION_SECRET} MASTER_KEY: ${MASTER_KEY} + CONTAINER_MEMORY_MB: ${CONTAINER_MEMORY_MB:-512} + CONTAINER_CPUS: ${CONTAINER_CPUS:-1} volumes: - /var/run/docker.sock:/var/run/docker.sock - ./data:/repo/data