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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
141
server/agents.ts
141
server/agents.ts
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user