feat: use docker run for agent containers + add Docker socket to control-center

- Replace docker service create with docker run for agent deployment
- Add Docker CLI to control-center image for container management
- Mount Docker socket in control-center container
- Add AGENT_DATABASE_URL (Go format) to compose environment
- Fix container status check from Swarm service to docker inspect
- Verify container running after deploy with docker inspect
- Add LLM_BASE_URL and LLM_API_KEY env vars to control-center compose
This commit is contained in:
¨NW¨
2026-04-11 01:48:47 +01:00
parent bf66df31da
commit 32a20234d4
3 changed files with 114 additions and 43 deletions

View File

@@ -24,12 +24,16 @@ RUN pnpm build
# ── Runtime Stage ─────────────────────────────────────────────────────────────
FROM node:22-alpine
# Install runtime dependencies
# Install runtime dependencies including Docker CLI for container management
RUN apk add --no-cache \
wget \
curl \
bash \
netcat-openbsd \
&& curl -fsSL https://get.docker.com/builds/Linux/x86_64/docker-27.5.1.tgz -o /tmp/docker.tgz \
&& tar -xzf /tmp/docker.tgz -C /tmp \
&& mv /tmp/docker/docker /usr/local/bin/docker \
&& rm -rf /tmp/docker.tgz /tmp/docker \
&& rm -rf /var/cache/apk/*
WORKDIR /app
@@ -54,9 +58,11 @@ COPY drizzle/ ./drizzle/
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Run as non-root user
# Run as non-root user with docker access
RUN addgroup -g 1001 goclaw && \
addgroup -g 998 docker && \
adduser -u 1001 -G goclaw -s /bin/sh -D goclaw && \
addgroup goclaw docker && \
chown -R goclaw:goclaw /app
USER goclaw

View File

@@ -152,10 +152,13 @@ services:
environment:
NODE_ENV: production
DATABASE_URL: "mysql://${MYSQL_USER:-goclaw}:${MYSQL_PASSWORD:-goClawPass123}@db:3306/${MYSQL_DATABASE:-goclaw}"
AGENT_DATABASE_URL: "${MYSQL_USER:-goclaw}:${MYSQL_PASSWORD:-goClawPass123}@tcp(db:3306)/${MYSQL_DATABASE:-goclaw}?parseTime=true"
GATEWAY_URL: "http://gateway:18789"
JWT_SECRET: "${JWT_SECRET:-change-me-in-production}"
OLLAMA_BASE_URL: "${LLM_BASE_URL:-https://ollama.com/v1}"
OLLAMA_API_KEY: "${LLM_API_KEY:-}"
LLM_BASE_URL: "${LLM_BASE_URL:-https://ollama.com/v1}"
LLM_API_KEY: "${LLM_API_KEY:-}"
VITE_APP_ID: "${VITE_APP_ID:-}"
OAUTH_SERVER_URL: "${OAUTH_SERVER_URL:-}"
VITE_OAUTH_PORTAL_URL: "${VITE_OAUTH_PORTAL_URL:-}"
@@ -163,6 +166,9 @@ services:
BUILT_IN_FORGE_API_KEY: "${BUILT_IN_FORGE_API_KEY:-}"
VITE_FRONTEND_FORGE_API_KEY: "${VITE_FRONTEND_FORGE_API_KEY:-}"
VITE_FRONTEND_FORGE_API_URL: "${VITE_FRONTEND_FORGE_API_URL:-}"
volumes:
# Mount Docker socket for agent container management
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
db:
condition: service_healthy

View File

@@ -378,15 +378,13 @@ export async function saveHistory(
}
}
// ─── Container Management (Docker Swarm) ────────────────────────────────────
// ─── Container Management (Docker) ─────────────────────────────────────────
/**
* Deploy an agent as a Docker Swarm service.
* Each agent runs in its own container with the agent-worker binary.
* Deploy an agent as a Docker container.
* Each agent runs in its own container on the goclaw-net bridge network.
*/
export async function deployAgentContainer(
agentId: number
): Promise<{
export async function deployAgentContainer(agentId: number): Promise<{
success: boolean;
serviceName: string;
servicePort: number;
@@ -410,59 +408,121 @@ export async function deployAgentContainer(
error: "Agent not found",
};
const serviceName = `goclaw-agent-${agentId}`;
const servicePort = 8001 + ((agentId - 1) % 999); // Ports 8001-8999
const containerName = `goclaw-agent-${agentId}`;
const servicePort = 8001 + ((agentId - 1) % 999);
const containerImage =
(agent as any).containerImage || "goclaw-agent-worker:latest";
try {
// Remove existing container if any (stop + rm)
await execAsync(`docker rm -f ${containerName} 2>/dev/null || true`, {
timeout: 10000,
});
// Update status to deploying
await db
.update(agents)
.set({
containerStatus: "deploying",
serviceName,
serviceName: containerName,
servicePort,
} as any)
.where(eq(agents.id, agentId));
// Build Docker Swarm service create command
const dbUrl =
process.env.DATABASE_URL || "mysql://goclaw:goclaw123@db:3306/goclaw";
const llmBaseUrl = process.env.LLM_BASE_URL || "https://api.openai.com/v1";
// Agent-worker uses Go format: user:pass@tcp(host:port)/dbname?parseTime=true
// Control-center DATABASE_URL is Node.js format: mysql://user:pass@host:port/dbname
// Convert if necessary, or use AGENT_DATABASE_URL env var
const dbUrlRaw =
process.env.AGENT_DATABASE_URL ||
process.env.DATABASE_URL ||
"mysql://goclaw:goClawPass123@db:3306/goclaw";
let dbUrl: string;
if (dbUrlRaw.startsWith("mysql://")) {
// Convert Node.js format: mysql://user:pass@host:port/dbname → user:pass@tcp(host:port)/dbname?parseTime=true
const match = dbUrlRaw.match(
/^mysql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)$/
);
if (match) {
const [, user, pass, host, port, dbname] = match;
dbUrl = `${user}:${pass}@tcp(${host}:${port})/${dbname}?parseTime=true`;
} else {
dbUrl = "goclaw:goClawPass123@tcp(db:3306)/goclaw?parseTime=true";
}
} else {
dbUrl = dbUrlRaw; // Already in Go format
}
const llmBaseUrl = process.env.LLM_BASE_URL || "https://ollama.com/v1";
const llmApiKey = process.env.LLM_API_KEY || "";
const defaultModel = (agent as any).model || "qwen2.5:7b";
const cmd = [
"docker service create",
`--name ${serviceName}`,
`--env AGENT_ID=${agentId}`,
`--env AGENT_PORT=${servicePort}`,
`--env DATABASE_URL="${dbUrl}"`,
`--env LLM_BASE_URL="${llmBaseUrl}"`,
`--env LLM_API_KEY="${llmApiKey}"`,
`--network goclaw-overlay`,
`--replicas 1`,
"docker run -d",
`--name ${containerName}`,
`--network goclaw_goclaw-net`,
`-p ${servicePort}:${servicePort}`,
`-e AGENT_ID=${agentId}`,
`-e AGENT_PORT=${servicePort}`,
`-e "DATABASE_URL=${dbUrl}"`,
`-e "LLM_BASE_URL=${llmBaseUrl}"`,
`-e "LLM_API_KEY=${llmApiKey}"`,
`-e "DEFAULT_MODEL=${defaultModel}"`,
`--restart unless-stopped`,
containerImage,
].join(" \\\n ");
console.log(
`[Container] Deploying agent ${agentId}: ${serviceName} on port ${servicePort}`
`[Container] Deploying agent ${agentId}: ${containerName} on port ${servicePort}`
);
const { stdout, stderr } = await execAsync(cmd, { timeout: 30000 });
console.log(
`[Container] Agent ${agentId} container created: ${stdout.trim()}`
);
// Wait briefly and verify the container is running
await new Promise(r => setTimeout(r, 2000));
const { stdout: inspectOut } = await execAsync(
`docker inspect ${containerName} --format '{{.State.Status}}'`,
{ timeout: 5000 }
);
const actualStatus = inspectOut.trim();
if (actualStatus !== "running") {
const { stdout: logs } = await execAsync(
`docker logs ${containerName} --tail 20 2>&1`,
{ timeout: 5000 }
);
console.error(
`[Container] Agent ${agentId} not running. Status: ${actualStatus}`
);
console.error(`[Container] Logs:\n${logs}`);
await db
.update(agents)
.set({ containerStatus: "error" } as any)
.where(eq(agents.id, agentId));
return {
success: false,
serviceName: containerName,
servicePort: 0,
error: `Container status: ${actualStatus}`,
};
}
// Update status to running
await db
.update(agents)
.set({
containerStatus: "running",
serviceName,
serviceName: containerName,
servicePort,
} as any)
.where(eq(agents.id, agentId));
console.log(`[Container] Agent ${agentId} deployed: ${serviceName}`);
return { success: true, serviceName, servicePort };
console.log(`[Container] Agent ${agentId} deployed: ${containerName}`);
return { success: true, serviceName: containerName, servicePort };
} catch (error: any) {
console.error(
`[Container] Failed to deploy agent ${agentId}:`,
@@ -479,7 +539,7 @@ export async function deployAgentContainer(
return {
success: false,
serviceName,
serviceName: containerName,
servicePort: 0,
error: error.message,
};
@@ -487,7 +547,7 @@ export async function deployAgentContainer(
}
/**
* Stop and remove an agent's Docker Swarm service.
* Stop and remove an agent's Docker container.
*/
export async function stopAgentContainer(
agentId: number
@@ -498,17 +558,15 @@ export async function stopAgentContainer(
const agent = await getAgentById(agentId);
if (!agent) return { success: false, error: "Agent not found" };
const serviceName = (agent as any).serviceName || `goclaw-agent-${agentId}`;
const containerName = (agent as any).serviceName || `goclaw-agent-${agentId}`;
try {
// Remove the Docker Swarm service
console.log(`[Container] Stopping agent ${agentId}: ${serviceName}`);
await execAsync(`docker service rm ${serviceName}`, {
console.log(`[Container] Stopping agent ${agentId}: ${containerName}`);
await execAsync(`docker rm -f ${containerName}`, {
timeout: 15000,
}).catch(() => {
// Service may not exist — that's OK
console.log(
`[Container] Service ${serviceName} not found (already removed)`
`[Container] Container ${containerName} not found (already removed)`
);
});
@@ -522,7 +580,7 @@ export async function stopAgentContainer(
} as any)
.where(eq(agents.id, agentId));
console.log(`[Container] Agent ${agentId} stopped: ${serviceName}`);
console.log(`[Container] Agent ${agentId} stopped: ${containerName}`);
return { success: true };
} catch (error: any) {
console.error(
@@ -544,7 +602,7 @@ export async function stopAgentContainer(
/**
* Get the status of an agent's container.
* Checks Docker Swarm service and returns current state.
* Checks Docker container state and returns current status.
*/
export async function getAgentContainerStatus(
agentId: number
@@ -559,19 +617,20 @@ export async function getAgentContainerStatus(
const serviceName = (agent as any).serviceName || "";
const servicePort = (agent as any).servicePort || 0;
// Verify actual Docker Swarm state
// Verify actual Docker container state
if (serviceName) {
try {
const { stdout } = await execAsync(
`docker service inspect ${serviceName} --format '{{.Spec.Mode.Replicated.Replicas}}'`,
`docker inspect ${serviceName} --format '{{.State.Status}}' 2>/dev/null`,
{ timeout: 5000 }
);
const replicas = parseInt(stdout.trim());
if (replicas > 0) {
const state = stdout.trim();
if (state === "running") {
return { status: "running", serviceName, servicePort };
}
return { status: state || "stopped", serviceName, servicePort };
} catch {
// Service doesn't exist
// Container doesn't exist
return { status: "stopped" };
}
}