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,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "active_support/core_ext/hash"
5
+ require_relative "devcontainer_parser"
6
+ require_relative "devcontainer"
7
+ require_relative "secrets_loader"
8
+
9
+ module Kamal
10
+ module Dev
11
+ class Config
12
+ attr_reader :raw_config
13
+
14
+ def initialize(config, validate: false)
15
+ @raw_config = case config
16
+ when String
17
+ load_from_file(config)
18
+ when Hash
19
+ config.deep_symbolize_keys
20
+ else
21
+ raise Kamal::Dev::ConfigurationError, "Config must be a file path (String) or Hash"
22
+ end
23
+
24
+ validate! if validate
25
+ end
26
+
27
+ def service
28
+ raw_config[:service]
29
+ end
30
+
31
+ def image
32
+ raw_config[:image]
33
+ end
34
+
35
+ # Build configuration for building images from source
36
+ #
37
+ # @return [Hash] Build configuration (devcontainer, dockerfile, context)
38
+ def build
39
+ raw_config[:build]&.deep_stringify_keys || {}
40
+ end
41
+
42
+ # Check if build configuration is present
43
+ #
44
+ # @return [Boolean] true if build section exists
45
+ def build?
46
+ !build.empty?
47
+ end
48
+
49
+ # Get build source type
50
+ #
51
+ # @return [Symbol, nil] :devcontainer, :dockerfile, or nil
52
+ def build_source_type
53
+ return nil unless build?
54
+
55
+ if build["devcontainer"]
56
+ :devcontainer
57
+ elsif build["dockerfile"]
58
+ :dockerfile
59
+ end
60
+ end
61
+
62
+ # Get build source path (devcontainer.json or Dockerfile)
63
+ #
64
+ # @return [String, nil] Path to build source
65
+ def build_source_path
66
+ case build_source_type
67
+ when :devcontainer
68
+ build["devcontainer"]
69
+ when :dockerfile
70
+ build["dockerfile"]
71
+ end
72
+ end
73
+
74
+ # Get build context path
75
+ #
76
+ # @return [String] Build context (defaults to ".")
77
+ def build_context
78
+ build["context"] || "."
79
+ end
80
+
81
+ def provider
82
+ raw_config[:provider]&.deep_stringify_keys || {}
83
+ end
84
+
85
+ def defaults
86
+ raw_config[:defaults]&.deep_stringify_keys || {}
87
+ end
88
+
89
+ def vms
90
+ raw_config[:vms]&.deep_stringify_keys || {}
91
+ end
92
+
93
+ def vm_count
94
+ vms["count"] || 1
95
+ end
96
+
97
+ def naming_pattern
98
+ raw_config.dig(:naming, :pattern) || "{service}-{index}"
99
+ end
100
+
101
+ def secrets
102
+ raw_config[:secrets] || []
103
+ end
104
+
105
+ def secrets_file
106
+ raw_config[:secrets_file] || ".kamal/secrets"
107
+ end
108
+
109
+ def ssh
110
+ raw_config[:ssh]&.deep_stringify_keys || {}
111
+ end
112
+
113
+ def ssh_key_path
114
+ ssh["key_path"] || "~/.ssh/id_rsa.pub"
115
+ end
116
+
117
+ # Git configuration for remote code cloning
118
+ #
119
+ # @return [Hash] Git configuration (repository, branch, workspace_folder)
120
+ def git
121
+ raw_config[:git]&.deep_stringify_keys || {}
122
+ end
123
+
124
+ # Git repository URL to clone on remote deployment
125
+ #
126
+ # @return [String, nil] Git repository URL
127
+ def git_repository
128
+ git["repository"]
129
+ end
130
+
131
+ # Git branch to checkout (defaults to main)
132
+ #
133
+ # @return [String] Git branch name
134
+ def git_branch
135
+ git["branch"] || "main"
136
+ end
137
+
138
+ # Workspace folder where code should be cloned
139
+ # Typically matches the workspaceFolder in devcontainer.json
140
+ #
141
+ # @return [String] Workspace folder path
142
+ def git_workspace_folder
143
+ git["workspace_folder"] || "/workspaces/#{service}"
144
+ end
145
+
146
+ # Git authentication token (environment variable name)
147
+ # Used for HTTPS cloning of private repositories
148
+ #
149
+ # @return [String, nil] Environment variable name containing git token (e.g., GitHub PAT)
150
+ def git_token_env
151
+ git["token"]
152
+ end
153
+
154
+ # Git authentication token value loaded from environment
155
+ #
156
+ # @return [String, nil] Git token (GitHub PAT, GitLab token, etc.) from ENV
157
+ def git_token
158
+ return nil unless git_token_env
159
+ ENV[git_token_env]
160
+ end
161
+
162
+ # Check if git clone is configured for remote deployments
163
+ #
164
+ # @return [Boolean] true if git repository is configured
165
+ def git_clone_enabled?
166
+ !git_repository.nil? && !git_repository.empty?
167
+ end
168
+
169
+ # Registry configuration for image building and pushing
170
+ #
171
+ # @return [Hash] Registry configuration (server, username_env, password_env)
172
+ def registry
173
+ raw_config[:registry]&.deep_stringify_keys || {}
174
+ end
175
+
176
+ # Registry server URL (defaults to ghcr.io)
177
+ #
178
+ # @return [String] Registry server URL
179
+ def registry_server
180
+ registry["server"] || "ghcr.io"
181
+ end
182
+
183
+ # Registry username loaded from environment variable
184
+ #
185
+ # @return [String, nil] Registry username from ENV
186
+ def registry_username
187
+ return nil unless registry["username"]
188
+
189
+ # Handle both string and array formats (YAML parsing inconsistency)
190
+ env_var = registry["username"]
191
+ env_var = env_var.first if env_var.is_a?(Array)
192
+ ENV[env_var]
193
+ end
194
+
195
+ # Registry password/token loaded from environment variable
196
+ #
197
+ # @return [String, nil] Registry password from ENV
198
+ def registry_password
199
+ return nil unless registry["password"]
200
+
201
+ # Handle both string and array formats (YAML parsing inconsistency)
202
+ env_var = registry["password"]
203
+ env_var = env_var.first if env_var.is_a?(Array)
204
+ ENV[env_var]
205
+ end
206
+
207
+ # Check if registry credentials are configured
208
+ #
209
+ # @return [Boolean] true if both username and password ENV vars are set
210
+ def registry_configured?
211
+ !!(registry["username"] && registry["password"])
212
+ end
213
+
214
+ def container_name(index)
215
+ pattern = naming_pattern
216
+
217
+ # Handle zero-padded indexes like {index:03}
218
+ pattern = pattern.gsub(/\{index:(\d+)\}/) do
219
+ format("%0#{$1}d", index)
220
+ end
221
+
222
+ # Replace standard placeholders
223
+ name = pattern.gsub("{service}", service.to_s)
224
+ .gsub("{index}", index.to_s)
225
+
226
+ validate_docker_name!(name)
227
+ name
228
+ end
229
+
230
+ # Load and parse devcontainer configuration
231
+ #
232
+ # Handles both:
233
+ # - Direct image reference: image: "ruby:3.2"
234
+ # - Devcontainer.json path: image: ".devcontainer/devcontainer.json"
235
+ #
236
+ # @return [Devcontainer] Parsed devcontainer configuration
237
+ def devcontainer
238
+ @devcontainer ||= load_devcontainer
239
+ end
240
+
241
+ # Check if using devcontainer.json for configuration
242
+ #
243
+ # Supports both:
244
+ # - New format: build: { devcontainer: ".devcontainer/devcontainer.json" }
245
+ # - Old format: image: ".devcontainer/devcontainer.json" (backward compatibility)
246
+ #
247
+ # @return [Boolean] true if using devcontainer.json
248
+ def devcontainer_json?
249
+ # New format: build.devcontainer
250
+ return true if build_source_type == :devcontainer
251
+
252
+ # Old format: image points to .json file (backward compatibility)
253
+ image.to_s.end_with?(".json") || image.to_s.include?("devcontainer")
254
+ end
255
+
256
+ def validate!
257
+ errors = []
258
+
259
+ errors << "Configuration must include 'service' (service name is required)" if service.nil? || service.empty?
260
+ errors << "Configuration must include 'image' (image reference is required)" if image.nil? || image.empty?
261
+
262
+ if provider.empty?
263
+ errors << "Configuration must include 'provider' (provider configuration is required)"
264
+ elsif provider["type"].nil? || provider["type"].empty?
265
+ errors << "Configuration must include 'provider.type' (provider type is required)"
266
+ end
267
+
268
+ # Validate service name against Docker naming rules
269
+ unless service.nil? || service.empty?
270
+ unless docker_name_valid?(service)
271
+ errors << "Service name '#{service}' is invalid. Docker names must start with a letter or number and contain only [a-zA-Z0-9_.-]"
272
+ end
273
+ end
274
+
275
+ raise Kamal::Dev::ConfigurationError, errors.join("\n") unless errors.empty?
276
+
277
+ self
278
+ end
279
+
280
+ private
281
+
282
+ def load_from_file(path)
283
+ unless File.exist?(path)
284
+ raise Kamal::Dev::ConfigurationError, "Configuration file not found: #{path}"
285
+ end
286
+
287
+ YAML.safe_load_file(path, permitted_classes: [Symbol], symbolize_names: true)
288
+ rescue Psych::SyntaxError => e
289
+ raise Kamal::Dev::ConfigurationError, "Invalid YAML in #{path}: #{e.message}"
290
+ end
291
+
292
+ # Load devcontainer configuration
293
+ #
294
+ # Supports:
295
+ # - New format: build.devcontainer
296
+ # - Old format: image pointing to .json (backward compatibility)
297
+ # - Direct image reference
298
+ #
299
+ # @return [Devcontainer] Devcontainer instance
300
+ def load_devcontainer
301
+ config_hash = if devcontainer_json?
302
+ # Parse devcontainer.json file
303
+ # New format: build.devcontainer, Old format: image
304
+ devcontainer_path = build_source_path || image
305
+ parser = DevcontainerParser.new(devcontainer_path)
306
+ parser.parse
307
+ else
308
+ # Direct image reference - create minimal config
309
+ {
310
+ image: image,
311
+ ports: [],
312
+ mounts: [],
313
+ env: {},
314
+ options: [],
315
+ user: nil,
316
+ workspace: nil
317
+ }
318
+ end
319
+
320
+ # Load and inject secrets if configured
321
+ if !secrets.empty? && File.exist?(secrets_file)
322
+ loader = Kamal::Dev::SecretsLoader.new(secrets_file)
323
+ loaded_secrets = loader.load_secrets_for(secrets)
324
+ config_hash[:secrets] = loaded_secrets
325
+ else
326
+ config_hash[:secrets] = {}
327
+ end
328
+
329
+ Devcontainer.new(config_hash)
330
+ end
331
+
332
+ # Validate Docker name against naming rules
333
+ #
334
+ # Docker container names must:
335
+ # - Start with a letter or number
336
+ # - Contain only: letters, numbers, underscores, periods, hyphens
337
+ #
338
+ # @param name [String] Container name to validate
339
+ # @return [Boolean] true if valid, false otherwise
340
+ def docker_name_valid?(name)
341
+ return false if name.nil? || name.empty?
342
+
343
+ # Must start with alphanumeric and contain only [a-zA-Z0-9_.-]
344
+ name.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/)
345
+ end
346
+
347
+ # Validate and raise error if Docker name is invalid
348
+ #
349
+ # @param name [String] Container name to validate
350
+ # @raise [Kamal::Dev::ConfigurationError] if name is invalid
351
+ def validate_docker_name!(name)
352
+ return if docker_name_valid?(name)
353
+
354
+ raise Kamal::Dev::ConfigurationError,
355
+ "Container name '#{name}' is invalid. Docker names must start with a letter or number and contain only [a-zA-Z0-9_.-]"
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Dev
5
+ # Represents a single devcontainer instance with its parsed configuration
6
+ #
7
+ # Provides methods to access Docker configuration and generate Docker run commands.
8
+ #
9
+ # @example Creating a devcontainer
10
+ # config = {
11
+ # image: "ruby:3.2",
12
+ # ports: [3000, 5432],
13
+ # mounts: [{source: "gem-cache", target: "/usr/local/bundle", type: "volume"}],
14
+ # env: {"RAILS_ENV" => "development"},
15
+ # options: ["--cpus=2", "--memory=4g"],
16
+ # user: "vscode",
17
+ # workspace: "/workspace"
18
+ # }
19
+ # devcontainer = Devcontainer.new(config)
20
+ # command = devcontainer.docker_run_command(name: "myapp-dev-1")
21
+ class Devcontainer
22
+ attr_reader :image, :ports, :mounts, :env, :options, :user, :workspace, :secrets
23
+
24
+ # Initialize devcontainer with parsed configuration
25
+ #
26
+ # @param config [Hash] Parsed configuration hash with keys:
27
+ # - :image [String] Docker image name
28
+ # - :ports [Array<Integer>] Port mappings
29
+ # - :mounts [Array<Hash>] Volume/bind mounts
30
+ # - :env [Hash] Environment variables
31
+ # - :options [Array<String>] Docker run options
32
+ # - :user [String, nil] Remote user
33
+ # - :workspace [String, nil] Workspace folder path
34
+ # - :secrets [Hash, nil] Base64-encoded secrets (optional)
35
+ def initialize(config)
36
+ @image = config[:image]
37
+ @ports = config[:ports] || []
38
+ @mounts = config[:mounts] || []
39
+ @env = config[:env] || {}
40
+ @options = config[:options] || []
41
+ @user = config[:user]
42
+ @workspace = config[:workspace]
43
+ @secrets = config[:secrets] || {}
44
+ end
45
+
46
+ # Generate Docker run flags from configuration
47
+ #
48
+ # @return [Array<String>] Array of Docker flags and their values
49
+ def docker_run_flags
50
+ flags = []
51
+
52
+ # Port mappings (-p HOST:CONTAINER)
53
+ @ports.each do |port|
54
+ flags << "-p"
55
+ flags << "#{port}:#{port}"
56
+ end
57
+
58
+ # Volume mounts (-v SOURCE:TARGET)
59
+ @mounts.each do |mount|
60
+ flags << "-v"
61
+ flags << "#{mount[:source]}:#{mount[:target]}"
62
+ end
63
+
64
+ # Environment variables (-e KEY=VALUE)
65
+ @env.each do |key, value|
66
+ flags << "-e"
67
+ flags << "#{key}=#{value}"
68
+ end
69
+
70
+ # Secrets (Base64-encoded) (-e KEY_B64=encoded_value)
71
+ @secrets.each do |key, encoded_value|
72
+ flags << "-e"
73
+ flags << "#{key}_B64=#{encoded_value}"
74
+ end
75
+
76
+ # Docker run options (--cpus=2, --memory=4g, etc.)
77
+ flags.concat(@options)
78
+
79
+ # Remote user (--user USER)
80
+ if @user
81
+ flags << "--user"
82
+ flags << @user
83
+ end
84
+
85
+ # Workspace folder (-w /workspace)
86
+ if @workspace
87
+ flags << "-w"
88
+ flags << @workspace
89
+ end
90
+
91
+ flags
92
+ end
93
+
94
+ # Generate full Docker run command array
95
+ #
96
+ # @param name [String] Container name
97
+ # @return [Array<String>] Full docker run command with all flags
98
+ #
99
+ # @example
100
+ # devcontainer.docker_run_command(name: "myapp-dev-1")
101
+ # #=> ["docker", "run", "-d", "--name", "myapp-dev-1", "-p", "3000:3000", ..., "ruby:3.2"]
102
+ def docker_run_command(name:)
103
+ command = ["docker", "run"]
104
+
105
+ # Run in detached mode
106
+ command << "-d"
107
+
108
+ # Container name
109
+ command << "--name"
110
+ command << name
111
+
112
+ # Add all flags
113
+ command.concat(docker_run_flags)
114
+
115
+ # Image must be last
116
+ command << @image
117
+
118
+ command
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kamal
6
+ module Dev
7
+ # Parses VS Code devcontainer.json specifications into Docker configuration
8
+ #
9
+ # Handles JSON with comments (// and /* */), extracts container properties,
10
+ # and transforms them into a standardized configuration hash for Docker deployment.
11
+ #
12
+ # @example Basic usage
13
+ # parser = DevcontainerParser.new(".devcontainer/devcontainer.json")
14
+ # config = parser.parse
15
+ # #=> {image: "ruby:3.2", ports: [3000], workspace: "/workspace", ...}
16
+ class DevcontainerParser
17
+ # Custom error for validation failures
18
+ class ValidationError < StandardError; end
19
+
20
+ attr_reader :file_path
21
+
22
+ # Initialize parser with devcontainer.json file path
23
+ #
24
+ # @param file_path [String] Path to devcontainer.json file
25
+ def initialize(file_path)
26
+ @file_path = file_path
27
+ end
28
+
29
+ # Parse devcontainer.json and return standardized config hash
30
+ #
31
+ # @return [Hash] Configuration hash with keys:
32
+ # - :image [String] Docker image name
33
+ # - :ports [Array<Integer>] Port mappings
34
+ # - :mounts [Array<Hash>] Volume/bind mounts
35
+ # - :env [Hash] Environment variables
36
+ # - :options [Array<String>] Docker run options
37
+ # - :user [String, nil] Remote user
38
+ # - :workspace [String, nil] Workspace folder path
39
+ #
40
+ # @raise [Errno::ENOENT] if file doesn't exist
41
+ # @raise [JSON::ParserError] if JSON is malformed
42
+ # @raise [ValidationError] if required properties are missing
43
+ def parse
44
+ content = File.read(@file_path)
45
+ clean_content = strip_comments(content)
46
+ devcontainer_json = JSON.parse(clean_content)
47
+
48
+ validate_required_properties!(devcontainer_json)
49
+
50
+ {
51
+ image: extract_image(devcontainer_json),
52
+ ports: extract_ports(devcontainer_json),
53
+ mounts: extract_mounts(devcontainer_json),
54
+ env: extract_env(devcontainer_json),
55
+ options: extract_options(devcontainer_json),
56
+ user: extract_user(devcontainer_json),
57
+ workspace: extract_workspace(devcontainer_json)
58
+ }
59
+ end
60
+
61
+ # Check if devcontainer uses Docker Compose
62
+ #
63
+ # @return [Boolean] true if dockerComposeFile property is present
64
+ def uses_compose?
65
+ content = File.read(@file_path)
66
+ clean_content = strip_comments(content)
67
+ devcontainer_json = JSON.parse(clean_content)
68
+
69
+ devcontainer_json.key?("dockerComposeFile")
70
+ rescue
71
+ false
72
+ end
73
+
74
+ # Get path to compose file if present
75
+ #
76
+ # Returns path relative to .devcontainer/ directory
77
+ #
78
+ # @return [String, nil] Path to compose file or nil if not using compose
79
+ def compose_file_path
80
+ return nil unless uses_compose?
81
+
82
+ content = File.read(@file_path)
83
+ clean_content = strip_comments(content)
84
+ devcontainer_json = JSON.parse(clean_content)
85
+
86
+ compose_file = devcontainer_json["dockerComposeFile"]
87
+ return nil unless compose_file
88
+
89
+ # Handle array of compose files (use first one)
90
+ compose_file = compose_file.first if compose_file.is_a?(Array)
91
+
92
+ # Resolve path relative to .devcontainer directory
93
+ devcontainer_dir = File.dirname(@file_path)
94
+ File.join(devcontainer_dir, compose_file)
95
+ rescue
96
+ nil
97
+ end
98
+
99
+ private
100
+
101
+ # Strip single-line (//) and multi-line (/* */) comments from JSON
102
+ #
103
+ # @param content [String] Raw JSON content
104
+ # @return [String] JSON without comments
105
+ def strip_comments(content)
106
+ # Remove multi-line comments /* ... */
107
+ content = content.gsub(/\/\*.*?\*\//m, "")
108
+
109
+ # Remove single-line comments // ...
110
+ # But preserve comments inside strings (basic approach)
111
+ content.gsub(/(?<!:)\/\/.*?$/, "")
112
+ end
113
+
114
+ # Validate that required properties exist
115
+ #
116
+ # @param json [Hash] Parsed JSON hash
117
+ # @raise [ValidationError] if image property is missing
118
+ def validate_required_properties!(json)
119
+ # Docker Compose files have their own validation - skip image check
120
+ return if json["dockerComposeFile"]
121
+
122
+ unless json["image"] || json["dockerfile"]
123
+ raise ValidationError, "Devcontainer.json must specify either 'image', 'dockerfile', or 'dockerComposeFile' property"
124
+ end
125
+ end
126
+
127
+ # Extract Docker image name
128
+ #
129
+ # @param json [Hash] Parsed devcontainer.json
130
+ # @return [String] Image name
131
+ # @raise [ValidationError] if neither image nor dockerfile specified
132
+ def extract_image(json)
133
+ if json["image"]
134
+ json["image"]
135
+ elsif json["dockerfile"]
136
+ # Dockerfile builds not yet supported - raise validation error
137
+ raise ValidationError, "Image property is required (Dockerfile builds not yet supported)"
138
+ else
139
+ raise ValidationError, "Image property is required"
140
+ end
141
+ end
142
+
143
+ # Extract forward ports
144
+ #
145
+ # @param json [Hash] Parsed devcontainer.json
146
+ # @return [Array<Integer>] List of ports to forward
147
+ def extract_ports(json)
148
+ return [] unless json["forwardPorts"]
149
+
150
+ Array(json["forwardPorts"]).map(&:to_i)
151
+ end
152
+
153
+ # Extract mounts (volumes and bind mounts)
154
+ #
155
+ # @param json [Hash] Parsed devcontainer.json
156
+ # @return [Array<Hash>] List of mount configurations with :source, :target, :type
157
+ def extract_mounts(json)
158
+ return [] unless json["mounts"]
159
+
160
+ Array(json["mounts"]).map do |mount|
161
+ {
162
+ source: mount["source"],
163
+ target: mount["target"],
164
+ type: mount["type"] || "bind"
165
+ }
166
+ end
167
+ end
168
+
169
+ # Extract container environment variables
170
+ #
171
+ # @param json [Hash] Parsed devcontainer.json
172
+ # @return [Hash] Environment variable key-value pairs
173
+ def extract_env(json)
174
+ json["containerEnv"] || {}
175
+ end
176
+
177
+ # Extract Docker run options (runArgs)
178
+ #
179
+ # @param json [Hash] Parsed devcontainer.json
180
+ # @return [Array<String>] Docker run arguments
181
+ def extract_options(json)
182
+ return [] unless json["runArgs"]
183
+
184
+ Array(json["runArgs"])
185
+ end
186
+
187
+ # Extract remote user
188
+ #
189
+ # @param json [Hash] Parsed devcontainer.json
190
+ # @return [String, nil] Remote user name
191
+ def extract_user(json)
192
+ json["remoteUser"]
193
+ end
194
+
195
+ # Extract workspace folder path
196
+ #
197
+ # @param json [Hash] Parsed devcontainer.json
198
+ # @return [String, nil] Workspace folder path
199
+ def extract_workspace(json)
200
+ json["workspaceFolder"]
201
+ end
202
+ end
203
+ end
204
+ end