Skip to content

Commit 72de319

Browse files
committed
wip - copied from DockerMCP, starting to switch to RubyLLM tooling
1 parent 05e5ff5 commit 72de319

23 files changed

+3283
-0
lines changed

lib/ruby_llm/docker/build_image.rb

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
module Docker
5+
# MCP tool for building Docker images from Dockerfile content.
6+
#
7+
# This tool provides the ability to build Docker images by providing Dockerfile
8+
# content as a string. It supports optional tagging of the resulting image for
9+
# easy identification and reuse.
10+
#
11+
# == Security Considerations
12+
#
13+
# Building Docker images can be potentially dangerous:
14+
# - Dockerfile commands execute with Docker daemon privileges
15+
# - Images can contain malicious software or backdoors
16+
# - Build process can access host resources (network, files)
17+
# - Base images may contain vulnerabilities
18+
# - Build context may expose sensitive information
19+
#
20+
# Security recommendations:
21+
# - Review Dockerfile content carefully before building
22+
# - Use trusted base images from official repositories
23+
# - Scan built images for vulnerabilities
24+
# - Limit network access during builds
25+
# - Avoid including secrets in Dockerfile instructions
26+
# - Use multi-stage builds to minimize final image size
27+
#
28+
# == Features
29+
#
30+
# - Build images from Dockerfile content strings
31+
# - Optional image tagging during build
32+
# - Comprehensive error handling
33+
# - Support for all standard Dockerfile instructions
34+
# - Returns image ID and build status
35+
#
36+
# == Example Usage
37+
#
38+
# # Simple image build
39+
# BuildImage.call(
40+
# server_context: context,
41+
# dockerfile: "FROM alpine:latest\nRUN apk add --no-cache curl"
42+
# )
43+
#
44+
# # Build with custom tag
45+
# BuildImage.call(
46+
# server_context: context,
47+
# dockerfile: dockerfile_content,
48+
# tag: "myapp:v1.0"
49+
# )
50+
#
51+
# @see Docker::Image.build
52+
# @see TagImage
53+
# @since 0.1.0
54+
class BuildImage < RubyLLM::Tool
55+
description 'Build a Docker image'
56+
57+
input_schema(
58+
properties: {
59+
dockerfile: {
60+
type: 'string',
61+
description: 'Dockerfile content as a string'
62+
},
63+
tag: {
64+
type: 'string',
65+
description: 'Tag for the built image (e.g., "myimage:latest")'
66+
}
67+
},
68+
required: ['dockerfile']
69+
)
70+
71+
# Build a Docker image from Dockerfile content.
72+
#
73+
# This method creates a Docker image by building from the provided Dockerfile
74+
# content string. The Dockerfile is processed by the Docker daemon and can
75+
# include any valid Dockerfile instructions. Optionally, the resulting image
76+
# can be tagged with a custom name for easy reference.
77+
#
78+
# @param dockerfile [String] the complete Dockerfile content as a string
79+
# @param server_context [Object] MCP server context (unused but required)
80+
# @param tag [String, nil] optional tag to apply to the built image
81+
#
82+
# @return [RubyLLM::Tool::Response] build results including image ID and tag info
83+
#
84+
# @raise [Docker::Error] for Docker daemon communication errors
85+
# @raise [StandardError] for build failures or other errors
86+
#
87+
# @example Build simple image
88+
# dockerfile = <<~DOCKERFILE
89+
# FROM alpine:latest
90+
# RUN apk add --no-cache nginx
91+
# EXPOSE 80
92+
# CMD ["nginx", "-g", "daemon off;"]
93+
# DOCKERFILE
94+
#
95+
# response = BuildImage.call(
96+
# server_context: context,
97+
# dockerfile: dockerfile,
98+
# tag: "my-nginx:latest"
99+
# )
100+
#
101+
# @see Docker::Image.build
102+
def self.call(dockerfile:, server_context:, tag: nil)
103+
# Build the image
104+
image = Docker::Image.build(dockerfile)
105+
106+
# If a tag was specified, tag the image
107+
if tag
108+
# Split tag into repo and tag parts
109+
repo, image_tag = tag.split(':', 2)
110+
image_tag ||= 'latest'
111+
image.tag('repo' => repo, 'tag' => image_tag, 'force' => true)
112+
end
113+
114+
response_text = "Image built successfully. ID: #{image.id}"
115+
response_text += ", Tag: #{tag}" if tag
116+
117+
RubyLLM::Tool::Response.new([{
118+
type: 'text',
119+
text: response_text
120+
}])
121+
rescue StandardError => e
122+
RubyLLM::Tool::Response.new([{
123+
type: 'text',
124+
text: "Error building image: #{e.message}"
125+
}])
126+
end
127+
end
128+
end
129+
end
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
module Docker
5+
# MCP tool for copying files and directories from host to Docker containers.
6+
#
7+
# This tool provides the ability to copy files or entire directory trees from
8+
# the host filesystem into running Docker containers. It uses Docker's archive
9+
# streaming API to efficiently transfer files while preserving permissions and
10+
# directory structure.
11+
#
12+
# == ⚠️ SECURITY WARNING ⚠️
13+
#
14+
# This tool can be dangerous as it allows:
15+
# - Reading arbitrary files from the host filesystem
16+
# - Writing files into container filesystems
17+
# - Potentially overwriting critical container files
18+
# - Escalating privileges if used with setuid/setgid files
19+
# - Exposing sensitive host data to containers
20+
#
21+
# Security recommendations:
22+
# - Validate source paths to prevent directory traversal
23+
# - Ensure containers run with minimal privileges
24+
# - Monitor file copy operations for sensitive paths
25+
# - Use read-only filesystems where possible
26+
# - Implement proper access controls on source files
27+
#
28+
# == Features
29+
#
30+
# - Copy individual files or entire directories
31+
# - Preserve file permissions and directory structure
32+
# - Optional ownership changes after copy
33+
# - Comprehensive error handling
34+
# - Support for both absolute and relative paths
35+
#
36+
# == Example Usage
37+
#
38+
# # Copy a configuration file
39+
# CopyToContainer.call(
40+
# server_context: context,
41+
# id: "web-server",
42+
# source_path: "/host/config/nginx.conf",
43+
# destination_path: "/etc/nginx/"
44+
# )
45+
#
46+
# # Copy directory with ownership change
47+
# CopyToContainer.call(
48+
# server_context: context,
49+
# id: "app-container",
50+
# source_path: "/host/app/src",
51+
# destination_path: "/app/",
52+
# owner: "appuser:appgroup"
53+
# )
54+
#
55+
# @see Docker::Container#archive_in_stream
56+
# @since 0.1.0
57+
class CopyToContainer < RubyLLM::Tool
58+
description 'Copy a file or directory from the local filesystem into a running Docker container. ' \
59+
'The source path is on the local machine, and the destination path is inside the container.'
60+
61+
input_schema(
62+
properties: {
63+
id: {
64+
type: 'string',
65+
description: 'Container ID or name'
66+
},
67+
source_path: {
68+
type: 'string',
69+
description: 'Path to the file or directory on the local filesystem to copy'
70+
},
71+
destination_path: {
72+
type: 'string',
73+
description: 'Path inside the container where the file/directory should be copied'
74+
},
75+
owner: {
76+
type: 'string',
77+
description: 'Owner for the copied files (optional, e.g., "1000:1000" or "username:group")'
78+
}
79+
},
80+
required: %w[id source_path destination_path]
81+
)
82+
83+
# Copy files or directories from host filesystem to a Docker container.
84+
#
85+
# This method creates a tar archive of the source path and streams it into
86+
# the specified container using Docker's archive API. The operation preserves
87+
# file permissions and directory structure. Optionally, ownership can be
88+
# changed after the copy operation completes.
89+
#
90+
# The source path must exist on the host filesystem and be readable by the
91+
# process running the MCP server. The destination path must be a valid path
92+
# within the container.
93+
#
94+
# @param id [String] container ID or name to copy files into
95+
# @param source_path [String] path to file/directory on host filesystem
96+
# @param destination_path [String] destination path inside container
97+
# @param server_context [Object] MCP server context (unused but required)
98+
# @param owner [String, nil] ownership specification (e.g., "user:group", "1000:1000")
99+
#
100+
# @return [RubyLLM::Tool::Response] success/failure message with operation details
101+
#
102+
# @raise [Docker::Error::NotFoundError] if container doesn't exist
103+
# @raise [StandardError] for file system or Docker API errors
104+
#
105+
# @example Copy configuration file
106+
# response = CopyToContainer.call(
107+
# server_context: context,
108+
# id: "nginx-container",
109+
# source_path: "/etc/nginx/sites-available/default",
110+
# destination_path: "/etc/nginx/sites-enabled/"
111+
# )
112+
#
113+
# @example Copy directory with ownership
114+
# response = CopyToContainer.call(
115+
# server_context: context,
116+
# id: "app-container",
117+
# source_path: "/local/project",
118+
# destination_path: "/app/",
119+
# owner: "www-data:www-data"
120+
# )
121+
#
122+
# @see Docker::Container#archive_in_stream
123+
# @see #add_to_tar
124+
def self.call(id:, source_path:, destination_path:, server_context:, owner: nil)
125+
container = Docker::Container.get(id)
126+
127+
# Verify source path exists
128+
unless File.exist?(source_path)
129+
return RubyLLM::Tool::Response.new([{
130+
type: 'text',
131+
text: "Source path not found: #{source_path}"
132+
}])
133+
end
134+
135+
# Create a tar archive of the source
136+
tar_io = StringIO.new
137+
tar_io.set_encoding('ASCII-8BIT')
138+
139+
Gem::Package::TarWriter.new(tar_io) do |tar|
140+
add_to_tar(tar, source_path, File.basename(source_path))
141+
end
142+
143+
tar_io.rewind
144+
145+
# Copy to container
146+
container.archive_in_stream(destination_path) do
147+
tar_io.read
148+
end
149+
150+
# Optionally change ownership
151+
if owner
152+
chown_path = File.join(destination_path, File.basename(source_path))
153+
container.exec(['chown', '-R', owner, chown_path])
154+
end
155+
156+
file_type = File.directory?(source_path) ? 'directory' : 'file'
157+
response_text = "Successfully copied #{file_type} from #{source_path} to #{id}:#{destination_path}"
158+
response_text += "\nOwnership changed to #{owner}" if owner
159+
160+
RubyLLM::Tool::Response.new([{
161+
type: 'text',
162+
text: response_text
163+
}])
164+
rescue Docker::Error::NotFoundError
165+
RubyLLM::Tool::Response.new([{
166+
type: 'text',
167+
text: "Container #{id} not found"
168+
}])
169+
rescue StandardError => e
170+
RubyLLM::Tool::Response.new([{
171+
type: 'text',
172+
text: "Error copying to container: #{e.message}"
173+
}])
174+
end
175+
176+
# Recursively add files and directories to a tar archive.
177+
#
178+
# This helper method builds a tar archive by recursively traversing
179+
# the filesystem starting from the given path. It preserves file
180+
# permissions and handles both files and directories appropriately.
181+
#
182+
# For directories, it creates directory entries in the tar and then
183+
# recursively processes all contained files and subdirectories.
184+
# For files, it reads the content and adds it to the tar with
185+
# preserved permissions.
186+
#
187+
# @param tar [Gem::Package::TarWriter] the tar writer instance
188+
# @param path [String] the filesystem path to add to the archive
189+
# @param archive_path [String] the path within the tar archive
190+
#
191+
# @return [void]
192+
#
193+
# @example Add single file
194+
# add_to_tar(tar_writer, "/host/file.txt", "file.txt")
195+
#
196+
# @example Add directory tree
197+
# add_to_tar(tar_writer, "/host/mydir", "mydir")
198+
#
199+
# @see Gem::Package::TarWriter#mkdir
200+
# @see Gem::Package::TarWriter#add_file_simple
201+
def self.add_to_tar(tar, path, archive_path)
202+
if File.directory?(path)
203+
# Add directory entry
204+
tar.mkdir(archive_path, File.stat(path).mode)
205+
206+
# Add directory contents
207+
Dir.entries(path).each do |entry|
208+
next if ['.', '..'].include?(entry)
209+
210+
full_path = File.join(path, entry)
211+
archive_entry_path = File.join(archive_path, entry)
212+
add_to_tar(tar, full_path, archive_entry_path)
213+
end
214+
else
215+
# Add file
216+
File.open(path, 'rb') do |file|
217+
tar.add_file_simple(archive_path, File.stat(path).mode, file.size) do |tar_file|
218+
IO.copy_stream(file, tar_file)
219+
end
220+
end
221+
end
222+
end
223+
end
224+
end
225+
end

0 commit comments

Comments
 (0)