Files
remote-mcp-manager/apps/server/src/service/orchestrator.ts
Dasemu c918b32e72
Some checks failed
CI / test (push) Has been cancelled
feat: support publishing a container's port to host loopback
Some installations need to stay reachable by a native process on the
same host outside the manager's own proxy — e.g. a legacy nginx
location serving media/signed URLs for an app that also happens to be
an MCP server. Previously this required manually recreating the
container outside the manager's lifecycle (lost on the next
build/redeploy). publishHostPort makes it a real, persisted setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 07:05:56 +02:00

133 lines
4.3 KiB
TypeScript

import {
type Installation,
createInstallation,
updateInstallation,
getInstallationById,
getInstallationBySlug,
slugify,
} from "../db/installations.js";
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
import { runNixpacksBuild } from "../build/nixpacks.js";
import {
runInstallationContainer,
stopInstallationContainer,
waitForContainerHealth,
getImageId,
removeImageIfUnused,
} from "../docker/containers.js";
import { generateBearerToken } from "../crypto/secrets.js";
export interface DraftResult {
installation: Installation;
detectedEnvKeys: string[];
}
export async function createDraftInstallation(input: {
name: string;
repoUrl: string;
gitRef?: string;
}): Promise<DraftResult> {
const slug = slugify(input.name);
if (getInstallationBySlug(slug)) {
throw new Error(`an installation with slug "${slug}" already exists`);
}
const installation = createInstallation({ name: input.name, slug, repoUrl: input.repoUrl, gitRef: input.gitRef });
try {
updateInstallation(installation.id, { status: "cloning" });
const dir = await cloneOrUpdateRepo(slug, input.repoUrl, input.gitRef);
const detectedEnvKeys = readEnvExampleKeys(dir);
const updated = updateInstallation(installation.id, { status: "draft", statusDetail: null });
return { installation: updated, detectedEnvKeys };
} catch (err) {
const updated = updateInstallation(installation.id, { status: "error", statusDetail: (err as Error).message });
return { installation: updated, detectedEnvKeys: [] };
}
}
export function configureInstallation(
id: string,
patch: {
env?: Record<string, string>;
portEnvVar?: string;
hostEnvVar?: string;
httpPath?: string;
internalPort?: number;
basicAuthUsername?: string | null;
basicAuthPassword?: string | null;
startCommand?: string | null;
publishHostPort?: number | null;
},
): Installation {
return updateInstallation(id, patch);
}
export async function buildInstallation(id: string): Promise<Installation> {
const installation = getInstallationById(id);
if (!installation) throw new Error("installation not found");
updateInstallation(id, { status: "building", statusDetail: null });
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,
installation.startCommand,
);
if (!result.success) {
return updateInstallation(id, {
status: "error",
statusDetail: `build failed, see log at ${result.logPath}`,
});
}
// 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 });
}
export async function deployInstallation(id: string): Promise<Installation> {
let installation = getInstallationById(id);
if (!installation) throw new Error("installation not found");
updateInstallation(id, { status: "starting", statusDetail: null });
try {
await runInstallationContainer(installation);
await waitForContainerHealth(installation);
const bearerToken = installation.bearerToken ?? generateBearerToken();
return updateInstallation(id, { status: "running", statusDetail: null, bearerToken });
} catch (err) {
return updateInstallation(id, { status: "error", statusDetail: (err as Error).message });
}
}
export async function redeployInstallation(id: string, opts: { rebuild?: boolean } = {}): Promise<Installation> {
if (opts.rebuild) {
await buildInstallation(id);
}
return deployInstallation(id);
}
export async function removeInstallationContainer(id: string): Promise<void> {
const installation = getInstallationById(id);
if (!installation) return;
await stopInstallationContainer(installation.containerName);
if (installation.imageTag) {
const imageId = await getImageId(installation.imageTag);
if (imageId) await removeImageIfUnused(imageId);
}
}