qwen_2.5_model / src /modules /docker /docker.service.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
19.9 kB
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import Docker from 'dockerode';
/**
* The only Docker profiles OpenWA manages (and may start/stop/remove). Used to bound teardown so a
* caller-supplied profile name can never reach removeService for an unrelated container.
*/
export const MANAGED_DOCKER_PROFILES: readonly string[] = ['postgres', 'redis', 'minio'];
interface ContainerInfo {
id: string;
name: string;
state: string;
status: string;
labels: Record<string, string>;
}
interface OrchestrationResult {
success: boolean;
message: string;
containersStarted: string[];
containersStopped: string[];
containersRemoved: string[];
errors: string[];
estimatedTime: number; // Estimated restart time in seconds
}
@Injectable()
export class DockerService implements OnModuleInit {
private readonly logger = new Logger(DockerService.name);
private docker: Docker | null = null;
private isAvailable = false;
private reinitInFlight = false;
async onModuleInit() {
await this.initializeDocker();
// Bootstrap orchestration: start containers based on saved config
await this.bootstrapOrchestration();
}
/**
* Bootstrap orchestration: start built-in containers based on saved config
* This runs on application startup to ensure containers match saved configuration
*/
private async bootstrapOrchestration(): Promise<void> {
if (!this.isAvailable) {
this.logger.log('[Bootstrap Orchestration] Docker not available, skipping');
return;
}
const profiles: string[] = [];
// Check for built-in services from environment variables
if (process.env.REDIS_BUILTIN === 'true') {
profiles.push('redis');
}
if (process.env.POSTGRES_BUILTIN === 'true') {
profiles.push('postgres');
}
if (process.env.MINIO_BUILTIN === 'true') {
profiles.push('minio');
}
if (profiles.length === 0) {
this.logger.log('[Bootstrap Orchestration] No built-in services configured');
return;
}
this.logger.log(`[Bootstrap Orchestration] Starting built-in services: ${profiles.join(', ')}`);
const result = await this.orchestrateProfiles(profiles);
if (result.success) {
this.logger.log(`[Bootstrap Orchestration] Started ${result.containersStarted.length} container(s)`);
} else {
this.logger.warn(`[Bootstrap Orchestration] Issues: ${result.errors.join('; ')}`);
}
}
private async initializeDocker(): Promise<void> {
try {
this.docker = new Docker(this.buildDockerOptions());
await this.docker.ping();
this.isAvailable = true;
this.logger.log('Docker API connected successfully');
} catch (error) {
this.logger.warn(
'Docker not available. Container orchestration disabled.',
error instanceof Error ? error.message : error,
);
this.isAvailable = false;
}
}
// Visible for testing
buildDockerOptions(): Docker.DockerOptions {
const dockerHost = process.env.DOCKER_HOST;
if (dockerHost) {
const match = /^tcp:\/\/([^:]+):(\d+)$/.exec(dockerHost);
if (match) {
return { host: match[1], port: parseInt(match[2], 10), protocol: 'http' };
}
}
return { socketPath: '/var/run/docker.sock' };
}
/**
* Check if Docker is available.
*
* Startup-race recovery: when the API talks to the Docker socket-proxy over TCP
* (DOCKER_HOST=tcp://...), the proxy container may not be accepting connections at
* the moment onModuleInit runs (compose `service_started` doesn't wait for readiness).
* If the first connect failed, retry it once in the background here so orchestration
* recovers without a process restart. Only for the DOCKER_HOST (proxy/tcp) case — a
* socket-based or docker-less deployment has no such race.
*/
isDockerAvailable(): boolean {
if (!this.isAvailable && !this.reinitInFlight && process.env.DOCKER_HOST) {
this.reinitInFlight = true;
void this.initializeDocker().finally(() => {
this.reinitInFlight = false;
});
}
return this.isAvailable;
}
/**
* List all OpenWA-related containers
*/
async listContainers(): Promise<ContainerInfo[]> {
if (!this.docker || !this.isAvailable) {
return [];
}
try {
const containers = await this.docker.listContainers({ all: true });
return containers
.filter(c => {
// Filter by OpenWA labels or name prefix
const labels = c.Labels || {};
return labels['com.openwa.service'] || c.Names?.some(n => n.startsWith('/openwa-'));
})
.map(c => ({
id: c.Id.substring(0, 12),
name: c.Names?.[0]?.replace(/^\//, '') || 'unknown',
state: c.State || 'unknown',
status: c.Status || 'unknown',
labels: c.Labels || {},
}));
} catch (error) {
this.logger.error('Failed to list containers', error);
return [];
}
}
/**
* Which bundled (OpenWA-managed) service containers are currently RUNNING, keyed by the
* `com.openwa.service` label (`database` | `cache` | `storage`). Lets the dashboard show the real
* built-in state instead of the saved intent. All false when Docker is unavailable or none run.
*/
async getRunningBuiltinServices(): Promise<{ database: boolean; cache: boolean; storage: boolean }> {
const containers = await this.listContainers();
const isRunning = (svc: string): boolean =>
containers.some(
c =>
c.labels['com.openwa.service'] === svc && c.labels['com.openwa.builtin'] === 'true' && c.state === 'running',
);
return { database: isRunning('database'), cache: isRunning('cache'), storage: isRunning('storage') };
}
/**
* Get container by service name or label
*/
async getContainerByService(service: string): Promise<Docker.Container | null> {
if (!this.docker || !this.isAvailable) {
return null;
}
try {
const containers = await this.docker.listContainers({
all: true,
filters: {
label: [`com.openwa.service=${service}`],
},
});
if (containers.length > 0) {
return this.docker.getContainer(containers[0].Id);
}
// Fallback: try by EXACT name (never a substring — a substring, and especially the empty
// string, would resolve an arbitrary container). OpenWA-managed containers are `openwa-<service>`.
const target = `openwa-${service}`;
const allContainers = await this.docker.listContainers({ all: true });
const match = allContainers.find(c => c.Names?.some(n => n === target || n === `/${target}`));
if (match) {
return this.docker.getContainer(match.Id);
}
return null;
} catch (error) {
this.logger.error(`Failed to get container for service: ${service}`, error);
return null;
}
}
/**
* Container specifications for optional services
* Mirrors docker-compose.yml settings but uses Docker API directly
*/
private getContainerSpec(profile: string): {
image: string;
name: string;
alias: string; // DNS alias for network resolution
env?: string[];
cmd?: string[];
volumes?: { name: string; path: string }[];
healthcheck?: { test: string[]; interval: number; timeout: number; retries: number };
labels: Record<string, string>;
ports?: { container: number; host: number }[];
} | null {
const specs: Record<string, ReturnType<typeof this.getContainerSpec>> = {
redis: {
image: 'redis:7-alpine',
name: 'openwa-redis',
alias: 'redis', // DNS alias for resolution
cmd: ['redis-server', '--appendonly', 'yes'],
volumes: [{ name: 'openwa_redis-data', path: '/data' }],
healthcheck: {
test: ['CMD', 'redis-cli', 'ping'],
interval: 5000000000, // 5s in nanoseconds
timeout: 3000000000,
retries: 5,
},
labels: {
'com.openwa.service': 'cache',
'com.openwa.builtin': 'true',
},
},
postgres: {
image: 'postgres:16-alpine',
name: 'openwa-postgres',
alias: 'postgres',
// Use hardcoded defaults for built-in container (don't inherit SQLite paths)
env: ['POSTGRES_USER=openwa', 'POSTGRES_PASSWORD=openwa', 'POSTGRES_DB=openwa'],
volumes: [{ name: 'openwa_postgres-data', path: '/var/lib/postgresql/data' }],
healthcheck: {
test: ['CMD-SHELL', 'pg_isready -U openwa'],
interval: 5000000000,
timeout: 3000000000,
retries: 5,
},
labels: {
'com.openwa.service': 'database',
'com.openwa.builtin': 'true',
},
},
minio: {
image: 'minio/minio',
name: 'openwa-minio',
alias: 'minio',
cmd: ['server', '/data', '--console-address', ':9001'],
env: [
// Prefer the canonical names the app/dashboard use; fall back to the legacy ones, then the
// built-in default, so the bundled MinIO and the app share credentials.
`MINIO_ROOT_USER=${process.env.S3_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY || 'minioadmin'}`,
`MINIO_ROOT_PASSWORD=${process.env.S3_SECRET_ACCESS_KEY || process.env.S3_SECRET_KEY || 'minioadmin'}`,
],
volumes: [{ name: 'openwa_minio-data', path: '/data' }],
ports: [
{ container: 9000, host: 9000 },
{ container: 9001, host: 9001 },
],
healthcheck: {
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'],
interval: 10000000000,
timeout: 5000000000,
retries: 3,
},
labels: {
'com.openwa.service': 'storage',
'com.openwa.builtin': 'true',
},
},
};
return specs[profile] || null;
}
/**
* Create and start a service using Docker API directly
*/
async createService(profile: string): Promise<boolean> {
if (!this.docker || !this.isAvailable) {
this.logger.error('Docker not available for creating service');
return false;
}
const spec = this.getContainerSpec(profile);
if (!spec) {
this.logger.error(`Unknown profile: ${profile}`);
return false;
}
this.logger.log(`Creating service: ${profile} (image: ${spec.image})`);
try {
// Check if container already exists
const existing = await this.getContainerByService(profile);
if (existing) {
const info = await existing.inspect();
if (info.State.Running) {
this.logger.log(`Container ${spec.name} already running`);
return true;
}
// Start existing container
await existing.start();
this.logger.log(`Started existing container: ${spec.name}`);
return true;
}
// Pull image first
this.logger.log(`Pulling image: ${spec.image}`);
await new Promise<void>((resolve, reject) => {
void this.docker!.pull(spec.image, (err: Error | null, stream: NodeJS.ReadableStream) => {
if (err) return reject(err);
this.docker!.modem.followProgress(stream, (err2: Error | null) => {
if (err2) return reject(err2);
resolve();
});
});
});
// Create volume if needed
if (spec.volumes) {
for (const vol of spec.volumes) {
try {
await this.docker.createVolume({ Name: vol.name });
this.logger.log(`Created volume: ${vol.name}`);
} catch (error) {
this.logger.debug(`Volume ${vol.name} creation skipped (may already exist)`, { error: String(error) });
}
}
}
// Create container
const containerConfig: Docker.ContainerCreateOptions = {
name: spec.name,
Image: spec.image,
Cmd: spec.cmd,
Env: spec.env,
Labels: spec.labels,
HostConfig: {
NetworkMode: 'openwa-network',
RestartPolicy: { Name: 'unless-stopped' },
Binds: spec.volumes?.map(v => `${v.name}:${v.path}`),
PortBindings: spec.ports?.reduce<Record<string, { HostIp: string; HostPort: string }[]>>((acc, p) => {
acc[`${p.container}/tcp`] = [{ HostIp: '127.0.0.1', HostPort: p.host.toString() }];
return acc;
}, {}),
},
Healthcheck: spec.healthcheck
? {
Test: spec.healthcheck.test,
Interval: spec.healthcheck.interval,
Timeout: spec.healthcheck.timeout,
Retries: spec.healthcheck.retries,
}
: undefined,
NetworkingConfig: {
EndpointsConfig: {
'openwa-network': {
Aliases: [spec.alias, profile], // Add DNS aliases for network resolution
},
},
},
};
const container = await this.docker.createContainer(containerConfig);
await container.start();
this.logger.log(`Created and started container: ${spec.name}`);
return true;
} catch (error) {
this.logger.error(`Failed to create service ${profile}: ${error instanceof Error ? error.message : error}`);
return false;
}
}
/**
* Start a container by service name - creates if not exists
*/
async startService(service: string): Promise<boolean> {
const container = await this.getContainerByService(service);
if (!container) {
// Container doesn't exist - create it using docker-compose
this.logger.log(`Container for service '${service}' not found, creating...`);
// Map service names to docker-compose profiles
const serviceToProfile: Record<string, string> = {
database: 'postgres',
cache: 'redis',
storage: 'minio',
postgres: 'postgres',
redis: 'redis',
minio: 'minio',
};
const profile = serviceToProfile[service] || service;
return this.createService(profile);
}
try {
const info = await container.inspect();
if (info.State.Running) {
this.logger.log(`Service '${service}' is already running`);
return true;
}
await container.start();
this.logger.log(`Started service: ${service}`);
return true;
} catch (error) {
this.logger.error(`Failed to start service: ${service}`, error);
return false;
}
}
/**
* Stop and remove a container by service name to save space
*/
async removeService(profile: string): Promise<boolean> {
this.logger.log(`Removing service with profile: ${profile}`);
// First try to get the container and remove via dockerode
const serviceMap: Record<string, string> = {
postgres: 'database',
redis: 'cache',
minio: 'storage',
};
const service = serviceMap[profile] || profile;
const container = await this.getContainerByService(service);
if (container) {
try {
const info = await container.inspect();
if (info.State.Running) {
await container.stop();
this.logger.log(`Stopped container: ${profile}`);
}
// v: true removes only the container's ANONYMOUS volumes; named datastore volumes
// (redis/postgres/minio data) are preserved, so disable + re-enable keeps the data.
await container.remove({ v: true });
this.logger.log(`Removed container: ${profile}`);
return true;
} catch (error) {
this.logger.error(`Failed to remove container: ${error instanceof Error ? error.message : error}`);
return false;
}
}
// Container doesn't exist - that's fine for removal
this.logger.log(`Container for service '${profile}' not found, nothing to remove`);
return true;
}
/**
* Stop a container by service name (without removing)
*/
async stopService(service: string): Promise<boolean> {
const container = await this.getContainerByService(service);
if (!container) {
this.logger.warn(`Container for service '${service}' not found`);
return true; // Already doesn't exist
}
try {
const info = await container.inspect();
if (!info.State.Running) {
this.logger.log(`Service '${service}' is already stopped`);
return true;
}
await container.stop();
this.logger.log(`Stopped service: ${service}`);
return true;
} catch (error) {
this.logger.error(`Failed to stop service: ${service}`, error);
return false;
}
}
/**
* Orchestrate services based on required profiles
* This will start containers that match the profiles
*/
async orchestrateProfiles(profiles: string[]): Promise<OrchestrationResult> {
// Calculate estimated time based on profiles
// Base: 15 seconds for core restart (increased for reliability)
let estimatedTime = 15;
if (profiles.includes('postgres')) estimatedTime += 20; // PostgreSQL takes longer
if (profiles.includes('redis')) estimatedTime += 13;
if (profiles.includes('minio')) estimatedTime += 15;
const result: OrchestrationResult = {
success: true,
message: '',
containersStarted: [],
containersStopped: [],
containersRemoved: [],
errors: [],
estimatedTime,
};
if (!this.docker || !this.isAvailable) {
result.success = false;
result.message = 'Docker is not available';
return result;
}
this.logger.log(`Orchestrating profiles: ${profiles.join(', ')}`);
// Map profiles to service names
const profileToService: Record<string, string> = {
postgres: 'database',
redis: 'cache',
minio: 'storage',
};
for (const profile of profiles) {
const service = profileToService[profile] || profile;
try {
const started = await this.startService(service);
if (started) {
result.containersStarted.push(profile);
} else {
// Container might not exist yet - this is expected for first-time setup
result.errors.push(
`Service '${profile}' container not found. It may need to be created first with docker-compose.`,
);
}
} catch (error) {
result.errors.push(`Failed to start ${profile}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
if (result.errors.length > 0) {
result.success = profiles.length > 0 && result.containersStarted.length > 0;
result.message = result.errors.join('; ');
} else {
result.message = `Successfully orchestrated ${result.containersStarted.length} service(s)`;
}
return result;
}
/**
* Get Docker system info
*/
async getSystemInfo(): Promise<{ available: boolean; info?: Record<string, unknown> }> {
if (!this.docker || !this.isAvailable) {
return { available: false };
}
try {
const info = (await this.docker.info()) as {
Containers: number;
ContainersRunning: number;
ContainersPaused: number;
ContainersStopped: number;
Images: number;
ServerVersion: string;
OperatingSystem: string;
Architecture: string;
};
return {
available: true,
info: {
containers: info.Containers,
containersRunning: info.ContainersRunning,
containersPaused: info.ContainersPaused,
containersStopped: info.ContainersStopped,
images: info.Images,
serverVersion: info.ServerVersion,
operatingSystem: info.OperatingSystem,
architecture: info.Architecture,
},
};
} catch (error) {
this.logger.error('Failed to get Docker info', error);
return { available: false };
}
}
}