Files
flynn/src/gateway/handlers/dockerDependencies.ts
T
2026-02-22 20:22:37 -08:00

440 lines
13 KiB
TypeScript

import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import type { Config } from '../../config/index.js';
const execFile = promisify(execFileCb);
export type DockerDependencyId = string;
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
export interface DockerDependencyStatus {
id: DockerDependencyId;
name: string;
service: string;
configured: boolean;
state: string;
health: string;
statusText: string;
containerName: string | null;
availableActions: DockerDependencyAction[];
error?: string;
}
export interface DockerDependencyControlResult {
dependency: DockerDependencyId;
action: DockerDependencyAction;
status: DockerDependencyStatus;
message?: string;
}
type DockerComposeResult = { stdout: string; stderr: string };
type DockerComposeRunner = (args: string[]) => Promise<DockerComposeResult>;
interface ComposePsEntry {
Name?: string;
Service?: string;
State?: string;
Status?: string;
Health?: string;
}
interface ComposeDiscovery {
profileArgs: string[];
services: string[];
}
interface DockerDependencyDescriptor {
id: DockerDependencyId;
name: string;
service: string;
configured: boolean;
}
const COMPOSE_FILE = 'docker-compose.yml';
const FLYNN_SERVICE = 'flynn';
const WHISPER_SERVICE = 'whisper-server';
const WHISPER_DEPENDENCY_ID = 'whisper';
const BRAVE_SEARCH_SERVICE = 'brave-search';
function runCompose(args: string[], timeout: number): Promise<DockerComposeResult> {
return execFile('docker', ['compose', '-f', COMPOSE_FILE, ...args], {
timeout,
maxBuffer: 4 * 1024 * 1024,
}) as Promise<DockerComposeResult>;
}
function defaultRunner(args: string[]): Promise<DockerComposeResult> {
return runCompose(args, 10_000);
}
function defaultControlRunner(args: string[]): Promise<DockerComposeResult> {
return runCompose(args, 15 * 60_000);
}
function normalizeError(error: unknown): string {
if (error && typeof error === 'object') {
const maybe = error as { stderr?: string; stdout?: string; message?: string };
const stderr = maybe.stderr?.trim();
if (stderr) {return stderr;}
const stdout = maybe.stdout?.trim();
if (stdout) {return stdout;}
if (typeof maybe.message === 'string' && maybe.message.trim().length > 0) {
return maybe.message.trim();
}
}
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim();
}
return String(error);
}
function parseServiceList(output: string): string[] {
return output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function unique(items: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const item of items) {
const key = item.trim();
if (!key || seen.has(key)) {continue;}
seen.add(key);
out.push(key);
}
return out;
}
function buildProfileArgs(profiles: string[]): string[] {
return unique(profiles).flatMap((profile) => ['--profile', profile]);
}
function withProfiles(profileArgs: string[], args: string[]): string[] {
return [...profileArgs, ...args];
}
function parseComposePsOutput(output: string): ComposePsEntry[] {
const trimmed = output.trim();
if (!trimmed) {return [];}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is ComposePsEntry => Boolean(entry && typeof entry === 'object'));
}
if (parsed && typeof parsed === 'object') {
return [parsed as ComposePsEntry];
}
} catch {
// Some compose versions emit newline-delimited JSON objects instead of an array.
}
const entries: ComposePsEntry[] = [];
for (const line of trimmed.split('\n')) {
const item = line.trim();
if (!item) {continue;}
try {
const parsed = JSON.parse(item) as unknown;
if (parsed && typeof parsed === 'object') {
entries.push(parsed as ComposePsEntry);
}
} catch {
// Ignore non-JSON lines.
}
}
return entries;
}
function normalizeState(rawState: string | undefined): string {
const state = String(rawState ?? '').trim().toLowerCase();
if (!state) {return 'unknown';}
if (state === 'running') {return 'running';}
if (state === 'exited' || state === 'dead' || state === 'stopped') {return 'stopped';}
if (state === 'created' || state === 'restarting' || state === 'paused') {return state;}
return state;
}
function buildStatusText(state: string, health: string, statusField: string): string {
if (statusField.trim().length > 0) {
return statusField.trim();
}
if (state === 'running' && health && health !== 'none' && health !== 'unknown') {
return `running (${health})`;
}
return state || 'unknown';
}
function computeAvailableActions(state: string): DockerDependencyAction[] {
if (state === 'not-found' || state === 'unavailable') {
return [];
}
if (state === 'running') {
return ['restart', 'stop', 'update'];
}
if (state === 'stopped' || state === 'not-created' || state === 'created') {
return ['start', 'restart', 'update'];
}
if (state === 'restarting' || state === 'paused') {
return ['restart', 'stop', 'update'];
}
return ['start', 'restart', 'stop', 'update'];
}
function isLocalWhisperEndpoint(endpoint: string): boolean {
let parsed: URL;
try {
parsed = new URL(endpoint);
} catch {
return false;
}
const isLocalHost = parsed.hostname === 'localhost'
|| parsed.hostname === '127.0.0.1'
|| parsed.hostname === '::1'
|| parsed.hostname === '0.0.0.0';
if (!isLocalHost) {return false;}
return parsed.pathname.includes('/audio/transcriptions');
}
function isWhisperConfigured(config: Config): boolean {
if (!config.audio.enabled) {return false;}
const endpoint = config.audio.provider?.endpoint;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
return false;
}
return isLocalWhisperEndpoint(endpoint);
}
function isBraveSearchConfigured(config: Config): boolean {
if (config.web_search.provider !== 'brave') {return false;}
const apiKey = config.web_search.api_key;
return typeof apiKey === 'string' && apiKey.trim().length > 0;
}
function toDependencyId(service: string): DockerDependencyId {
if (service === WHISPER_SERVICE) {
return WHISPER_DEPENDENCY_ID;
}
return service;
}
function toDependencyName(service: string): string {
if (service === WHISPER_SERVICE) {
return 'Whisper (whisper.cpp)';
}
return service
.split(/[-_]+/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function describeDependency(service: string, config: Config): DockerDependencyDescriptor {
if (service === WHISPER_SERVICE) {
return {
id: WHISPER_DEPENDENCY_ID,
name: 'Whisper (whisper.cpp)',
service,
configured: isWhisperConfigured(config),
};
}
if (service === BRAVE_SEARCH_SERVICE) {
return {
id: BRAVE_SEARCH_SERVICE,
name: 'Brave Search',
service,
configured: isBraveSearchConfigured(config),
};
}
return {
id: toDependencyId(service),
name: toDependencyName(service),
service,
configured: true,
};
}
function unavailableStatus(error: unknown): DockerDependencyStatus {
return {
id: 'compose',
name: 'Docker Compose',
service: COMPOSE_FILE,
configured: false,
state: 'unavailable',
health: 'none',
statusText: 'unavailable',
containerName: null,
availableActions: [],
error: normalizeError(error),
};
}
async function discoverCompose(runner: DockerComposeRunner): Promise<ComposeDiscovery> {
let profiles: string[] = [];
try {
const response = await runner(['config', '--profiles']);
profiles = parseServiceList(response.stdout);
} catch {
profiles = [];
}
const profileArgs = buildProfileArgs(profiles);
const response = await runner(withProfiles(profileArgs, ['config', '--services']));
const configuredServices = parseServiceList(response.stdout)
.filter((service) => service !== FLYNN_SERVICE);
// Compose `ps --all` can surface running services even when profile discovery is unavailable.
let runtimeServices: string[] = [];
try {
const psResponse = await runner(withProfiles(profileArgs, ['ps', '--all', '--format', 'json']));
runtimeServices = parseComposePsOutput(psResponse.stdout)
.map((entry) => String(entry.Service ?? '').trim())
.filter((service) => service.length > 0 && service !== FLYNN_SERVICE);
} catch {
runtimeServices = [];
}
return {
profileArgs,
services: unique([...configuredServices, ...runtimeServices]),
};
}
export async function listDockerDependencyStatuses(
config: Config,
runner: DockerComposeRunner = defaultRunner,
): Promise<DockerDependencyStatus[]> {
let discovery: ComposeDiscovery;
try {
discovery = await discoverCompose(runner);
} catch (error) {
return [unavailableStatus(error)];
}
if (discovery.services.length === 0) {
return [];
}
return Promise.all(discovery.services.map(async (service): Promise<DockerDependencyStatus> => {
const descriptor = describeDependency(service, config);
const baseStatus: DockerDependencyStatus = {
id: descriptor.id,
name: descriptor.name,
service: descriptor.service,
configured: descriptor.configured,
state: 'unknown',
health: 'unknown',
statusText: 'unknown',
containerName: null,
availableActions: [],
};
try {
const response = await runner(withProfiles(discovery.profileArgs, ['ps', service, '--format', 'json']));
const rows = parseComposePsOutput(response.stdout)
.filter((entry) => (entry.Service ?? '') === service || !entry.Service);
if (rows.length === 0) {
return {
...baseStatus,
state: 'not-created',
health: 'none',
statusText: 'defined, not started',
availableActions: computeAvailableActions('not-created'),
};
}
const row = rows[0];
const state = normalizeState(row.State);
const health = String(row.Health ?? '').trim().toLowerCase() || 'none';
const statusField = String(row.Status ?? '');
return {
...baseStatus,
state,
health,
statusText: buildStatusText(state, health, statusField),
containerName: row.Name?.trim() || null,
availableActions: computeAvailableActions(state),
};
} catch (error) {
return {
...baseStatus,
statusText: 'unknown',
availableActions: computeAvailableActions('unknown'),
error: normalizeError(error),
};
}
}));
}
function ensureValidAction(action: string): asserts action is DockerDependencyAction {
if (action !== 'start' && action !== 'restart' && action !== 'stop' && action !== 'update') {
throw new Error(`Unsupported action: ${action}`);
}
}
async function resolveDiscoveredDependency(
config: Config,
dependency: string,
runner: DockerComposeRunner,
): Promise<{ discovery: ComposeDiscovery; descriptor: DockerDependencyDescriptor }> {
const discovery = await discoverCompose(runner);
const descriptors = discovery.services.map((service) => describeDependency(service, config));
const descriptor = descriptors.find((entry) => entry.id === dependency || entry.service === dependency);
if (!descriptor) {
throw new Error(`Unsupported dependency: ${dependency}`);
}
return { discovery, descriptor };
}
export async function controlDockerDependency(
config: Config,
dependency: string,
action: string,
runner: DockerComposeRunner = defaultControlRunner,
): Promise<DockerDependencyControlResult> {
ensureValidAction(action);
const { discovery, descriptor } = await resolveDiscoveredDependency(config, dependency, runner);
const service = descriptor.service;
let message: string | undefined;
if (action === 'start') {
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
message = `Started ${service} container.`;
} else if (action === 'restart') {
try {
await runner(withProfiles(discovery.profileArgs, ['restart', service]));
message = `Restarted ${service} container.`;
} catch (error) {
const detail = normalizeError(error).toLowerCase();
if (detail.includes('no containers to restart')) {
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
message = `${service} container was not running; started it.`;
} else {
throw error;
}
}
} else if (action === 'stop') {
await runner(withProfiles(discovery.profileArgs, ['stop', service]));
message = `Stopped ${service} container.`;
} else if (action === 'update') {
await runner(withProfiles(discovery.profileArgs, ['pull', service]));
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
message = `Pulled latest ${service} image and reconciled container.`;
}
const status = (await listDockerDependencyStatuses(config, runner))
.find((entry) => entry.id === descriptor.id || entry.service === descriptor.service);
if (!status) {
throw new Error('Failed to load docker dependency status after action.');
}
return {
dependency: descriptor.id,
action,
status,
message,
};
}