440 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|