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.
@@ -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