kamal-dev 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +508 -0
- data/LICENSE.txt +21 -0
- data/README.md +899 -0
- data/Rakefile +10 -0
- data/exe/kamal-dev +10 -0
- data/exe/plugin-kamal-dev +283 -0
- data/lib/kamal/cli/dev.rb +1192 -0
- data/lib/kamal/dev/builder.rb +332 -0
- data/lib/kamal/dev/compose_parser.rb +255 -0
- data/lib/kamal/dev/config.rb +359 -0
- data/lib/kamal/dev/devcontainer.rb +122 -0
- data/lib/kamal/dev/devcontainer_parser.rb +204 -0
- data/lib/kamal/dev/registry.rb +149 -0
- data/lib/kamal/dev/secrets_loader.rb +93 -0
- data/lib/kamal/dev/state_manager.rb +271 -0
- data/lib/kamal/dev/templates/dev-entrypoint.sh +44 -0
- data/lib/kamal/dev/templates/dev.yml +93 -0
- data/lib/kamal/dev/version.rb +7 -0
- data/lib/kamal/dev.rb +33 -0
- data/lib/kamal/providers/base.rb +121 -0
- data/lib/kamal/providers/upcloud.rb +299 -0
- data/lib/kamal-dev.rb +5 -0
- data/sig/kamal/dev.rbs +6 -0
- data/test_installer.sh +73 -0
- metadata +141 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kamal
|
|
4
|
+
module Dev
|
|
5
|
+
# Builder for building and pushing Docker images
|
|
6
|
+
#
|
|
7
|
+
# Wraps Docker build and push operations with:
|
|
8
|
+
# - Build progress display
|
|
9
|
+
# - Tag management (timestamp, git SHA, custom)
|
|
10
|
+
# - Error handling for build failures
|
|
11
|
+
# - Registry authentication
|
|
12
|
+
#
|
|
13
|
+
# @example Build an image
|
|
14
|
+
# builder = Kamal::Dev::Builder.new(config, registry)
|
|
15
|
+
# builder.build(
|
|
16
|
+
# dockerfile: ".devcontainer/Dockerfile",
|
|
17
|
+
# context: ".",
|
|
18
|
+
# tag: "abc123"
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example Push an image
|
|
22
|
+
# builder.push("myapp-dev:abc123")
|
|
23
|
+
#
|
|
24
|
+
class Builder
|
|
25
|
+
attr_reader :config, :registry
|
|
26
|
+
|
|
27
|
+
# Initialize builder with configuration and registry
|
|
28
|
+
#
|
|
29
|
+
# @param config [Kamal::Dev::Config] Configuration object
|
|
30
|
+
# @param registry [Kamal::Dev::Registry] Registry object
|
|
31
|
+
def initialize(config, registry)
|
|
32
|
+
@config = config
|
|
33
|
+
@registry = registry
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build Docker image from Dockerfile
|
|
37
|
+
#
|
|
38
|
+
# @param dockerfile [String] Path to Dockerfile
|
|
39
|
+
# @param context [String] Build context path (default: ".")
|
|
40
|
+
# @param tag [String] Image tag (optional, auto-generated if not provided)
|
|
41
|
+
# @param image_base [String] Base image name from config (optional, uses config.service if not provided)
|
|
42
|
+
# @param build_args [Hash] Build arguments (optional)
|
|
43
|
+
# @param secrets [Hash] Build secrets (optional)
|
|
44
|
+
# @return [String] Full image reference with tag
|
|
45
|
+
# @raise [Kamal::Dev::BuildError] if build fails
|
|
46
|
+
def build(dockerfile:, context: ".", tag: nil, image_base: nil, build_args: {}, secrets: {})
|
|
47
|
+
tag ||= registry.tag_with_timestamp
|
|
48
|
+
|
|
49
|
+
# Use image_base if provided (new format), otherwise fall back to service name (old format)
|
|
50
|
+
image_ref = if image_base
|
|
51
|
+
registry.image_tag(image_base, tag)
|
|
52
|
+
else
|
|
53
|
+
registry.image_tag(config.service, tag)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# If git clone is enabled, wrap Dockerfile with entrypoint injection
|
|
57
|
+
if config.git_clone_enabled?
|
|
58
|
+
build_with_entrypoint(
|
|
59
|
+
dockerfile: dockerfile,
|
|
60
|
+
context: context,
|
|
61
|
+
image: image_ref,
|
|
62
|
+
build_args: build_args,
|
|
63
|
+
secrets: secrets
|
|
64
|
+
)
|
|
65
|
+
else
|
|
66
|
+
command = build_command(
|
|
67
|
+
dockerfile: dockerfile,
|
|
68
|
+
context: context,
|
|
69
|
+
image: image_ref,
|
|
70
|
+
build_args: build_args,
|
|
71
|
+
secrets: secrets
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
execute_with_output(command, "Building image #{image_ref}...")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
image_ref
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Push Docker image to registry
|
|
81
|
+
#
|
|
82
|
+
# @param image_ref [String] Full image reference (registry/user/image:tag)
|
|
83
|
+
# @return [Boolean] true if push succeeded
|
|
84
|
+
# @raise [Kamal::Dev::BuildError] if push fails
|
|
85
|
+
def push(image_ref)
|
|
86
|
+
command = ["docker", "push", image_ref]
|
|
87
|
+
|
|
88
|
+
execute_with_output(command, "Pushing image #{image_ref}...")
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Authenticate with Docker registry
|
|
94
|
+
#
|
|
95
|
+
# @return [Boolean] true if login succeeded
|
|
96
|
+
# @raise [Kamal::Dev::RegistryError] if login fails
|
|
97
|
+
def login
|
|
98
|
+
command = registry.login_command
|
|
99
|
+
|
|
100
|
+
# Login command uses password on command line, so we execute silently
|
|
101
|
+
result = execute_command(command)
|
|
102
|
+
|
|
103
|
+
unless result[:success]
|
|
104
|
+
raise Kamal::Dev::RegistryError,
|
|
105
|
+
"Docker login failed: #{result[:error]}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check if Docker is available
|
|
112
|
+
#
|
|
113
|
+
# @return [Boolean] true if Docker is installed and running
|
|
114
|
+
def docker_available?
|
|
115
|
+
result = execute_command(["docker", "version"])
|
|
116
|
+
result[:success]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Check if image exists locally
|
|
120
|
+
#
|
|
121
|
+
# @param image_ref [String] Full image reference
|
|
122
|
+
# @return [Boolean] true if image exists
|
|
123
|
+
def image_exists?(image_ref)
|
|
124
|
+
result = execute_command(["docker", "image", "inspect", image_ref])
|
|
125
|
+
result[:success]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Tag image with timestamp
|
|
129
|
+
#
|
|
130
|
+
# @param base_image [String] Base image reference
|
|
131
|
+
# @return [String] New image reference with timestamp tag
|
|
132
|
+
def tag_with_timestamp(base_image)
|
|
133
|
+
tag = registry.tag_with_timestamp
|
|
134
|
+
new_image = "#{base_image}:#{tag}"
|
|
135
|
+
|
|
136
|
+
execute_command(["docker", "tag", base_image, new_image])
|
|
137
|
+
|
|
138
|
+
new_image
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Tag image with git SHA
|
|
142
|
+
#
|
|
143
|
+
# @param base_image [String] Base image reference
|
|
144
|
+
# @return [String, nil] New image reference with git SHA tag or nil if not in git repo
|
|
145
|
+
def tag_with_git_sha(base_image)
|
|
146
|
+
tag = registry.tag_with_git_sha
|
|
147
|
+
return nil unless tag
|
|
148
|
+
|
|
149
|
+
new_image = "#{base_image}:#{tag}"
|
|
150
|
+
|
|
151
|
+
execute_command(["docker", "tag", base_image, new_image])
|
|
152
|
+
|
|
153
|
+
new_image
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# Build image with kamal-dev entrypoint injected
|
|
159
|
+
#
|
|
160
|
+
# Creates a temporary wrapper Dockerfile that:
|
|
161
|
+
# 1. Builds from the original Dockerfile
|
|
162
|
+
# 2. Injects the dev-entrypoint.sh script
|
|
163
|
+
# 3. Sets it as the container entrypoint
|
|
164
|
+
#
|
|
165
|
+
# @param dockerfile [String] Original Dockerfile path
|
|
166
|
+
# @param context [String] Build context
|
|
167
|
+
# @param image [String] Target image name with tag
|
|
168
|
+
# @param build_args [Hash] Build arguments
|
|
169
|
+
# @param secrets [Hash] Build secrets
|
|
170
|
+
def build_with_entrypoint(dockerfile:, context:, image:, build_args: {}, secrets: {})
|
|
171
|
+
require "tmpdir"
|
|
172
|
+
require "fileutils"
|
|
173
|
+
|
|
174
|
+
Dir.mktmpdir("kamal-dev-build") do |temp_dir|
|
|
175
|
+
# Copy entrypoint script to temp directory
|
|
176
|
+
entrypoint_template = File.expand_path("../templates/dev-entrypoint.sh", __FILE__)
|
|
177
|
+
entrypoint_dest = File.join(temp_dir, "dev-entrypoint.sh")
|
|
178
|
+
FileUtils.cp(entrypoint_template, entrypoint_dest)
|
|
179
|
+
FileUtils.chmod(0755, entrypoint_dest)
|
|
180
|
+
|
|
181
|
+
# Resolve paths to absolute
|
|
182
|
+
context_abs = File.expand_path(context)
|
|
183
|
+
original_dockerfile_path = File.expand_path(File.join(context, dockerfile))
|
|
184
|
+
|
|
185
|
+
# Create wrapper Dockerfile
|
|
186
|
+
wrapper_dockerfile = File.join(temp_dir, "Dockerfile.kamal-dev")
|
|
187
|
+
File.write(wrapper_dockerfile, generate_wrapper_dockerfile(original_dockerfile_path))
|
|
188
|
+
|
|
189
|
+
# Copy wrapper to context so docker build can access it
|
|
190
|
+
FileUtils.cp(wrapper_dockerfile, File.join(context_abs, "Dockerfile.kamal-dev"))
|
|
191
|
+
FileUtils.cp(entrypoint_dest, File.join(context_abs, "dev-entrypoint.sh"))
|
|
192
|
+
|
|
193
|
+
begin
|
|
194
|
+
# Build using wrapper Dockerfile
|
|
195
|
+
command = build_command(
|
|
196
|
+
dockerfile: "Dockerfile.kamal-dev",
|
|
197
|
+
context: context,
|
|
198
|
+
image: image,
|
|
199
|
+
build_args: build_args,
|
|
200
|
+
secrets: secrets
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
execute_with_output(command, "Building image with kamal-dev entrypoint #{image}...")
|
|
204
|
+
ensure
|
|
205
|
+
# Cleanup temporary files from context
|
|
206
|
+
FileUtils.rm_f(File.join(context_abs, "Dockerfile.kamal-dev"))
|
|
207
|
+
FileUtils.rm_f(File.join(context_abs, "dev-entrypoint.sh"))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Generate wrapper Dockerfile content
|
|
213
|
+
#
|
|
214
|
+
# @param original_dockerfile [String] Path to original Dockerfile
|
|
215
|
+
# @return [String] Wrapper Dockerfile content
|
|
216
|
+
def generate_wrapper_dockerfile(original_dockerfile)
|
|
217
|
+
# Read original Dockerfile to extract final image
|
|
218
|
+
# Use multi-stage build: stage 1 = original, stage 2 = add entrypoint
|
|
219
|
+
<<~DOCKERFILE
|
|
220
|
+
# Stage 1: Build original image
|
|
221
|
+
FROM scratch AS original-dockerfile
|
|
222
|
+
# This is a placeholder - we'll build from the original file
|
|
223
|
+
|
|
224
|
+
# We can't easily include another Dockerfile, so we'll use a different approach
|
|
225
|
+
# Build the original image first, then extend it
|
|
226
|
+
|
|
227
|
+
# Actually, simpler approach: read the original and inline it
|
|
228
|
+
DOCKERFILE
|
|
229
|
+
|
|
230
|
+
# Better approach: Just extend the original Dockerfile directly
|
|
231
|
+
original_content = File.read(original_dockerfile)
|
|
232
|
+
|
|
233
|
+
<<~DOCKERFILE
|
|
234
|
+
#{original_content}
|
|
235
|
+
|
|
236
|
+
# Kamal Dev: Inject entrypoint for git clone functionality
|
|
237
|
+
# Create /workspaces directory with proper ownership for non-root user
|
|
238
|
+
USER root
|
|
239
|
+
RUN mkdir -p /workspaces && chown -R vscode:vscode /workspaces
|
|
240
|
+
USER vscode
|
|
241
|
+
|
|
242
|
+
# Copy entrypoint script with execute permissions
|
|
243
|
+
COPY --chmod=755 dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
|
|
244
|
+
ENTRYPOINT ["/usr/local/bin/dev-entrypoint.sh"]
|
|
245
|
+
DOCKERFILE
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Build docker build command
|
|
249
|
+
#
|
|
250
|
+
# @param dockerfile [String] Dockerfile path
|
|
251
|
+
# @param context [String] Build context
|
|
252
|
+
# @param image [String] Image reference with tag
|
|
253
|
+
# @param build_args [Hash] Build arguments
|
|
254
|
+
# @param secrets [Hash] Build secrets
|
|
255
|
+
# @return [Array<String>] Docker build command
|
|
256
|
+
def build_command(dockerfile:, context:, image:, build_args: {}, secrets: {})
|
|
257
|
+
cmd = ["docker", "build"]
|
|
258
|
+
|
|
259
|
+
# Add platform flag for cross-platform compatibility
|
|
260
|
+
# Cloud VMs are typically linux/amd64, even when building on arm64 (Mac)
|
|
261
|
+
cmd += ["--platform", "linux/amd64"]
|
|
262
|
+
|
|
263
|
+
# Add dockerfile flag
|
|
264
|
+
cmd += ["-f", dockerfile] if dockerfile != "Dockerfile"
|
|
265
|
+
|
|
266
|
+
# Add tag
|
|
267
|
+
cmd += ["-t", image]
|
|
268
|
+
|
|
269
|
+
# Add build args
|
|
270
|
+
build_args.each do |key, value|
|
|
271
|
+
cmd += ["--build-arg", "#{key}=#{value}"]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Add build secrets
|
|
275
|
+
secrets.each do |key, value|
|
|
276
|
+
cmd += ["--secret", "id=#{key},env=#{value}"]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Add context (must be last)
|
|
280
|
+
cmd << context
|
|
281
|
+
|
|
282
|
+
cmd
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Execute command and capture output
|
|
286
|
+
#
|
|
287
|
+
# @param command [Array<String>] Command to execute
|
|
288
|
+
# @param message [String] Progress message to display
|
|
289
|
+
# @return [Hash] Result with :success, :output, :error
|
|
290
|
+
# @raise [Kamal::Dev::BuildError] if command fails
|
|
291
|
+
def execute_with_output(command, message)
|
|
292
|
+
puts message if message
|
|
293
|
+
|
|
294
|
+
result = execute_command(command, show_output: true)
|
|
295
|
+
|
|
296
|
+
unless result[:success]
|
|
297
|
+
raise Kamal::Dev::BuildError,
|
|
298
|
+
"Command failed: #{command.join(" ")}\n#{result[:error]}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
result
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Execute shell command
|
|
305
|
+
#
|
|
306
|
+
# @param command [Array<String>] Command to execute
|
|
307
|
+
# @param show_output [Boolean] Whether to show command output
|
|
308
|
+
# @return [Hash] Result with :success, :output, :error
|
|
309
|
+
def execute_command(command, show_output: false)
|
|
310
|
+
require "open3"
|
|
311
|
+
|
|
312
|
+
output, error, status = Open3.capture3(*command)
|
|
313
|
+
|
|
314
|
+
if show_output && !output.empty?
|
|
315
|
+
puts output
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
{
|
|
319
|
+
success: status.success?,
|
|
320
|
+
output: output,
|
|
321
|
+
error: error
|
|
322
|
+
}
|
|
323
|
+
rescue => e
|
|
324
|
+
{
|
|
325
|
+
success: false,
|
|
326
|
+
output: "",
|
|
327
|
+
error: e.message
|
|
328
|
+
}
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Dev
|
|
7
|
+
# Parser for Docker Compose files
|
|
8
|
+
#
|
|
9
|
+
# Parses compose.yaml files to extract service definitions, build contexts,
|
|
10
|
+
# and Dockerfiles. Identifies main application service vs dependent services
|
|
11
|
+
# (databases, caches, etc.) for deployment orchestration.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# parser = Kamal::Dev::ComposeParser.new(".devcontainer/compose.yaml")
|
|
15
|
+
# parser.main_service
|
|
16
|
+
# # => "app"
|
|
17
|
+
#
|
|
18
|
+
# @example Get build context
|
|
19
|
+
# parser.service_build_context("app")
|
|
20
|
+
# # => "."
|
|
21
|
+
#
|
|
22
|
+
# @example Check if service has build section
|
|
23
|
+
# parser.has_build_section?("postgres")
|
|
24
|
+
# # => false
|
|
25
|
+
#
|
|
26
|
+
class ComposeParser
|
|
27
|
+
attr_reader :compose_file_path, :compose_data
|
|
28
|
+
|
|
29
|
+
# Initialize parser with compose file path
|
|
30
|
+
#
|
|
31
|
+
# @param compose_file_path [String] Path to compose.yaml file
|
|
32
|
+
# @raise [Kamal::Dev::ConfigurationError] if file not found or invalid YAML
|
|
33
|
+
def initialize(compose_file_path)
|
|
34
|
+
@compose_file_path = compose_file_path
|
|
35
|
+
@compose_data = load_and_parse
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get all services from compose file
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] Service definitions keyed by service name
|
|
41
|
+
def services
|
|
42
|
+
compose_data.fetch("services", {})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Identify the main application service
|
|
46
|
+
#
|
|
47
|
+
# Uses heuristic: first service with a build: section,
|
|
48
|
+
# or first service if none have build sections
|
|
49
|
+
#
|
|
50
|
+
# @return [String, nil] Main service name
|
|
51
|
+
def main_service
|
|
52
|
+
# Find first service with build section
|
|
53
|
+
service_with_build = services.find { |_, config| config.key?("build") }
|
|
54
|
+
return service_with_build[0] if service_with_build
|
|
55
|
+
|
|
56
|
+
# Fallback to first service
|
|
57
|
+
services.keys.first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get build context for a service
|
|
61
|
+
#
|
|
62
|
+
# Resolves context path relative to the compose file's directory,
|
|
63
|
+
# since Docker Compose interprets paths relative to the compose file location.
|
|
64
|
+
#
|
|
65
|
+
# @param service_name [String] Service name
|
|
66
|
+
# @return [String] Build context path resolved relative to compose file (default: ".")
|
|
67
|
+
def service_build_context(service_name)
|
|
68
|
+
service = services[service_name]
|
|
69
|
+
return "." unless service
|
|
70
|
+
|
|
71
|
+
build_config = service["build"]
|
|
72
|
+
return "." unless build_config
|
|
73
|
+
|
|
74
|
+
# Get context from build config
|
|
75
|
+
context = if build_config.is_a?(String)
|
|
76
|
+
# Handle string build path (shorthand) - this is the context
|
|
77
|
+
build_config
|
|
78
|
+
else
|
|
79
|
+
# Handle object build config
|
|
80
|
+
build_config["context"] || "."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Resolve context relative to compose file's directory
|
|
84
|
+
# Docker Compose does this automatically, but we're extracting values
|
|
85
|
+
compose_dir = File.dirname(compose_file_path)
|
|
86
|
+
File.expand_path(context, compose_dir)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get Dockerfile path for a service
|
|
90
|
+
#
|
|
91
|
+
# Returns path relative to the build context (as Docker expects),
|
|
92
|
+
# NOT resolved to absolute path.
|
|
93
|
+
#
|
|
94
|
+
# @param service_name [String] Service name
|
|
95
|
+
# @return [String] Dockerfile path relative to build context (default: "Dockerfile")
|
|
96
|
+
def service_dockerfile(service_name)
|
|
97
|
+
service = services[service_name]
|
|
98
|
+
return "Dockerfile" unless service
|
|
99
|
+
|
|
100
|
+
build_config = service["build"]
|
|
101
|
+
return "Dockerfile" unless build_config
|
|
102
|
+
|
|
103
|
+
# Handle object build config
|
|
104
|
+
return "Dockerfile" if build_config.is_a?(String)
|
|
105
|
+
|
|
106
|
+
# Return dockerfile path as-is (relative to build context)
|
|
107
|
+
build_config["dockerfile"] || "Dockerfile"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check if service has a build section
|
|
111
|
+
#
|
|
112
|
+
# @param service_name [String] Service name
|
|
113
|
+
# @return [Boolean] true if service uses build:, false if image:
|
|
114
|
+
def has_build_section?(service_name)
|
|
115
|
+
service = services[service_name]
|
|
116
|
+
return false unless service
|
|
117
|
+
|
|
118
|
+
service.key?("build")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get dependent services (services without build sections)
|
|
122
|
+
#
|
|
123
|
+
# These are typically databases, caches, message queues, etc.
|
|
124
|
+
# that use pre-built images from registries
|
|
125
|
+
#
|
|
126
|
+
# @return [Array<String>] Service names without build sections
|
|
127
|
+
def dependent_services
|
|
128
|
+
services.select { |_, config| !config.key?("build") }.keys
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Transform compose file for deployment
|
|
132
|
+
#
|
|
133
|
+
# Replaces build: sections with image: references pointing to
|
|
134
|
+
# the pushed registry image. Removes local bind mounts (DevPod-style).
|
|
135
|
+
# Optionally injects git clone functionality for remote deployments.
|
|
136
|
+
# Preserves named volumes and other service properties.
|
|
137
|
+
#
|
|
138
|
+
# @param image_ref [String] Full image reference (e.g., "ghcr.io/user/app:tag")
|
|
139
|
+
# @param config [Kamal::Dev::Config] Optional config for git clone setup
|
|
140
|
+
# @return [String] Transformed YAML content
|
|
141
|
+
# @raise [Kamal::Dev::ConfigurationError] if transformation fails
|
|
142
|
+
def transform_for_deployment(image_ref, config: nil)
|
|
143
|
+
transformed = deep_copy(compose_data)
|
|
144
|
+
main = main_service
|
|
145
|
+
|
|
146
|
+
if main && transformed["services"][main]
|
|
147
|
+
# Remove build section
|
|
148
|
+
transformed["services"][main].delete("build")
|
|
149
|
+
|
|
150
|
+
# Add image reference
|
|
151
|
+
transformed["services"][main]["image"] = image_ref
|
|
152
|
+
|
|
153
|
+
# Remove local bind mounts (DevPod-style: code will be cloned, not mounted)
|
|
154
|
+
# Keep named volumes (databases, caches, etc.)
|
|
155
|
+
if transformed["services"][main]["volumes"]
|
|
156
|
+
transformed["services"][main]["volumes"] = transformed["services"][main]["volumes"].reject do |volume|
|
|
157
|
+
# Reject if it's a bind mount (contains ":" and first part is a path)
|
|
158
|
+
if volume.is_a?(String) && volume.include?(":")
|
|
159
|
+
source, _target = volume.split(":", 2)
|
|
160
|
+
# Named volumes don't start with . or / or ~
|
|
161
|
+
source.start_with?(".", "/", "~")
|
|
162
|
+
else
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Remove volumes array if empty
|
|
168
|
+
transformed["services"][main].delete("volumes") if transformed["services"][main]["volumes"].empty?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Inject git clone environment variables if configured
|
|
172
|
+
# The actual cloning is handled by the entrypoint script in the image
|
|
173
|
+
if config&.git_clone_enabled?
|
|
174
|
+
inject_git_env_vars!(transformed["services"][main], config)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Convert back to YAML
|
|
179
|
+
YAML.dump(transformed)
|
|
180
|
+
rescue => e
|
|
181
|
+
raise Kamal::Dev::ConfigurationError, "Failed to transform compose file: #{e.message}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
# Inject git clone environment variables into service configuration
|
|
187
|
+
#
|
|
188
|
+
# The entrypoint script in the Docker image will use these variables
|
|
189
|
+
# to clone the repository. Local devcontainers won't have these vars set,
|
|
190
|
+
# so they'll use mounted code instead.
|
|
191
|
+
#
|
|
192
|
+
# @param service_config [Hash] Service configuration to modify
|
|
193
|
+
# @param config [Kamal::Dev::Config] Configuration with git settings
|
|
194
|
+
def inject_git_env_vars!(service_config, config)
|
|
195
|
+
# Initialize environment hash if not present
|
|
196
|
+
service_config["environment"] ||= {}
|
|
197
|
+
|
|
198
|
+
# Inject git clone environment variables
|
|
199
|
+
# These are used by /usr/local/bin/dev-entrypoint.sh in the image
|
|
200
|
+
service_config["environment"]["KAMAL_DEV_GIT_REPO"] = config.git_repository
|
|
201
|
+
service_config["environment"]["KAMAL_DEV_GIT_BRANCH"] = config.git_branch
|
|
202
|
+
service_config["environment"]["KAMAL_DEV_WORKSPACE_FOLDER"] = config.git_workspace_folder
|
|
203
|
+
|
|
204
|
+
# Inject authentication token if configured (for private repositories)
|
|
205
|
+
if config.git_token
|
|
206
|
+
service_config["environment"]["KAMAL_DEV_GIT_TOKEN"] = config.git_token
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Load and parse compose YAML file
|
|
211
|
+
#
|
|
212
|
+
# @return [Hash] Parsed compose data
|
|
213
|
+
# @raise [Kamal::Dev::ConfigurationError] if file not found or invalid
|
|
214
|
+
def load_and_parse
|
|
215
|
+
unless File.exist?(compose_file_path)
|
|
216
|
+
raise Kamal::Dev::ConfigurationError, "Compose file not found: #{compose_file_path}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
content = File.read(compose_file_path)
|
|
220
|
+
data = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
221
|
+
|
|
222
|
+
validate_compose_structure!(data)
|
|
223
|
+
data
|
|
224
|
+
rescue Psych::SyntaxError => e
|
|
225
|
+
raise Kamal::Dev::ConfigurationError, "Invalid YAML in compose file: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Validate compose file structure
|
|
229
|
+
#
|
|
230
|
+
# @param data [Hash] Parsed compose data
|
|
231
|
+
# @raise [Kamal::Dev::ConfigurationError] if structure invalid
|
|
232
|
+
def validate_compose_structure!(data)
|
|
233
|
+
unless data.is_a?(Hash)
|
|
234
|
+
raise Kamal::Dev::ConfigurationError, "Compose file must be a YAML object"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
unless data.key?("services")
|
|
238
|
+
raise Kamal::Dev::ConfigurationError, "Compose file must have 'services' section"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if data["services"].empty?
|
|
242
|
+
raise Kamal::Dev::ConfigurationError, "Compose file must define at least one service"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Deep copy hash to avoid modifying original
|
|
247
|
+
#
|
|
248
|
+
# @param obj [Object] Object to copy
|
|
249
|
+
# @return [Object] Deep copy
|
|
250
|
+
def deep_copy(obj)
|
|
251
|
+
Marshal.load(Marshal.dump(obj))
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|