Compare commits
5 Commits
5e8397bcd9
...
bd299187d6
| Author | SHA1 | Date | |
|---|---|---|---|
| bd299187d6 | |||
| ccf6708309 | |||
| 4cf857fd2d | |||
| b4628ac255 | |||
| ccb7fcd842 |
@@ -24,3 +24,7 @@ SESSION_SECRET=change-me-too
|
|||||||
# Random string (min 32 chars) used to derive the AES-256-GCM key that encrypts
|
# Random string (min 32 chars) used to derive the AES-256-GCM key that encrypts
|
||||||
# installation env vars and bearer tokens at rest
|
# installation env vars and bearer tokens at rest
|
||||||
MASTER_KEY=change-me-to-a-random-string-of-at-least-32-characters
|
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
|
||||||
|
|||||||
17
.gitea/workflows/ci.yml
Normal file
17
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build --workspace apps/server
|
||||||
|
- run: npm test
|
||||||
@@ -6,7 +6,7 @@ Recurring mistakes and gotchas found while building and operating this project,
|
|||||||
|
|
||||||
Nixpacks does not fail when a repo's `engines`/version requirement isn't met — it silently falls back to its own default (e.g. picked Node 18 for a repo requiring Node >=20 in `Social-MCP`). The build goes green, then the container crashes at runtime (`crypto is not defined`, missing globals, etc.).
|
Nixpacks does not fail when a repo's `engines`/version requirement isn't met — it silently falls back to its own default (e.g. picked Node 18 for a repo requiring Node >=20 in `Social-MCP`). The build goes green, then the container crashes at runtime (`crypto is not defined`, missing globals, etc.).
|
||||||
|
|
||||||
**Avoid it:** don't trust a green build as proof the runtime is correct. Check the deployed container's actual runtime version against the repo's declared requirement, or pin it explicitly (`NIXPACKS_NODE_VERSION`, `.nvmrc`, etc.) — not yet automated in V1, tracked as a known gap.
|
**Avoid it:** don't trust a green build as proof the runtime is correct. The manager now detects a Node version hint from the repo itself (`.nvmrc`, else `package.json` `engines.node`) and passes it to Nixpacks as `NIXPACKS_NODE_VERSION` (`apps/server/src/build/nixpacks.ts`, `detectNodeVersion`), so the build uses the version the repo actually declares instead of Nixpacks' own default. Repos with no version hint at all still get Nixpacks' default, unchanged.
|
||||||
|
|
||||||
## Nixpacks needs a dependency manifest, and fails cleanly without one
|
## Nixpacks needs a dependency manifest, and fails cleanly without one
|
||||||
|
|
||||||
|
|||||||
19
README.md
19
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
|
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`).
|
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
|
## Known limitations
|
||||||
|
|
||||||
- Nixpacks needs a dependency manifest (`requirements.txt`/`pyproject.toml` for Python,
|
- 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;
|
`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.
|
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.
|
- 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.).
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "11.10.0",
|
"better-sqlite3": "11.10.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@types/http-proxy": "1.17.16",
|
"@types/http-proxy": "1.17.16",
|
||||||
"@types/node": "24.9.1",
|
"@types/node": "24.9.1",
|
||||||
"tsx": "4.20.6",
|
"tsx": "4.20.6",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^5.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/server/src/build/nixpacks.test.ts
Normal file
41
apps/server/src/build/nixpacks.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { detectNodeVersion } from "./nixpacks.js";
|
||||||
|
|
||||||
|
function mkTmpRepo(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), "nixpacks-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("detectNodeVersion", () => {
|
||||||
|
it("reads the major version from .nvmrc", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, ".nvmrc"), "20.11.0\n");
|
||||||
|
expect(detectNodeVersion(dir)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers .nvmrc over package.json when both are present", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, ".nvmrc"), "18\n");
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ engines: { node: ">=22" } }));
|
||||||
|
expect(detectNodeVersion(dir)).toBe("18");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to package.json engines.node", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ engines: { node: ">=20.0.0" } }));
|
||||||
|
expect(detectNodeVersion(dir)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when there is no version hint", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
expect(detectNodeVersion(dir)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for malformed package.json instead of throwing", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), "{not valid json");
|
||||||
|
expect(detectNodeVersion(dir)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,41 @@ export interface BuildResult {
|
|||||||
logPath: string;
|
logPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMajorVersion(raw: string): string | undefined {
|
||||||
|
const match = raw.match(/(\d+)/);
|
||||||
|
return match ? match[1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nixpacks doesn't fail when a repo's declared runtime requirement isn't met — it silently
|
||||||
|
* falls back to its own default version (seen in practice: Node 18 picked for a repo requiring
|
||||||
|
* >=20, build green, container crashed at runtime on a missing global). Pinning
|
||||||
|
* NIXPACKS_NODE_VERSION from the repo's own .nvmrc/package.json closes that gap for Node repos.
|
||||||
|
*/
|
||||||
|
export function detectNodeVersion(repoDir: string): string | undefined {
|
||||||
|
const nvmrcPath = path.join(repoDir, ".nvmrc");
|
||||||
|
if (fs.existsSync(nvmrcPath)) {
|
||||||
|
const version = parseMajorVersion(fs.readFileSync(nvmrcPath, "utf8"));
|
||||||
|
if (version) return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgPath = path.join(repoDir, "package.json");
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } };
|
||||||
|
const engineNode = pkg.engines?.node;
|
||||||
|
if (typeof engineNode === "string") {
|
||||||
|
const version = parseMajorVersion(engineNode);
|
||||||
|
if (version) return version;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// malformed package.json — nothing to pin, let nixpacks use its default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> {
|
export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const buildId = Date.now().toString();
|
const buildId = Date.now().toString();
|
||||||
@@ -19,7 +54,16 @@ export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string
|
|||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
||||||
|
|
||||||
const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag]);
|
const env = { ...process.env };
|
||||||
|
const nodeVersion = detectNodeVersion(repoDir);
|
||||||
|
if (nodeVersion) {
|
||||||
|
env.NIXPACKS_NODE_VERSION = nodeVersion;
|
||||||
|
logStream.write(
|
||||||
|
`[remote-mcp-manager] pinning NIXPACKS_NODE_VERSION=${nodeVersion} (detected from .nvmrc/package.json engines.node)\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag], { env });
|
||||||
child.stdout.pipe(logStream, { end: false });
|
child.stdout.pipe(logStream, { end: false });
|
||||||
child.stderr.pipe(logStream, { end: false });
|
child.stderr.pipe(logStream, { end: false });
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,7 @@ export const config = {
|
|||||||
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
||||||
adminPassword: process.env.ADMIN_PASSWORD,
|
adminPassword: process.env.ADMIN_PASSWORD,
|
||||||
sessionSecret: process.env.SESSION_SECRET,
|
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),
|
||||||
};
|
};
|
||||||
|
|||||||
25
apps/server/src/crypto/secrets.test.ts
Normal file
25
apps/server/src/crypto/secrets.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it, beforeAll } from "vitest";
|
||||||
|
import { encryptSecret, decryptSecret, generateBearerToken } from "./secrets.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.MASTER_KEY = "test-master-key-at-least-32-characters-long";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secrets", () => {
|
||||||
|
it("round-trips an encrypted value", () => {
|
||||||
|
const encrypted = encryptSecret("hello world");
|
||||||
|
expect(encrypted).not.toContain("hello world");
|
||||||
|
expect(decryptSecret(encrypted)).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails to decrypt with a tampered ciphertext", () => {
|
||||||
|
const encrypted = encryptSecret("hello world");
|
||||||
|
const tampered = Buffer.from(encrypted, "base64");
|
||||||
|
tampered[tampered.length - 1] ^= 0xff;
|
||||||
|
expect(() => decryptSecret(tampered.toString("base64"))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates distinct bearer tokens", () => {
|
||||||
|
expect(generateBearerToken()).not.toBe(generateBearerToken());
|
||||||
|
});
|
||||||
|
});
|
||||||
61
apps/server/src/db/installations.test.ts
Normal file
61
apps/server/src/db/installations.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// db/index.ts opens the sqlite file as a side effect of being imported, using config
|
||||||
|
// derived from DATA_DIR/DB_PATH — so these must be set before the dynamic import below.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpmgr-db-test-"));
|
||||||
|
process.env.DATA_DIR = tmpDir;
|
||||||
|
process.env.DB_PATH = path.join(tmpDir, "db.sqlite");
|
||||||
|
process.env.MASTER_KEY = "test-master-key-at-least-32-characters-long";
|
||||||
|
|
||||||
|
const { createInstallation, updateInstallation, getInstallationById, slugify } = await import("./installations.js");
|
||||||
|
|
||||||
|
describe("slugify", () => {
|
||||||
|
it("normalizes names into a URL/container-safe slug", () => {
|
||||||
|
expect(slugify("My Cool MCP!")).toBe("my-cool-mcp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installations repository", () => {
|
||||||
|
it("creates a draft installation with a derived container name", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "Test MCP",
|
||||||
|
slug: "test-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installation.status).toBe("draft");
|
||||||
|
expect(installation.containerName).toBe("mcp-test-mcp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists status transitions", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "State MCP",
|
||||||
|
slug: "state-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
updateInstallation(installation.id, { status: "building" });
|
||||||
|
updateInstallation(installation.id, { status: "built", imageTag: "mcpmgr/state-mcp:latest" });
|
||||||
|
const final = updateInstallation(installation.id, { status: "running", bearerToken: "secret-token" });
|
||||||
|
|
||||||
|
expect(final.status).toBe("running");
|
||||||
|
expect(final.imageTag).toBe("mcpmgr/state-mcp:latest");
|
||||||
|
expect(getInstallationById(installation.id)?.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips encrypted env vars", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "Env MCP",
|
||||||
|
slug: "env-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateInstallation(installation.id, { env: { FOO: "bar", API_KEY: "shh" } });
|
||||||
|
|
||||||
|
expect(updated.env).toEqual({ FOO: "bar", API_KEY: "shh" });
|
||||||
|
expect(getInstallationById(installation.id)?.env).toEqual({ FOO: "bar", API_KEY: "shh" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,8 @@ export async function runInstallationContainer(installation: Installation): Prom
|
|||||||
HostConfig: {
|
HostConfig: {
|
||||||
RestartPolicy: { Name: "unless-stopped" },
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
NetworkMode: config.mcpNetworkName,
|
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;
|
return container.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getImageId(imageTag: string): Promise<string | undefined> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
export async function stopInstallationContainer(containerName: string): Promise<void> {
|
||||||
await removeContainerIfExists(containerName);
|
await removeContainerIfExists(containerName);
|
||||||
}
|
}
|
||||||
|
|||||||
35
apps/server/src/middleware/requireAuth.test.ts
Normal file
35
apps/server/src/middleware/requireAuth.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { requireAuth } from "./requireAuth.js";
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
const res = {} as Response;
|
||||||
|
res.status = vi.fn().mockReturnValue(res);
|
||||||
|
res.json = vi.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("requireAuth", () => {
|
||||||
|
it("calls next when the session is authenticated", () => {
|
||||||
|
const req = { session: { authenticated: true } } as unknown as Request;
|
||||||
|
const res = mockRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 401 when the session is not authenticated", () => {
|
||||||
|
const req = { session: {} } as unknown as Request;
|
||||||
|
const res = mockRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: "authentication required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
} from "../db/installations.js";
|
} from "../db/installations.js";
|
||||||
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
|
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
|
||||||
import { runNixpacksBuild } from "../build/nixpacks.js";
|
import { runNixpacksBuild } from "../build/nixpacks.js";
|
||||||
import { runInstallationContainer, stopInstallationContainer, waitForContainerHealth } from "../docker/containers.js";
|
import {
|
||||||
|
runInstallationContainer,
|
||||||
|
stopInstallationContainer,
|
||||||
|
waitForContainerHealth,
|
||||||
|
getImageId,
|
||||||
|
removeImageIfUnused,
|
||||||
|
} from "../docker/containers.js";
|
||||||
import { generateBearerToken } from "../crypto/secrets.js";
|
import { generateBearerToken } from "../crypto/secrets.js";
|
||||||
|
|
||||||
export interface DraftResult {
|
export interface DraftResult {
|
||||||
@@ -58,6 +64,8 @@ export async function buildInstallation(id: string): Promise<Installation> {
|
|||||||
|
|
||||||
updateInstallation(id, { status: "building", statusDetail: null });
|
updateInstallation(id, { status: "building", statusDetail: null });
|
||||||
const imageTag = `mcpmgr/${installation.slug}:latest`;
|
const imageTag = `mcpmgr/${installation.slug}:latest`;
|
||||||
|
const previousImageId = installation.imageTag ? await getImageId(installation.imageTag) : undefined;
|
||||||
|
|
||||||
const result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag);
|
const result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -67,6 +75,15 @@ export async function buildInstallation(id: string): Promise<Installation> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nixpacks moves the tag to the new image, leaving the previous one dangling — clean it up
|
||||||
|
// so rebuilds don't accumulate untagged images forever.
|
||||||
|
if (previousImageId) {
|
||||||
|
const newImageId = await getImageId(imageTag);
|
||||||
|
if (newImageId && newImageId !== previousImageId) {
|
||||||
|
await removeImageIfUnused(previousImageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
|
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,4 +115,9 @@ export async function removeInstallationContainer(id: string): Promise<void> {
|
|||||||
const installation = getInstallationById(id);
|
const installation = getInstallationById(id);
|
||||||
if (!installation) return;
|
if (!installation) return;
|
||||||
await stopInstallationContainer(installation.containerName);
|
await stopInstallationContainer(installation.containerName);
|
||||||
|
|
||||||
|
if (installation.imageTag) {
|
||||||
|
const imageId = await getImageId(installation.imageTag);
|
||||||
|
if (imageId) await removeImageIfUnused(imageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
MASTER_KEY: ${MASTER_KEY}
|
MASTER_KEY: ${MASTER_KEY}
|
||||||
|
CONTAINER_MEMORY_MB: ${CONTAINER_MEMORY_MB:-512}
|
||||||
|
CONTAINER_CPUS: ${CONTAINER_CPUS:-1}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./data:/repo/data
|
- ./data:/repo/data
|
||||||
|
|||||||
1007
package-lock.json
generated
1007
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "npm run dev --workspace apps/server"
|
"dev:server": "npm run dev --workspace apps/server",
|
||||||
|
"test": "npm run test --workspace apps/server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user