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,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
|