Skip to content

Commit 160ed6d

Browse files
Add Docker-based script execution for Docker Compose deployments (#233)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 816c0c3 commit 160ed6d

12 files changed

Lines changed: 812 additions & 2 deletions

RockBot.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<Project Path="src/RockBot.Tools.Mcp/RockBot.Tools.Mcp.csproj" />
2222
<Project Path="src/RockBot.Scripts.Abstractions/RockBot.Scripts.Abstractions.csproj" />
2323
<Project Path="src/RockBot.Scripts.Container/RockBot.Scripts.Container.csproj" />
24+
<Project Path="src/RockBot.Scripts.Docker/RockBot.Scripts.Docker.csproj" />
2425
<Project Path="src/RockBot.Scripts.Local/RockBot.Scripts.Local.csproj" />
2526
<Project Path="src/RockBot.Scripts.Remote/RockBot.Scripts.Remote.csproj" />
2627
<Project Path="src/RockBot.Scripts.Manager/RockBot.Scripts.Manager.csproj" />

deploy/docker-compose/docker-compose.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,26 @@ services:
9999
volumes:
100100
- ${AGENT_DATA_PATH:-agent-data}:/data/agent
101101

102+
scripts-manager:
103+
image: rockylhotka/rockbot-scripts-manager:latest
104+
user: root # Required for Docker socket access
105+
depends_on:
106+
rabbitmq:
107+
condition: service_healthy
108+
environment:
109+
RabbitMq__HostName: rabbitmq
110+
RabbitMq__Port: "5672"
111+
RabbitMq__UserName: rockbot
112+
RabbitMq__Password: rockbot
113+
RabbitMq__VirtualHost: /
114+
Scripts__Provider: Docker
115+
Scripts__Docker__Image: python:3.12-slim
116+
Scripts__Docker__CpuLimit: "500m"
117+
Scripts__Docker__MemoryLimit: 256Mi
118+
Scripts__Docker__NetworkMode: bridge
119+
volumes:
120+
- /var/run/docker.sock:/var/run/docker.sock
121+
102122
blazor:
103123
image: rockylhotka/rockbot-blazor:latest
104124
depends_on:
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using System.Diagnostics;
2+
using Docker.DotNet;
3+
using Docker.DotNet.Models;
4+
using Microsoft.Extensions.Logging;
5+
using RockBot.Host;
6+
using RockBot.Messaging;
7+
8+
namespace RockBot.Scripts.Docker;
9+
10+
/// <summary>
11+
/// Handles script invocation requests by creating ephemeral Docker containers.
12+
/// </summary>
13+
internal sealed class DockerScriptHandler(
14+
IDockerClient docker,
15+
IMessagePublisher publisher,
16+
DockerScriptOptions options,
17+
AgentIdentity agent,
18+
ILogger<DockerScriptHandler> logger) : IMessageHandler<ScriptInvokeRequest>
19+
{
20+
public async Task HandleAsync(ScriptInvokeRequest request, MessageHandlerContext context)
21+
{
22+
var replyTo = context.Envelope.ReplyTo ?? options.DefaultResultTopic;
23+
var correlationId = context.Envelope.CorrelationId;
24+
string? containerId = null;
25+
26+
try
27+
{
28+
var createParams = BuildCreateParameters(request);
29+
var sw = Stopwatch.StartNew();
30+
31+
logger.LogDebug("Creating script container for call {ToolCallId}", request.ToolCallId);
32+
33+
var createResponse = await docker.Containers.CreateContainerAsync(createParams, context.CancellationToken);
34+
containerId = createResponse.ID;
35+
36+
await docker.Containers.StartContainerAsync(containerId, new ContainerStartParameters(), context.CancellationToken);
37+
38+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
39+
cts.CancelAfter(TimeSpan.FromSeconds(request.TimeoutSeconds + 5));
40+
41+
string? stdout = null;
42+
string? stderr = null;
43+
int exitCode;
44+
45+
try
46+
{
47+
var waitResponse = await docker.Containers.WaitContainerAsync(containerId, cts.Token);
48+
sw.Stop();
49+
50+
(stdout, stderr) = await ReadLogsAsync(containerId, context.CancellationToken);
51+
exitCode = (int)waitResponse.StatusCode;
52+
}
53+
catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
54+
{
55+
throw;
56+
}
57+
catch (OperationCanceledException)
58+
{
59+
sw.Stop();
60+
exitCode = -1;
61+
stderr = $"Container timed out after {request.TimeoutSeconds}s";
62+
}
63+
64+
var response = new ScriptInvokeResponse
65+
{
66+
ToolCallId = request.ToolCallId,
67+
Output = stdout,
68+
Stderr = stderr,
69+
ExitCode = exitCode,
70+
ElapsedMs = sw.ElapsedMilliseconds
71+
};
72+
73+
var envelope = response.ToEnvelope<ScriptInvokeResponse>(
74+
source: agent.Name,
75+
correlationId: correlationId);
76+
77+
await publisher.PublishAsync(replyTo, envelope, context.CancellationToken);
78+
}
79+
catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
80+
{
81+
throw;
82+
}
83+
catch (Exception ex)
84+
{
85+
logger.LogWarning(ex, "Script execution failed for call {ToolCallId}", request.ToolCallId);
86+
87+
var response = new ScriptInvokeResponse
88+
{
89+
ToolCallId = request.ToolCallId,
90+
Stderr = ex.Message,
91+
ExitCode = -1,
92+
ElapsedMs = 0
93+
};
94+
95+
var envelope = response.ToEnvelope<ScriptInvokeResponse>(
96+
source: agent.Name,
97+
correlationId: correlationId);
98+
99+
await publisher.PublishAsync(replyTo, envelope, context.CancellationToken);
100+
}
101+
finally
102+
{
103+
if (containerId is not null)
104+
{
105+
try
106+
{
107+
await docker.Containers.RemoveContainerAsync(containerId,
108+
new ContainerRemoveParameters { Force = true, RemoveVolumes = true });
109+
}
110+
catch (Exception ex)
111+
{
112+
logger.LogDebug(ex, "Failed to remove script container {ContainerId}", containerId);
113+
}
114+
}
115+
}
116+
}
117+
118+
private CreateContainerParameters BuildCreateParameters(ScriptInvokeRequest request)
119+
{
120+
var scriptCommand = "";
121+
if (request.PipPackages is { Count: > 0 })
122+
{
123+
scriptCommand += $"pip install --quiet --target /tmp/pypackages {string.Join(' ', request.PipPackages)} 2>&1 && ";
124+
scriptCommand += "PYTHONPATH=/tmp/pypackages ";
125+
}
126+
scriptCommand += "python -c \"$ROCKBOT_SCRIPT\" 2>&1";
127+
128+
var env = new List<string>
129+
{
130+
$"ROCKBOT_SCRIPT={request.Script}",
131+
$"ROCKBOT_INPUT={request.InputData}"
132+
};
133+
134+
if (!string.IsNullOrEmpty(options.StagingUrl))
135+
env.Add($"ROCKBOT_STAGING_URL={options.StagingUrl}");
136+
if (!string.IsNullOrEmpty(options.StagingToken))
137+
env.Add($"ROCKBOT_STAGING_TOKEN={options.StagingToken}");
138+
139+
return new CreateContainerParameters
140+
{
141+
Image = options.Image,
142+
Cmd = ["sh", "-c", scriptCommand],
143+
User = "1000",
144+
Env = env,
145+
Labels = new Dictionary<string, string>
146+
{
147+
["app"] = "rockbot-script",
148+
["rockbot.dev/tool-call-id"] = request.ToolCallId
149+
},
150+
HostConfig = new HostConfig
151+
{
152+
NetworkMode = options.NetworkMode,
153+
ReadonlyRootfs = true,
154+
Tmpfs = new Dictionary<string, string> { ["/tmp"] = "" },
155+
NanoCPUs = options.GetNanoCpus(),
156+
Memory = options.GetMemoryBytes(),
157+
SecurityOpt = ["no-new-privileges"],
158+
AutoRemove = false,
159+
RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.No }
160+
}
161+
};
162+
}
163+
164+
private async Task<(string stdout, string stderr)> ReadLogsAsync(string containerId, CancellationToken ct)
165+
{
166+
var logStream = await docker.Containers.GetContainerLogsAsync(
167+
containerId,
168+
tty: false,
169+
new ContainerLogsParameters { ShowStdout = true, ShowStderr = true },
170+
ct);
171+
172+
return await logStream.ReadOutputToEndAsync(ct);
173+
}
174+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
namespace RockBot.Scripts.Docker;
2+
3+
/// <summary>
4+
/// Configuration for Docker-based script execution.
5+
/// </summary>
6+
public sealed class DockerScriptOptions
7+
{
8+
/// <summary>
9+
/// Container image for running Python scripts. Defaults to "python:3.12-slim".
10+
/// </summary>
11+
public string Image { get; set; } = "python:3.12-slim";
12+
13+
/// <summary>
14+
/// CPU resource limit in Kubernetes-style notation (e.g. "500m" for half a CPU).
15+
/// Converted to Docker NanoCPUs internally.
16+
/// </summary>
17+
public string CpuLimit { get; set; } = "500m";
18+
19+
/// <summary>
20+
/// Memory resource limit in Kubernetes-style notation (e.g. "256Mi").
21+
/// Converted to bytes internally.
22+
/// </summary>
23+
public string MemoryLimit { get; set; } = "256Mi";
24+
25+
/// <summary>
26+
/// Docker network mode. Defaults to "bridge" for outbound internet access
27+
/// (scripts commonly interact with internet-based services).
28+
/// </summary>
29+
public string NetworkMode { get; set; } = "bridge";
30+
31+
/// <summary>
32+
/// Default topic for publishing script results when no ReplyTo is set.
33+
/// </summary>
34+
public string DefaultResultTopic { get; set; } = "script.result";
35+
36+
/// <summary>
37+
/// Base URL of the staging blob service. When non-empty, containers receive
38+
/// a ROCKBOT_STAGING_URL environment variable so scripts can upload files via REST.
39+
/// </summary>
40+
public string StagingUrl { get; set; } = "";
41+
42+
/// <summary>
43+
/// Auth token for the staging blob service. When non-empty, containers receive
44+
/// a ROCKBOT_STAGING_TOKEN environment variable.
45+
/// </summary>
46+
public string StagingToken { get; set; } = "";
47+
48+
/// <summary>
49+
/// Parses <see cref="CpuLimit"/> to Docker NanoCPUs.
50+
/// "500m" → 500_000_000, "1" → 1_000_000_000.
51+
/// </summary>
52+
internal long GetNanoCpus()
53+
{
54+
var value = CpuLimit.Trim();
55+
if (value.EndsWith('m'))
56+
return long.Parse(value[..^1]) * 1_000_000;
57+
return (long)(double.Parse(value) * 1_000_000_000);
58+
}
59+
60+
/// <summary>
61+
/// Parses <see cref="MemoryLimit"/> to bytes.
62+
/// "256Mi" → 268_435_456, "512Mi" → 536_870_912.
63+
/// </summary>
64+
internal long GetMemoryBytes()
65+
{
66+
var value = MemoryLimit.Trim();
67+
if (value.EndsWith("Mi"))
68+
return long.Parse(value[..^2]) * 1024 * 1024;
69+
if (value.EndsWith("Gi"))
70+
return long.Parse(value[..^2]) * 1024 * 1024 * 1024;
71+
if (value.EndsWith("Ki"))
72+
return long.Parse(value[..^2]) * 1024;
73+
return long.Parse(value);
74+
}
75+
}

0 commit comments

Comments
 (0)