feat: support host bind-mount volumes per installation
Some checks failed
CI / test (push) Has been cancelled

Needed for state that must survive rebuilds and that nothing else
manages — e.g. a Playwright browser profile holding an existing
logged-in session for a browser-automation MCP. hostVolumes is a list
of {hostPath, containerPath} pairs, bind-mounted via dockerode same as
the manager's own compose volumes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:16:45 +02:00
parent c918b32e72
commit 7b6e1b5855
5 changed files with 22 additions and 0 deletions

View File

@@ -68,6 +68,7 @@ installationsRouter.patch("/:id/config", (req, res) => {
basicAuthPassword, basicAuthPassword,
startCommand, startCommand,
publishHostPort, publishHostPort,
hostVolumes,
} = req.body ?? {}; } = req.body ?? {};
try { try {
const installation = configureInstallation(req.params.id, { const installation = configureInstallation(req.params.id, {
@@ -80,6 +81,7 @@ installationsRouter.patch("/:id/config", (req, res) => {
basicAuthPassword, basicAuthPassword,
startCommand, startCommand,
publishHostPort, publishHostPort,
hostVolumes,
}); });
res.json(toPublicJson(installation)); res.json(toPublicJson(installation));
} catch (err) { } catch (err) {

View File

@@ -38,6 +38,7 @@ for (const ddl of [
"ALTER TABLE installations ADD COLUMN basic_auth_password_encrypted TEXT", "ALTER TABLE installations ADD COLUMN basic_auth_password_encrypted TEXT",
"ALTER TABLE installations ADD COLUMN start_command TEXT", "ALTER TABLE installations ADD COLUMN start_command TEXT",
"ALTER TABLE installations ADD COLUMN publish_host_port INTEGER", "ALTER TABLE installations ADD COLUMN publish_host_port INTEGER",
"ALTER TABLE installations ADD COLUMN host_volumes_json TEXT",
]) { ]) {
try { try {
db.exec(ddl); db.exec(ddl);

View File

@@ -12,6 +12,11 @@ export type InstallationStatus =
| "stopped" | "stopped"
| "error"; | "error";
export interface HostVolume {
hostPath: string;
containerPath: string;
}
export interface Installation { export interface Installation {
id: string; id: string;
slug: string; slug: string;
@@ -33,6 +38,7 @@ export interface Installation {
basicAuthPassword: string | null; basicAuthPassword: string | null;
startCommand: string | null; startCommand: string | null;
publishHostPort: number | null; publishHostPort: number | null;
hostVolumes: HostVolume[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -58,6 +64,7 @@ interface Row {
basic_auth_password_encrypted: string | null; basic_auth_password_encrypted: string | null;
start_command: string | null; start_command: string | null;
publish_host_port: number | null; publish_host_port: number | null;
host_volumes_json: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -84,6 +91,7 @@ function rowToInstallation(row: Row): Installation {
basicAuthPassword: row.basic_auth_password_encrypted ? decryptSecret(row.basic_auth_password_encrypted) : null, basicAuthPassword: row.basic_auth_password_encrypted ? decryptSecret(row.basic_auth_password_encrypted) : null,
startCommand: row.start_command, startCommand: row.start_command,
publishHostPort: row.publish_host_port, publishHostPort: row.publish_host_port,
hostVolumes: row.host_volumes_json ? JSON.parse(row.host_volumes_json) : [],
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -148,6 +156,7 @@ export interface InstallationUpdate {
basicAuthPassword?: string | null; basicAuthPassword?: string | null;
startCommand?: string | null; startCommand?: string | null;
publishHostPort?: number | null; publishHostPort?: number | null;
hostVolumes?: HostVolume[];
gitRef?: string | null; gitRef?: string | null;
} }
@@ -172,6 +181,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
patch.basicAuthPassword !== undefined ? patch.basicAuthPassword : current.basicAuthPassword, patch.basicAuthPassword !== undefined ? patch.basicAuthPassword : current.basicAuthPassword,
startCommand: patch.startCommand !== undefined ? patch.startCommand : current.startCommand, startCommand: patch.startCommand !== undefined ? patch.startCommand : current.startCommand,
publishHostPort: patch.publishHostPort !== undefined ? patch.publishHostPort : current.publishHostPort, publishHostPort: patch.publishHostPort !== undefined ? patch.publishHostPort : current.publishHostPort,
hostVolumes: patch.hostVolumes !== undefined ? patch.hostVolumes : current.hostVolumes,
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef, gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
}; };
@@ -191,6 +201,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
basic_auth_password_encrypted = @basicAuthPasswordEncrypted, basic_auth_password_encrypted = @basicAuthPasswordEncrypted,
start_command = @startCommand, start_command = @startCommand,
publish_host_port = @publishHostPort, publish_host_port = @publishHostPort,
host_volumes_json = @hostVolumesJson,
git_ref = @gitRef, git_ref = @gitRef,
updated_at = @updatedAt updated_at = @updatedAt
WHERE id = @id`, WHERE id = @id`,
@@ -210,6 +221,7 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null, basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null,
startCommand: next.startCommand, startCommand: next.startCommand,
publishHostPort: next.publishHostPort, publishHostPort: next.publishHostPort,
hostVolumesJson: JSON.stringify(next.hostVolumes),
gitRef: next.gitRef, gitRef: next.gitRef,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });

View File

@@ -40,6 +40,10 @@ export async function runInstallationContainer(installation: Installation): Prom
? { [`${installation.internalPort}/tcp`]: [{ HostIp: "127.0.0.1", HostPort: String(installation.publishHostPort) }] } ? { [`${installation.internalPort}/tcp`]: [{ HostIp: "127.0.0.1", HostPort: String(installation.publishHostPort) }] }
: undefined; : undefined;
// Bind-mounts a host directory into the container — for state that must survive rebuilds
// and that nothing else manages (e.g. a browser profile with an existing logged-in session).
const binds = installation.hostVolumes.map((v) => `${v.hostPath}:${v.containerPath}`);
const container = await docker.createContainer({ const container = await docker.createContainer({
name: installation.containerName, name: installation.containerName,
Image: installation.imageTag, Image: installation.imageTag,
@@ -55,6 +59,7 @@ export async function runInstallationContainer(installation: Installation): Prom
...(config.containerMemoryMb > 0 ? { Memory: config.containerMemoryMb * 1024 * 1024 } : {}), ...(config.containerMemoryMb > 0 ? { Memory: config.containerMemoryMb * 1024 * 1024 } : {}),
...(config.containerCpus > 0 ? { NanoCpus: Math.round(config.containerCpus * 1e9) } : {}), ...(config.containerCpus > 0 ? { NanoCpus: Math.round(config.containerCpus * 1e9) } : {}),
...(portBinding ? { PortBindings: portBinding } : {}), ...(portBinding ? { PortBindings: portBinding } : {}),
...(binds.length > 0 ? { Binds: binds } : {}),
}, },
}); });

View File

@@ -1,5 +1,6 @@
import { import {
type Installation, type Installation,
type HostVolume,
createInstallation, createInstallation,
updateInstallation, updateInstallation,
getInstallationById, getInstallationById,
@@ -57,6 +58,7 @@ export function configureInstallation(
basicAuthPassword?: string | null; basicAuthPassword?: string | null;
startCommand?: string | null; startCommand?: string | null;
publishHostPort?: number | null; publishHostPort?: number | null;
hostVolumes?: HostVolume[];
}, },
): Installation { ): Installation {
return updateInstallation(id, patch); return updateInstallation(id, patch);