Files
NextChair/manager/src/lib/docker.ts
T
Stefan Lazic 4763b50e62
CI / ci (push) Canceled after 0s
Dockge Removal
Removed dockge from the app as it is not required due to the manager page picking up most of its abilities.
2026-09-06 18:33:01 +01:00

92 lines
3.7 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Every call here runs `docker compose` with `cwd` set to a stack's own
* directory (stacks/<slug>/) rather than passing explicit -f/--env-file
* flags — compose already auto-loads `compose.yaml` and `.env` from the
* current directory, so a stack behaves identically whether it's started
* by this app or by hand from the command line.
*
* This app's own container needs the Docker socket mounted and the
* `docker compose` CLI installed (see manager/Dockerfile) — these calls
* are executed by the HOST's dockerd via that socket, not by a nested
* Docker daemon inside this container.
*/
export interface DockerCommandResult {
stdout: string;
stderr: string;
}
async function runDockerCompose(stackDir: string, args: string[]): Promise<DockerCommandResult> {
const result = await execFileAsync("docker", ["compose", ...args], {
cwd: stackDir,
maxBuffer: 10 * 1024 * 1024,
});
return result;
}
export async function composeUp(stackDir: string, options: { forceRecreate?: boolean } = {}): Promise<void> {
const args = ["up", "-d"];
if (options.forceRecreate) args.push("--force-recreate");
await runDockerCompose(stackDir, args);
}
export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise<void> {
const args = ["down"];
if (options.removeVolumes) args.push("-v");
await runDockerCompose(stackDir, args);
}
export async function composeRestart(stackDir: string): Promise<void> {
await runDockerCompose(stackDir, ["restart"]);
}
export async function composeLogs(stackDir: string, tailLines = 200): Promise<string> {
const result = await runDockerCompose(stackDir, ["logs", "--no-color", "--tail", String(tailLines)]);
// Compose writes container logs to stderr, not stdout — this is normal
// (docker's own `logs` command does the same), not a failure signal.
return result.stdout + result.stderr;
}
export type StackRunState = "running" | "stopped" | "unknown";
/** Best-effort — a stack that's never been started at all reports "stopped", not an error. */
export async function composeStatus(stackDir: string): Promise<StackRunState> {
try {
const result = await runDockerCompose(stackDir, ["ps", "--status", "running", "--format", "json"]);
return result.stdout.trim().length > 0 ? "running" : "stopped";
} catch {
return "unknown";
}
}
export async function composeExec(stackDir: string, serviceName: string, command: string[]): Promise<void> {
await runDockerCompose(stackDir, ["exec", "-T", serviceName, ...command]);
}
/** Applies pending Prisma migrations inside a freshly-started business container — retries briefly since the container's Node process may take a moment to come up after `composeUp`. */
export async function runMigrations(stackDir: string, serviceName: string): Promise<void> {
const maxAttempts = 6;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await composeExec(stackDir, serviceName, ["npx", "prisma", "migrate", "deploy"]);
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw lastError instanceof Error ? lastError : new Error("Migrations failed after retries.");
}
/** Caddy doesn't watch its sites/ directory — adding or removing a business's .caddy file needs this to take effect. */
export async function reloadCaddy(): Promise<void> {
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
}