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,1192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "net/ssh"
|
|
8
|
+
require "sshkit"
|
|
9
|
+
require "sshkit/dsl"
|
|
10
|
+
require_relative "../dev/config"
|
|
11
|
+
require_relative "../dev/devcontainer_parser"
|
|
12
|
+
require_relative "../dev/devcontainer"
|
|
13
|
+
require_relative "../dev/state_manager"
|
|
14
|
+
require_relative "../dev/compose_parser"
|
|
15
|
+
require_relative "../dev/registry"
|
|
16
|
+
require_relative "../dev/builder"
|
|
17
|
+
require_relative "../providers/upcloud"
|
|
18
|
+
|
|
19
|
+
# Configure SSHKit
|
|
20
|
+
SSHKit.config.use_format :pretty
|
|
21
|
+
SSHKit.config.output_verbosity = Logger::INFO
|
|
22
|
+
|
|
23
|
+
module Kamal
|
|
24
|
+
module Cli
|
|
25
|
+
class Dev < Thor
|
|
26
|
+
class_option :config, type: :string, default: "config/dev.yml", desc: "Path to configuration file"
|
|
27
|
+
|
|
28
|
+
desc "init", "Generate config/dev.yml template"
|
|
29
|
+
def init
|
|
30
|
+
config_path = "config/dev.yml"
|
|
31
|
+
|
|
32
|
+
if File.exist?(config_path)
|
|
33
|
+
print "⚠️ #{config_path} already exists. Overwrite? (y/n): "
|
|
34
|
+
response = $stdin.gets.chomp.downcase
|
|
35
|
+
return unless response == "y" || response == "yes"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Create config directory if it doesn't exist
|
|
39
|
+
FileUtils.mkdir_p("config") unless Dir.exist?("config")
|
|
40
|
+
|
|
41
|
+
# Copy template to config/dev.yml
|
|
42
|
+
template_path = File.expand_path("../../dev/templates/dev.yml", __FILE__)
|
|
43
|
+
FileUtils.cp(template_path, config_path)
|
|
44
|
+
|
|
45
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
46
|
+
puts "✅ Created #{config_path}"
|
|
47
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
48
|
+
puts
|
|
49
|
+
puts "Next steps:"
|
|
50
|
+
puts
|
|
51
|
+
puts "1. Edit #{config_path} with your cloud provider credentials"
|
|
52
|
+
puts "2. Create .kamal/secrets file with your secrets:"
|
|
53
|
+
puts " export UPCLOUD_USERNAME=\"your-username\""
|
|
54
|
+
puts " export UPCLOUD_PASSWORD=\"your-password\""
|
|
55
|
+
puts
|
|
56
|
+
puts "3. Deploy your first workspace:"
|
|
57
|
+
puts " kamal dev deploy --count 3"
|
|
58
|
+
puts
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
desc "build", "Build image from Dockerfile and push to registry"
|
|
62
|
+
option :tag, type: :string, desc: "Custom image tag (defaults to timestamp)"
|
|
63
|
+
option :dockerfile, type: :string, desc: "Path to Dockerfile (overrides config)"
|
|
64
|
+
option :context, type: :string, desc: "Build context path (overrides config)"
|
|
65
|
+
option :skip_push, type: :boolean, default: false, desc: "Skip pushing image to registry"
|
|
66
|
+
def build
|
|
67
|
+
config = load_config
|
|
68
|
+
registry = Kamal::Dev::Registry.new(config)
|
|
69
|
+
builder = Kamal::Dev::Builder.new(config, registry)
|
|
70
|
+
|
|
71
|
+
# Check Docker is available
|
|
72
|
+
unless builder.docker_available?
|
|
73
|
+
puts "❌ Error: Docker is required to build images"
|
|
74
|
+
puts " Please install Docker Desktop or Docker Engine"
|
|
75
|
+
exit 1
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check registry credentials
|
|
79
|
+
unless registry.credentials_present?
|
|
80
|
+
username_var = config.registry["username"]
|
|
81
|
+
password_var = config.registry["password"]
|
|
82
|
+
puts "❌ Error: Registry credentials not found"
|
|
83
|
+
puts " Please set #{username_var} and #{password_var} in .kamal/secrets"
|
|
84
|
+
exit 1
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
puts "🔨 Building image for '#{config.service}'"
|
|
88
|
+
puts
|
|
89
|
+
|
|
90
|
+
# Authenticate with registry
|
|
91
|
+
puts "Authenticating with registry..."
|
|
92
|
+
begin
|
|
93
|
+
builder.login
|
|
94
|
+
puts "✓ Logged in to #{registry.server}"
|
|
95
|
+
rescue Kamal::Dev::RegistryError => e
|
|
96
|
+
puts "❌ Registry login failed: #{e.message}"
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
puts
|
|
100
|
+
|
|
101
|
+
# Determine build source from config or options
|
|
102
|
+
# Priority: CLI options > config.build > defaults
|
|
103
|
+
dockerfile = options[:dockerfile]
|
|
104
|
+
context = options[:context]
|
|
105
|
+
|
|
106
|
+
# If not provided via CLI, check config
|
|
107
|
+
unless dockerfile && context
|
|
108
|
+
if config.build_source_type == :devcontainer
|
|
109
|
+
# Parse devcontainer.json to get Dockerfile and context
|
|
110
|
+
devcontainer_path = config.build_source_path
|
|
111
|
+
parser = Kamal::Dev::DevcontainerParser.new(devcontainer_path)
|
|
112
|
+
|
|
113
|
+
if parser.uses_compose?
|
|
114
|
+
# Extract from compose file
|
|
115
|
+
compose_file = parser.compose_file_path
|
|
116
|
+
compose_parser = Kamal::Dev::ComposeParser.new(compose_file)
|
|
117
|
+
main_service = compose_parser.main_service
|
|
118
|
+
|
|
119
|
+
dockerfile ||= compose_parser.service_dockerfile(main_service)
|
|
120
|
+
context ||= compose_parser.service_build_context(main_service)
|
|
121
|
+
else
|
|
122
|
+
# For non-compose devcontainers, this will be implemented later
|
|
123
|
+
raise Kamal::Dev::ConfigurationError, "Non-compose devcontainer builds not yet supported. Use build.dockerfile instead."
|
|
124
|
+
end
|
|
125
|
+
elsif config.build_source_type == :dockerfile
|
|
126
|
+
dockerfile ||= config.build["dockerfile"]
|
|
127
|
+
context ||= config.build_context
|
|
128
|
+
else
|
|
129
|
+
dockerfile ||= "Dockerfile"
|
|
130
|
+
context ||= "."
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
tag = options[:tag]
|
|
135
|
+
|
|
136
|
+
puts "Building image..."
|
|
137
|
+
puts " Dockerfile: #{dockerfile}"
|
|
138
|
+
puts " Context: #{context}"
|
|
139
|
+
puts " Destination: #{config.image}"
|
|
140
|
+
puts " Tag: #{tag || "(auto-generated timestamp)"}"
|
|
141
|
+
puts
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
# Use config.image as base name, registry will handle full path
|
|
145
|
+
image_ref = builder.build(
|
|
146
|
+
dockerfile: dockerfile,
|
|
147
|
+
context: context,
|
|
148
|
+
tag: tag,
|
|
149
|
+
image_base: config.image
|
|
150
|
+
)
|
|
151
|
+
puts
|
|
152
|
+
puts "✓ Built image: #{image_ref}"
|
|
153
|
+
rescue Kamal::Dev::BuildError => e
|
|
154
|
+
puts "❌ Build failed: #{e.message}"
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
puts
|
|
158
|
+
|
|
159
|
+
# Push image (unless --skip-push)
|
|
160
|
+
unless options[:skip_push]
|
|
161
|
+
puts "Pushing image to registry..."
|
|
162
|
+
begin
|
|
163
|
+
builder.push(image_ref)
|
|
164
|
+
puts
|
|
165
|
+
puts "✓ Pushed image: #{image_ref}"
|
|
166
|
+
rescue Kamal::Dev::BuildError => e
|
|
167
|
+
puts "❌ Push failed: #{e.message}"
|
|
168
|
+
exit 1
|
|
169
|
+
end
|
|
170
|
+
puts
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
174
|
+
puts "✅ Build complete!"
|
|
175
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
176
|
+
puts
|
|
177
|
+
puts "Image: #{image_ref}"
|
|
178
|
+
puts
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
desc "push IMAGE", "Push image to registry"
|
|
182
|
+
def push(image_ref = nil)
|
|
183
|
+
config = load_config
|
|
184
|
+
registry = Kamal::Dev::Registry.new(config)
|
|
185
|
+
builder = Kamal::Dev::Builder.new(config, registry)
|
|
186
|
+
|
|
187
|
+
# Use provided image or generate from config
|
|
188
|
+
image_ref ||= begin
|
|
189
|
+
puts "No image specified. Using image from config..."
|
|
190
|
+
tag = registry.tag_with_timestamp
|
|
191
|
+
registry.image_tag(config.image, tag)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Check Docker is available
|
|
195
|
+
unless builder.docker_available?
|
|
196
|
+
puts "❌ Error: Docker is required to push images"
|
|
197
|
+
puts " Please install Docker Desktop or Docker Engine"
|
|
198
|
+
exit 1
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Check registry credentials
|
|
202
|
+
unless registry.credentials_present?
|
|
203
|
+
username_var = config.registry["username"]
|
|
204
|
+
password_var = config.registry["password"]
|
|
205
|
+
puts "❌ Error: Registry credentials not found"
|
|
206
|
+
puts " Please set #{username_var} and #{password_var} in .kamal/secrets"
|
|
207
|
+
exit 1
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
puts "📤 Pushing image '#{image_ref}'"
|
|
211
|
+
puts
|
|
212
|
+
|
|
213
|
+
# Authenticate with registry
|
|
214
|
+
puts "Authenticating with registry..."
|
|
215
|
+
begin
|
|
216
|
+
builder.login
|
|
217
|
+
puts "✓ Logged in to #{registry.server}"
|
|
218
|
+
rescue Kamal::Dev::RegistryError => e
|
|
219
|
+
puts "❌ Registry login failed: #{e.message}"
|
|
220
|
+
exit 1
|
|
221
|
+
end
|
|
222
|
+
puts
|
|
223
|
+
|
|
224
|
+
# Push image
|
|
225
|
+
puts "Pushing image to registry..."
|
|
226
|
+
begin
|
|
227
|
+
builder.push(image_ref)
|
|
228
|
+
puts
|
|
229
|
+
puts "✓ Pushed image: #{image_ref}"
|
|
230
|
+
rescue Kamal::Dev::BuildError => e
|
|
231
|
+
puts "❌ Push failed: #{e.message}"
|
|
232
|
+
exit 1
|
|
233
|
+
end
|
|
234
|
+
puts
|
|
235
|
+
|
|
236
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
237
|
+
puts "✅ Push complete!"
|
|
238
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
239
|
+
puts
|
|
240
|
+
puts "Image: #{image_ref}"
|
|
241
|
+
puts
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
desc "deploy [NAME]", "Deploy devcontainer(s)"
|
|
245
|
+
option :count, type: :numeric, default: 1, desc: "Number of containers to deploy"
|
|
246
|
+
option :from, type: :string, default: ".devcontainer/devcontainer.json", desc: "Path to devcontainer.json"
|
|
247
|
+
option :skip_cost_check, type: :boolean, default: false, desc: "Skip cost confirmation prompt"
|
|
248
|
+
option :skip_build, type: :boolean, default: false, desc: "Skip building image (use existing)"
|
|
249
|
+
option :skip_push, type: :boolean, default: false, desc: "Skip pushing image to registry"
|
|
250
|
+
def deploy(name = nil)
|
|
251
|
+
config = load_config
|
|
252
|
+
count = options[:count] || 1
|
|
253
|
+
|
|
254
|
+
# Validate git configuration if git clone is enabled
|
|
255
|
+
validate_git_config!(config)
|
|
256
|
+
|
|
257
|
+
puts "🚀 Deploying #{count} devcontainer workspace(s) for '#{config.service}'"
|
|
258
|
+
puts
|
|
259
|
+
|
|
260
|
+
# Step 1: Check if using Docker Compose
|
|
261
|
+
# For new format: use build.devcontainer path
|
|
262
|
+
# For old format: use image path (backward compatibility)
|
|
263
|
+
devcontainer_path = config.build_source_path || config.image
|
|
264
|
+
devcontainer_path = options[:from] if options[:from] != ".devcontainer/devcontainer.json" # CLI override
|
|
265
|
+
|
|
266
|
+
parser = Kamal::Dev::DevcontainerParser.new(devcontainer_path)
|
|
267
|
+
uses_compose = parser.uses_compose?
|
|
268
|
+
|
|
269
|
+
if uses_compose
|
|
270
|
+
deploy_compose_stack(config, count, parser)
|
|
271
|
+
else
|
|
272
|
+
deploy_single_container(config, count)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
no_commands do
|
|
277
|
+
# Deploy Docker Compose stacks to multiple VMs
|
|
278
|
+
#
|
|
279
|
+
# Handles full compose deployment workflow: build, push, transform, deploy
|
|
280
|
+
#
|
|
281
|
+
# @param config [Kamal::Dev::Config] Configuration object
|
|
282
|
+
# @param count [Integer] Number of VMs to deploy
|
|
283
|
+
# @param parser [Kamal::Dev::DevcontainerParser] Devcontainer parser
|
|
284
|
+
def deploy_compose_stack(config, count, parser)
|
|
285
|
+
compose_file = parser.compose_file_path
|
|
286
|
+
unless compose_file && File.exist?(compose_file)
|
|
287
|
+
raise Kamal::Dev::ConfigurationError, "Compose file not found at: #{compose_file || "unknown path"}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
compose_parser = Kamal::Dev::ComposeParser.new(compose_file)
|
|
291
|
+
registry = Kamal::Dev::Registry.new(config)
|
|
292
|
+
builder = Kamal::Dev::Builder.new(config, registry)
|
|
293
|
+
|
|
294
|
+
# Validate main service has build section
|
|
295
|
+
unless compose_parser.main_service
|
|
296
|
+
raise Kamal::Dev::ConfigurationError, "No services found in compose file: #{compose_file}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
unless options[:skip_build] || compose_parser.has_build_section?(compose_parser.main_service)
|
|
300
|
+
raise Kamal::Dev::ConfigurationError, "Main service '#{compose_parser.main_service}' has no build section. Use --skip-build with existing image."
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
puts "✓ Detected Docker Compose deployment"
|
|
304
|
+
puts " Compose file: #{File.basename(compose_file)}"
|
|
305
|
+
puts " Main service: #{compose_parser.main_service}"
|
|
306
|
+
puts " Dependent services: #{compose_parser.dependent_services.join(", ")}" unless compose_parser.dependent_services.empty?
|
|
307
|
+
puts
|
|
308
|
+
|
|
309
|
+
# Build and push main service image (unless skipped)
|
|
310
|
+
if options[:skip_build]
|
|
311
|
+
# Use existing image
|
|
312
|
+
tag = options[:tag] || "latest"
|
|
313
|
+
image_ref = registry.image_tag(config.image, tag)
|
|
314
|
+
puts "Using existing image: #{image_ref}"
|
|
315
|
+
puts
|
|
316
|
+
else
|
|
317
|
+
main_service = compose_parser.main_service
|
|
318
|
+
dockerfile = compose_parser.service_dockerfile(main_service)
|
|
319
|
+
context = compose_parser.service_build_context(main_service)
|
|
320
|
+
|
|
321
|
+
puts "🔨 Building image for service '#{main_service}'"
|
|
322
|
+
tag = options[:tag] || Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
323
|
+
|
|
324
|
+
begin
|
|
325
|
+
image_ref = builder.build(
|
|
326
|
+
dockerfile: dockerfile,
|
|
327
|
+
context: context,
|
|
328
|
+
tag: tag,
|
|
329
|
+
image_base: config.image
|
|
330
|
+
)
|
|
331
|
+
puts "✓ Built #{image_ref}"
|
|
332
|
+
puts
|
|
333
|
+
rescue => e
|
|
334
|
+
raise Kamal::Dev::BuildError, "Failed to build image: #{e.message}"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
unless options[:skip_push]
|
|
338
|
+
puts "📤 Pushing #{image_ref} to registry..."
|
|
339
|
+
begin
|
|
340
|
+
builder.push(image_ref)
|
|
341
|
+
puts "✓ Pushed #{image_ref}"
|
|
342
|
+
puts
|
|
343
|
+
rescue => e
|
|
344
|
+
raise Kamal::Dev::RegistryError, "Failed to push image: #{e.message}"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Transform compose file
|
|
350
|
+
puts "Transforming compose file..."
|
|
351
|
+
transformed_yaml = compose_parser.transform_for_deployment(image_ref, config: config)
|
|
352
|
+
if config.git_clone_enabled?
|
|
353
|
+
puts "✓ Transformed compose.yaml (build → image, git clone enabled)"
|
|
354
|
+
else
|
|
355
|
+
puts "✓ Transformed compose.yaml (build → image)"
|
|
356
|
+
end
|
|
357
|
+
puts
|
|
358
|
+
|
|
359
|
+
# Estimate cost and get confirmation
|
|
360
|
+
unless options[:skip_cost_check]
|
|
361
|
+
show_cost_estimate(config, count)
|
|
362
|
+
return unless confirm_deployment
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Provision or reuse VMs
|
|
366
|
+
puts "Provisioning #{count} VM(s)..."
|
|
367
|
+
vms = provision_vms(config, count)
|
|
368
|
+
puts "✓ #{vms.size} VM(s) ready"
|
|
369
|
+
puts
|
|
370
|
+
|
|
371
|
+
# Save VM state immediately (before bootstrap) to track orphaned VMs
|
|
372
|
+
# Only save NEW VMs that don't already have state
|
|
373
|
+
state_manager = get_state_manager
|
|
374
|
+
existing_state = state_manager.read_state
|
|
375
|
+
deployments_data = existing_state.fetch("deployments", {})
|
|
376
|
+
|
|
377
|
+
vms.each_with_index do |vm, idx|
|
|
378
|
+
vm_name = vm[:name] || "#{config.service}-#{idx + 1}"
|
|
379
|
+
# Skip if this VM already has state (reused VM)
|
|
380
|
+
next if deployments_data.key?(vm_name)
|
|
381
|
+
|
|
382
|
+
state_manager.add_compose_deployment(vm_name, vm[:id], vm[:ip], [])
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Wait for SSH to become available
|
|
386
|
+
puts "Waiting for SSH to become available on #{vms.size} VM(s)..."
|
|
387
|
+
wait_for_ssh(vms.map { |vm| vm[:ip] })
|
|
388
|
+
puts "✓ SSH ready on all VMs"
|
|
389
|
+
puts
|
|
390
|
+
|
|
391
|
+
# Bootstrap Docker + Compose
|
|
392
|
+
puts "Bootstrapping Docker and Compose on #{vms.size} VM(s)..."
|
|
393
|
+
bootstrap_docker(vms.map { |vm| vm[:ip] })
|
|
394
|
+
puts "✓ Docker and Compose installed on all VMs"
|
|
395
|
+
puts
|
|
396
|
+
|
|
397
|
+
# Login to registry on remote VMs
|
|
398
|
+
puts "Logging into container registry on #{vms.size} VM(s)..."
|
|
399
|
+
login_to_registry(vms.map { |vm| vm[:ip] }, registry)
|
|
400
|
+
puts "✓ Registry login successful"
|
|
401
|
+
puts
|
|
402
|
+
|
|
403
|
+
# Deploy compose stacks to each VM
|
|
404
|
+
deployed_vms = []
|
|
405
|
+
|
|
406
|
+
vms.each_with_index do |vm, idx|
|
|
407
|
+
vm_name = "#{config.service}-#{idx + 1}"
|
|
408
|
+
puts "Deploying compose stack to #{vm_name} (#{vm[:ip]})..."
|
|
409
|
+
|
|
410
|
+
containers = []
|
|
411
|
+
|
|
412
|
+
begin
|
|
413
|
+
on(prepare_hosts([vm[:ip]])) do
|
|
414
|
+
# Copy transformed compose file
|
|
415
|
+
upload! StringIO.new(transformed_yaml), "/root/compose.yaml"
|
|
416
|
+
|
|
417
|
+
# Deploy stack
|
|
418
|
+
execute "docker", "compose", "-f", "/root/compose.yaml", "up", "-d"
|
|
419
|
+
|
|
420
|
+
# Get container information
|
|
421
|
+
containers_json = capture("docker", "compose", "-f", "/root/compose.yaml", "ps", "--format", "json")
|
|
422
|
+
|
|
423
|
+
# Parse container information
|
|
424
|
+
containers_json.each_line do |line|
|
|
425
|
+
next if line.strip.empty?
|
|
426
|
+
container_data = JSON.parse(line.strip)
|
|
427
|
+
containers << {
|
|
428
|
+
name: container_data["Name"],
|
|
429
|
+
service: container_data["Service"],
|
|
430
|
+
image: container_data["Image"],
|
|
431
|
+
status: container_data["State"]
|
|
432
|
+
}
|
|
433
|
+
rescue JSON::ParserError => e
|
|
434
|
+
warn "Warning: Failed to parse container JSON: #{e.message}"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Save compose deployment to state
|
|
439
|
+
state_manager.add_compose_deployment(vm_name, vm[:id], vm[:ip], containers)
|
|
440
|
+
deployed_vms << vm
|
|
441
|
+
|
|
442
|
+
puts "✓ Deployed stack to #{vm_name}"
|
|
443
|
+
puts " VM: #{vm[:id]}"
|
|
444
|
+
puts " IP: #{vm[:ip]}"
|
|
445
|
+
puts " Containers: #{containers.map { |c| c[:service] }.join(", ")}"
|
|
446
|
+
puts
|
|
447
|
+
rescue => e
|
|
448
|
+
warn "❌ Failed to deploy to #{vm_name}: #{e.message}"
|
|
449
|
+
puts " VM will be cleaned up..."
|
|
450
|
+
# Continue with other VMs
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Check if any deployments succeeded
|
|
455
|
+
if deployed_vms.empty?
|
|
456
|
+
raise Kamal::Dev::DeploymentError, "All compose stack deployments failed"
|
|
457
|
+
elsif deployed_vms.size < vms.size
|
|
458
|
+
warn "⚠️ Warning: #{vms.size - deployed_vms.size} of #{vms.size} deployments failed"
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
462
|
+
puts "✅ Compose deployment complete!"
|
|
463
|
+
puts
|
|
464
|
+
puts "#{count} compose stack(s) deployed and running"
|
|
465
|
+
puts
|
|
466
|
+
puts "View deployments: kamal dev list"
|
|
467
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Deploy single containers (non-compose workflow)
|
|
471
|
+
#
|
|
472
|
+
# Original deployment flow for direct image deployments
|
|
473
|
+
#
|
|
474
|
+
# @param config [Kamal::Dev::Config] Configuration object
|
|
475
|
+
# @param count [Integer] Number of containers to deploy
|
|
476
|
+
def deploy_single_container(config, count)
|
|
477
|
+
# Load devcontainer
|
|
478
|
+
devcontainer_config = config.devcontainer
|
|
479
|
+
puts "✓ Loaded devcontainer configuration"
|
|
480
|
+
puts " Image: #{devcontainer_config.image}"
|
|
481
|
+
puts " Source: #{config.devcontainer_json? ? "devcontainer.json" : "direct image reference"}"
|
|
482
|
+
puts
|
|
483
|
+
|
|
484
|
+
# Estimate cost and get confirmation
|
|
485
|
+
unless options[:skip_cost_check]
|
|
486
|
+
show_cost_estimate(config, count)
|
|
487
|
+
return unless confirm_deployment
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Provision or reuse VMs
|
|
491
|
+
puts "Provisioning #{count} VM(s)..."
|
|
492
|
+
vms = provision_vms(config, count)
|
|
493
|
+
puts "✓ #{vms.size} VM(s) ready"
|
|
494
|
+
puts
|
|
495
|
+
|
|
496
|
+
# Save VM state immediately (before bootstrap) to track orphaned VMs
|
|
497
|
+
# Only save NEW VMs that don't already have state
|
|
498
|
+
state_manager = get_state_manager
|
|
499
|
+
existing_state = state_manager.read_state
|
|
500
|
+
deployments_data = existing_state.fetch("deployments", {})
|
|
501
|
+
next_index = find_next_index(deployments_data, config.service)
|
|
502
|
+
|
|
503
|
+
vms.each_with_index do |vm, idx|
|
|
504
|
+
# Skip if this VM already has state (reused VM)
|
|
505
|
+
next if vm[:name] && deployments_data.key?(vm[:name])
|
|
506
|
+
|
|
507
|
+
container_name = vm[:name] || config.container_name(next_index + idx)
|
|
508
|
+
deployment = {
|
|
509
|
+
name: container_name,
|
|
510
|
+
vm_id: vm[:id],
|
|
511
|
+
vm_ip: vm[:ip],
|
|
512
|
+
container_name: container_name,
|
|
513
|
+
status: "provisioned", # Track VM even if bootstrap fails
|
|
514
|
+
deployed_at: Time.now.utc.iso8601
|
|
515
|
+
}
|
|
516
|
+
state_manager.add_deployment(deployment)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Wait for SSH to become available
|
|
520
|
+
puts "Waiting for SSH to become available on #{vms.size} VM(s)..."
|
|
521
|
+
wait_for_ssh(vms.map { |vm| vm[:ip] })
|
|
522
|
+
puts "✓ SSH ready on all VMs"
|
|
523
|
+
puts
|
|
524
|
+
|
|
525
|
+
# Bootstrap Docker on VMs
|
|
526
|
+
puts "Bootstrapping Docker on #{vms.size} VM(s)..."
|
|
527
|
+
bootstrap_docker(vms.map { |vm| vm[:ip] })
|
|
528
|
+
puts "✓ Docker installed on all VMs"
|
|
529
|
+
puts
|
|
530
|
+
|
|
531
|
+
# Deploy containers
|
|
532
|
+
vms.each_with_index do |vm, idx|
|
|
533
|
+
container_name = config.container_name(next_index + idx)
|
|
534
|
+
docker_command = devcontainer_config.docker_run_command(name: container_name)
|
|
535
|
+
|
|
536
|
+
puts "Deploying #{container_name} to #{vm[:ip]}..."
|
|
537
|
+
deploy_container(vm[:ip], docker_command)
|
|
538
|
+
|
|
539
|
+
# Update deployment state to running
|
|
540
|
+
state_manager.update_deployment_status(container_name, "running")
|
|
541
|
+
|
|
542
|
+
puts "✓ #{container_name}"
|
|
543
|
+
puts " VM: #{vm[:id]}"
|
|
544
|
+
puts " IP: #{vm[:ip]}"
|
|
545
|
+
puts " Status: running"
|
|
546
|
+
puts
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
550
|
+
puts "✅ Deployment complete!"
|
|
551
|
+
puts
|
|
552
|
+
puts "#{count} workspace(s) deployed and running"
|
|
553
|
+
puts
|
|
554
|
+
puts "View deployments: kamal dev list"
|
|
555
|
+
puts "Connect via SSH: ssh root@<VM_IP>"
|
|
556
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
desc "stop [NAME]", "Stop devcontainer(s)"
|
|
561
|
+
option :all, type: :boolean, default: false, desc: "Stop all containers"
|
|
562
|
+
def stop(name = nil)
|
|
563
|
+
state_manager = get_state_manager
|
|
564
|
+
deployments = state_manager.list_deployments
|
|
565
|
+
|
|
566
|
+
if deployments.empty?
|
|
567
|
+
puts "No deployments found"
|
|
568
|
+
return
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
if options[:all]
|
|
572
|
+
# Stop all containers
|
|
573
|
+
count = 0
|
|
574
|
+
deployments.each do |container_name, deployment|
|
|
575
|
+
puts "Stopping #{container_name} on #{deployment["vm_ip"]}..."
|
|
576
|
+
stop_container(deployment["vm_ip"], container_name)
|
|
577
|
+
state_manager.update_deployment_status(container_name, "stopped")
|
|
578
|
+
count += 1
|
|
579
|
+
end
|
|
580
|
+
puts "Stopped #{count} container(s)"
|
|
581
|
+
elsif name
|
|
582
|
+
# Stop specific container
|
|
583
|
+
unless deployments.key?(name)
|
|
584
|
+
puts "Container '#{name}' not found"
|
|
585
|
+
return
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
deployment = deployments[name]
|
|
589
|
+
puts "Stopping #{name} on #{deployment["vm_ip"]}..."
|
|
590
|
+
stop_container(deployment["vm_ip"], name)
|
|
591
|
+
state_manager.update_deployment_status(name, "stopped")
|
|
592
|
+
puts "Container '#{name}' stopped"
|
|
593
|
+
else
|
|
594
|
+
puts "Error: Please specify a container name or use --all flag"
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
desc "list", "List deployed devcontainers"
|
|
599
|
+
option :format, type: :string, default: "table", desc: "Output format (table|json|yaml)"
|
|
600
|
+
def list
|
|
601
|
+
state_manager = get_state_manager
|
|
602
|
+
deployments = state_manager.list_deployments
|
|
603
|
+
|
|
604
|
+
if deployments.empty?
|
|
605
|
+
puts "No deployments found"
|
|
606
|
+
return
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
case options[:format]
|
|
610
|
+
when "json"
|
|
611
|
+
puts JSON.pretty_generate(deployments)
|
|
612
|
+
when "yaml"
|
|
613
|
+
puts YAML.dump(deployments)
|
|
614
|
+
else
|
|
615
|
+
# Table format (default)
|
|
616
|
+
print_table(deployments)
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
desc "remove [NAME]", "Remove devcontainer(s) and destroy VMs"
|
|
621
|
+
option :all, type: :boolean, default: false, desc: "Remove all deployments"
|
|
622
|
+
option :force, type: :boolean, default: false, desc: "Skip confirmation prompt"
|
|
623
|
+
def remove(name = nil)
|
|
624
|
+
state_manager = get_state_manager
|
|
625
|
+
deployments = state_manager.list_deployments
|
|
626
|
+
|
|
627
|
+
if deployments.empty?
|
|
628
|
+
puts "No deployments found"
|
|
629
|
+
return
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Load config and provider if available
|
|
633
|
+
provider = nil
|
|
634
|
+
begin
|
|
635
|
+
config = load_config
|
|
636
|
+
provider = get_provider(config)
|
|
637
|
+
rescue => e
|
|
638
|
+
puts "⚠️ Warning: Could not load config (#{e.message}). VMs will not be destroyed."
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
if options[:all]
|
|
642
|
+
# Confirmation prompt
|
|
643
|
+
unless options[:force]
|
|
644
|
+
print "⚠️ This will destroy #{deployments.size} VM(s) and remove all containers. Continue? (y/n): "
|
|
645
|
+
response = $stdin.gets.chomp.downcase
|
|
646
|
+
return unless response == "y" || response == "yes"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Remove all containers
|
|
650
|
+
count = 0
|
|
651
|
+
deployments.each do |container_name, deployment|
|
|
652
|
+
if provider
|
|
653
|
+
puts "Destroying VM #{deployment["vm_id"]} (#{deployment["vm_ip"]})..."
|
|
654
|
+
begin
|
|
655
|
+
stop_container(deployment["vm_ip"], container_name)
|
|
656
|
+
rescue
|
|
657
|
+
nil
|
|
658
|
+
end
|
|
659
|
+
provider.destroy_vm(deployment["vm_id"])
|
|
660
|
+
end
|
|
661
|
+
state_manager.remove_deployment(container_name)
|
|
662
|
+
count += 1
|
|
663
|
+
end
|
|
664
|
+
puts "Removed #{count} deployment(s)"
|
|
665
|
+
elsif name
|
|
666
|
+
# Remove specific container
|
|
667
|
+
unless deployments.key?(name)
|
|
668
|
+
puts "Container '#{name}' not found"
|
|
669
|
+
return
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
deployment = deployments[name]
|
|
673
|
+
|
|
674
|
+
# Confirmation prompt
|
|
675
|
+
unless options[:force]
|
|
676
|
+
print "⚠️ This will destroy VM #{deployment["vm_id"]} and remove container '#{name}'. Continue? (y/n): "
|
|
677
|
+
response = $stdin.gets.chomp.downcase
|
|
678
|
+
return unless response == "y" || response == "yes"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
if provider
|
|
682
|
+
puts "Destroying VM #{deployment["vm_id"]} (#{deployment["vm_ip"]})..."
|
|
683
|
+
begin
|
|
684
|
+
stop_container(deployment["vm_ip"], name)
|
|
685
|
+
rescue
|
|
686
|
+
nil
|
|
687
|
+
end
|
|
688
|
+
provider.destroy_vm(deployment["vm_id"])
|
|
689
|
+
end
|
|
690
|
+
state_manager.remove_deployment(name)
|
|
691
|
+
puts "Container '#{name}' removed"
|
|
692
|
+
else
|
|
693
|
+
puts "Error: Please specify a container name or use --all flag"
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
desc "status [NAME]", "Show devcontainer status"
|
|
698
|
+
option :all, type: :boolean, default: false, desc: "Show all deployments"
|
|
699
|
+
option :verbose, type: :boolean, default: false, desc: "Include VM details"
|
|
700
|
+
def status(name = nil)
|
|
701
|
+
puts "Status command called"
|
|
702
|
+
# Implementation will be added in later tasks
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
no_commands do
|
|
706
|
+
include SSHKit::DSL
|
|
707
|
+
|
|
708
|
+
# Load and memoize configuration
|
|
709
|
+
def load_config
|
|
710
|
+
@config ||= begin
|
|
711
|
+
config_path = options[:config] || self.class.class_options[:config].default
|
|
712
|
+
Kamal::Dev::Config.new(config_path, validate: true)
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Validate git configuration for remote code cloning
|
|
717
|
+
#
|
|
718
|
+
# Checks if git clone is enabled and validates token configuration:
|
|
719
|
+
# - Warns if using HTTPS URL without token (may fail for private repos)
|
|
720
|
+
# - Errors if token ENV var is configured but not actually set
|
|
721
|
+
#
|
|
722
|
+
# @param config [Kamal::Dev::Config] Configuration object
|
|
723
|
+
# @raise [Kamal::Dev::ConfigurationError] if token configured but ENV var not set
|
|
724
|
+
def validate_git_config!(config)
|
|
725
|
+
return unless config.git_clone_enabled?
|
|
726
|
+
|
|
727
|
+
repo_url = config.git_repository
|
|
728
|
+
|
|
729
|
+
# Check if using HTTPS (implies possible private repo)
|
|
730
|
+
if repo_url.start_with?("https://")
|
|
731
|
+
token_env = config.git_token_env
|
|
732
|
+
|
|
733
|
+
if token_env
|
|
734
|
+
# Token configured - verify it's actually available in ENV
|
|
735
|
+
unless config.git_token
|
|
736
|
+
raise Kamal::Dev::ConfigurationError,
|
|
737
|
+
"Git token environment variable '#{token_env}' is configured but not set.\n" \
|
|
738
|
+
"Please add to .kamal/secrets: export #{token_env}=\"your_token_here\""
|
|
739
|
+
end
|
|
740
|
+
puts "✓ Git authentication configured (using #{token_env})"
|
|
741
|
+
else
|
|
742
|
+
# No token configured - warn about potential issues with private repos
|
|
743
|
+
puts "⚠️ Git clone configured without authentication token"
|
|
744
|
+
puts " This will work for public repositories only"
|
|
745
|
+
puts " For private repos, configure git.token in config/dev.yml"
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Get state manager instance
|
|
751
|
+
def get_state_manager
|
|
752
|
+
@state_manager ||= Kamal::Dev::StateManager.new(".kamal/dev_state.yml")
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# Prepare SSH hosts with credentials
|
|
756
|
+
#
|
|
757
|
+
# Converts IP addresses to SSHKit host objects with SSH credentials configured.
|
|
758
|
+
#
|
|
759
|
+
# @param ips [Array<String>] IP addresses
|
|
760
|
+
# @return [Array<SSHKit::Host>] Configured host objects
|
|
761
|
+
def prepare_hosts(ips)
|
|
762
|
+
Array(ips).map do |ip|
|
|
763
|
+
host = SSHKit::Host.new(ip)
|
|
764
|
+
host.user = ssh_user
|
|
765
|
+
host.ssh_options = ssh_options
|
|
766
|
+
host
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# SSH user for VM connections
|
|
771
|
+
def ssh_user
|
|
772
|
+
"root" # TODO: Make configurable via config/dev.yml
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# SSH options for connections
|
|
776
|
+
def ssh_options
|
|
777
|
+
{
|
|
778
|
+
keys: [ssh_key_path],
|
|
779
|
+
auth_methods: ["publickey"],
|
|
780
|
+
verify_host_key: :never # Development VMs, accept any host key
|
|
781
|
+
}
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# SSH key path
|
|
785
|
+
def ssh_key_path
|
|
786
|
+
File.expand_path("~/.ssh/id_rsa") # TODO: Make configurable
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Wait for SSH to become available on VMs with exponential backoff
|
|
790
|
+
#
|
|
791
|
+
# Retries SSH connection with exponential backoff until successful or timeout.
|
|
792
|
+
# Cloud-init VMs may take 30-60s to boot and start SSH daemon.
|
|
793
|
+
#
|
|
794
|
+
# @param ips [Array<String>] VM IP addresses
|
|
795
|
+
# @param max_retries [Integer] Maximum number of retry attempts (default: 12)
|
|
796
|
+
# @param initial_delay [Integer] Initial delay in seconds (default: 5)
|
|
797
|
+
# @raise [RuntimeError] if SSH doesn't become available within timeout
|
|
798
|
+
#
|
|
799
|
+
# Retry schedule (total ~6 minutes):
|
|
800
|
+
# - Attempt 1-3: 5s, 10s, 20s (fast retries for quick boots)
|
|
801
|
+
# - Attempt 4-8: 30s each (steady retries)
|
|
802
|
+
# - Attempt 9-12: 30s each (final attempts)
|
|
803
|
+
def wait_for_ssh(ips, max_retries: 12, initial_delay: 5)
|
|
804
|
+
ips.each do |ip|
|
|
805
|
+
retries = 0
|
|
806
|
+
delay = initial_delay
|
|
807
|
+
connected = false
|
|
808
|
+
|
|
809
|
+
while retries < max_retries && !connected
|
|
810
|
+
begin
|
|
811
|
+
# Attempt SSH connection with short timeout
|
|
812
|
+
Net::SSH.start(ip, "root",
|
|
813
|
+
keys: [File.expand_path(load_config.ssh_key_path).sub(/\.pub$/, "")],
|
|
814
|
+
timeout: 5,
|
|
815
|
+
auth_methods: ["publickey"],
|
|
816
|
+
verify_host_key: :never,
|
|
817
|
+
non_interactive: true) do |ssh|
|
|
818
|
+
# Simple command to verify SSH is working
|
|
819
|
+
ssh.exec!("echo 'SSH ready'")
|
|
820
|
+
connected = true
|
|
821
|
+
end
|
|
822
|
+
rescue Net::SSH::Exception, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT, SocketError => e
|
|
823
|
+
retries += 1
|
|
824
|
+
if retries < max_retries
|
|
825
|
+
print "."
|
|
826
|
+
sleep delay
|
|
827
|
+
# Exponential backoff: 5s -> 10s -> 20s -> 30s (cap at 30s)
|
|
828
|
+
delay = [delay * 2, 30].min
|
|
829
|
+
else
|
|
830
|
+
raise "SSH connection to #{ip} failed after #{max_retries} attempts (#{max_retries * initial_delay}s timeout). Error: #{e.message}"
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
puts " ready" if connected
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Bootstrap Docker on VMs if not already installed
|
|
840
|
+
#
|
|
841
|
+
# Checks if Docker is installed and installs it if missing.
|
|
842
|
+
# Uses official Docker installation script.
|
|
843
|
+
#
|
|
844
|
+
# @param ips [Array<String>] VM IP addresses
|
|
845
|
+
def bootstrap_docker(ips)
|
|
846
|
+
on(prepare_hosts(ips)) do
|
|
847
|
+
# Check if Docker is already installed
|
|
848
|
+
# Use 'which' instead of 'command -v' since command is a shell builtin
|
|
849
|
+
docker_installed = test("which", "docker")
|
|
850
|
+
|
|
851
|
+
unless docker_installed
|
|
852
|
+
puts "Installing Docker..."
|
|
853
|
+
execute "curl", "-fsSL", "https://get.docker.com", "|", "sh"
|
|
854
|
+
execute "systemctl", "start", "docker"
|
|
855
|
+
execute "systemctl", "enable", "docker"
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Check if Docker Compose v2 is installed
|
|
859
|
+
compose_installed = test("docker", "compose", "version")
|
|
860
|
+
|
|
861
|
+
unless compose_installed
|
|
862
|
+
puts "Installing Docker Compose v2..."
|
|
863
|
+
# Install docker-compose-plugin (works on Ubuntu/Debian)
|
|
864
|
+
execute "apt-get", "update", raise_on_non_zero_exit: false
|
|
865
|
+
execute "apt-get", "install", "-y", "docker-compose-plugin", raise_on_non_zero_exit: false
|
|
866
|
+
|
|
867
|
+
# Verify installation succeeded
|
|
868
|
+
unless test("docker", "compose", "version")
|
|
869
|
+
raise Kamal::Dev::ConfigurationError, "Docker Compose v2 installation failed. Please install manually."
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
# Login to container registry on remote VMs
|
|
876
|
+
#
|
|
877
|
+
# Authenticates Docker on remote VMs with the configured registry.
|
|
878
|
+
# Required before pulling private images in compose deployments.
|
|
879
|
+
# Uses same approach as base Kamal: direct password with -p flag.
|
|
880
|
+
#
|
|
881
|
+
# @param ips [Array<String>] VM IP addresses
|
|
882
|
+
# @param registry [Kamal::Dev::Registry] Registry configuration
|
|
883
|
+
def login_to_registry(ips, registry)
|
|
884
|
+
unless registry.credentials_present?
|
|
885
|
+
puts "⚠️ Warning: Registry credentials not configured, skipping login"
|
|
886
|
+
puts " Private image pulls may fail without authentication"
|
|
887
|
+
return
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# Use Kamal's escaping approach: .dump handles all special chars
|
|
891
|
+
# This matches how base Kamal does registry login
|
|
892
|
+
username_escaped = registry.username.to_s.dump.gsub(/`/, '\\\\`')
|
|
893
|
+
password_escaped = registry.password.to_s.dump.gsub(/`/, '\\\\`')
|
|
894
|
+
|
|
895
|
+
on(prepare_hosts(ips)) do
|
|
896
|
+
# Execute docker login with -u and -p flags (same as base Kamal)
|
|
897
|
+
# SSHKit will properly quote arguments when passed as separate elements
|
|
898
|
+
execute "docker", "login", registry.server, "-u", username_escaped, "-p", password_escaped
|
|
899
|
+
end
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
# Deploy container to VM via SSH
|
|
903
|
+
#
|
|
904
|
+
# Executes docker run command on remote VM.
|
|
905
|
+
#
|
|
906
|
+
# @param ip [String] VM IP address
|
|
907
|
+
# @param docker_command [Array<String>] Docker run command array
|
|
908
|
+
def deploy_container(ip, docker_command)
|
|
909
|
+
on(prepare_hosts([ip])) do
|
|
910
|
+
execute(*docker_command)
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Stop container on VM via SSH
|
|
915
|
+
#
|
|
916
|
+
# @param ip [String] VM IP address
|
|
917
|
+
# @param container_name [String] Container name
|
|
918
|
+
def stop_container(ip, container_name)
|
|
919
|
+
on(prepare_hosts([ip])) do
|
|
920
|
+
# Check if container is running
|
|
921
|
+
# Note: capture with raise_on_non_zero_exit: false may return false on failure
|
|
922
|
+
running = capture("docker", "ps", "-q", "-f", "name=#{container_name}", raise_on_non_zero_exit: false)
|
|
923
|
+
running = running.to_s.strip if running
|
|
924
|
+
if running && !running.empty?
|
|
925
|
+
execute "docker", "stop", container_name
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Get cloud provider instance for VM provisioning
|
|
931
|
+
#
|
|
932
|
+
# Currently hardcoded to UpCloud provider. Credentials loaded from ENV variables.
|
|
933
|
+
# Future enhancement will support multiple providers via factory pattern.
|
|
934
|
+
#
|
|
935
|
+
# @param config [Kamal::Dev::Config] Deployment configuration
|
|
936
|
+
# @return [Kamal::Providers::Upcloud] UpCloud provider instance
|
|
937
|
+
# @raise [RuntimeError] if UPCLOUD_USERNAME or UPCLOUD_PASSWORD not set
|
|
938
|
+
#
|
|
939
|
+
# @example
|
|
940
|
+
# provider = get_provider(config)
|
|
941
|
+
# vm = provider.provision_vm(zone: "us-nyc1", plan: "1xCPU-2GB", ...)
|
|
942
|
+
def get_provider(config)
|
|
943
|
+
# TODO: Support multiple providers via factory pattern
|
|
944
|
+
# For now, assume UpCloud with credentials from ENV
|
|
945
|
+
username = ENV["UPCLOUD_USERNAME"]
|
|
946
|
+
password = ENV["UPCLOUD_PASSWORD"]
|
|
947
|
+
|
|
948
|
+
unless username && password
|
|
949
|
+
raise "Missing UpCloud credentials. Set UPCLOUD_USERNAME and UPCLOUD_PASSWORD environment variables."
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
Kamal::Providers::Upcloud.new(username: username, password: password)
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
# Display cost estimate and pricing information to user
|
|
956
|
+
#
|
|
957
|
+
# Queries provider for cost estimate and displays formatted output with:
|
|
958
|
+
# - VM plan and zone information
|
|
959
|
+
# - Cost warning message
|
|
960
|
+
# - Link to provider's pricing page
|
|
961
|
+
#
|
|
962
|
+
# @param config [Kamal::Dev::Config] Deployment configuration
|
|
963
|
+
# @param count [Integer] Number of VMs to deploy
|
|
964
|
+
# @return [void]
|
|
965
|
+
def show_cost_estimate(config, count)
|
|
966
|
+
provider = get_provider(config)
|
|
967
|
+
estimate = provider.estimate_cost(config.provider)
|
|
968
|
+
|
|
969
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
970
|
+
puts "💰 Cost Estimate"
|
|
971
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
972
|
+
puts
|
|
973
|
+
puts "Deploying #{count} × #{estimate[:plan]} VMs in #{estimate[:zone]}"
|
|
974
|
+
puts
|
|
975
|
+
puts "⚠️ #{estimate[:warning]}"
|
|
976
|
+
puts
|
|
977
|
+
puts "For accurate pricing, visit: #{estimate[:pricing_url]}"
|
|
978
|
+
puts
|
|
979
|
+
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
980
|
+
puts
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# Prompt user for deployment confirmation
|
|
984
|
+
#
|
|
985
|
+
# Displays interactive prompt asking user to confirm deployment.
|
|
986
|
+
# Accepts "y" or "yes" (case-insensitive) as confirmation.
|
|
987
|
+
#
|
|
988
|
+
# @return [Boolean] true if user confirmed, false otherwise
|
|
989
|
+
def confirm_deployment
|
|
990
|
+
print "Continue with deployment? (y/n): "
|
|
991
|
+
response = $stdin.gets.chomp.downcase
|
|
992
|
+
response == "y" || response == "yes"
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
# Provision or reuse VMs for deployment
|
|
996
|
+
#
|
|
997
|
+
# Checks state file for existing VMs and reuses them if available.
|
|
998
|
+
# Only provisions NEW VMs if needed to reach desired count.
|
|
999
|
+
#
|
|
1000
|
+
# @param config [Kamal::Dev::Config] Deployment configuration
|
|
1001
|
+
# @param count [Integer] Number of VMs needed
|
|
1002
|
+
# @return [Array<Hash>] Array of VM details, each containing:
|
|
1003
|
+
# - :id [String] VM identifier (UUID)
|
|
1004
|
+
# - :ip [String] Public IP address
|
|
1005
|
+
# - :status [Symbol] VM status (:running, :pending, etc.)
|
|
1006
|
+
#
|
|
1007
|
+
# @note Reuses existing VMs from state file before provisioning new ones
|
|
1008
|
+
# @note Currently provisions VMs sequentially. Batching for count > 5 is TODO.
|
|
1009
|
+
def provision_vms(config, count)
|
|
1010
|
+
state_manager = get_state_manager
|
|
1011
|
+
existing_state = state_manager.read_state
|
|
1012
|
+
deployments = existing_state.fetch("deployments", {})
|
|
1013
|
+
|
|
1014
|
+
# Find existing VMs for this service
|
|
1015
|
+
existing_vms = deployments.select { |name, data|
|
|
1016
|
+
name.start_with?(config.service)
|
|
1017
|
+
}.map { |name, data|
|
|
1018
|
+
{
|
|
1019
|
+
id: data["vm_id"],
|
|
1020
|
+
ip: data["vm_ip"],
|
|
1021
|
+
status: :running,
|
|
1022
|
+
name: name
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if existing_vms.size >= count
|
|
1027
|
+
puts "Found #{existing_vms.size} existing VM(s), reusing #{count}"
|
|
1028
|
+
return existing_vms.first(count)
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
# Need to provision additional VMs
|
|
1032
|
+
needed = count - existing_vms.size
|
|
1033
|
+
provider = get_provider(config)
|
|
1034
|
+
new_vms = []
|
|
1035
|
+
|
|
1036
|
+
puts "Found #{existing_vms.size} existing VM(s), provisioning #{needed} more..." if existing_vms.any?
|
|
1037
|
+
|
|
1038
|
+
needed.times do |i|
|
|
1039
|
+
vm_index = existing_vms.size + i + 1
|
|
1040
|
+
vm_config = {
|
|
1041
|
+
zone: config.provider["zone"],
|
|
1042
|
+
plan: config.provider["plan"],
|
|
1043
|
+
title: "#{config.service}-vm-#{vm_index}",
|
|
1044
|
+
ssh_key: load_ssh_key
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
vm = provider.provision_vm(vm_config)
|
|
1048
|
+
new_vms << vm
|
|
1049
|
+
|
|
1050
|
+
print "."
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
puts # Newline after progress dots
|
|
1054
|
+
|
|
1055
|
+
# Return combination of existing and new VMs
|
|
1056
|
+
existing_vms + new_vms
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
# Load SSH public key for VM provisioning
|
|
1060
|
+
#
|
|
1061
|
+
# Reads SSH public key from configured location (configurable via ssh.key_path
|
|
1062
|
+
# in config/dev.yml, defaults to ~/.ssh/id_rsa.pub).
|
|
1063
|
+
# Key is injected into provisioned VMs for SSH access.
|
|
1064
|
+
#
|
|
1065
|
+
# @return [String] SSH public key content
|
|
1066
|
+
# @raise [RuntimeError] if SSH key file doesn't exist
|
|
1067
|
+
#
|
|
1068
|
+
# @note Key must be in OpenSSH format (starts with "ssh-rsa", "ssh-ed25519", etc.)
|
|
1069
|
+
def load_ssh_key
|
|
1070
|
+
ssh_key_path = File.expand_path(load_config.ssh_key_path)
|
|
1071
|
+
|
|
1072
|
+
unless File.exist?(ssh_key_path)
|
|
1073
|
+
puts "❌ SSH public key not found"
|
|
1074
|
+
puts
|
|
1075
|
+
puts "Expected location: #{ssh_key_path}"
|
|
1076
|
+
puts
|
|
1077
|
+
puts "To fix this issue, choose one of the following:"
|
|
1078
|
+
puts
|
|
1079
|
+
puts "Option 1: Generate a new SSH key pair"
|
|
1080
|
+
puts " ssh-keygen -t ed25519 -C \"kamal-dev@#{ENV["USER"]}\" -f ~/.ssh/id_rsa"
|
|
1081
|
+
puts " (Press Enter to accept defaults)"
|
|
1082
|
+
puts
|
|
1083
|
+
puts "Option 2: Use an existing SSH key"
|
|
1084
|
+
puts " Add to config/dev.yml:"
|
|
1085
|
+
puts " ssh:"
|
|
1086
|
+
puts " key_path: ~/.ssh/id_ed25519.pub # Path to your public key"
|
|
1087
|
+
puts
|
|
1088
|
+
puts "Option 3: Copy existing key to default location"
|
|
1089
|
+
puts " cp ~/.ssh/your_existing_key.pub ~/.ssh/id_rsa.pub"
|
|
1090
|
+
puts
|
|
1091
|
+
exit 1
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
File.read(ssh_key_path).strip
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
# Find next available index for container naming
|
|
1098
|
+
#
|
|
1099
|
+
# Scans existing deployments and determines the next sequential index
|
|
1100
|
+
# for container naming. Extracts numeric indices from container names
|
|
1101
|
+
# matching the pattern "{service}-{index}".
|
|
1102
|
+
#
|
|
1103
|
+
# @param deployments [Hash] Hash of existing deployments (name => deployment_data)
|
|
1104
|
+
# @param service [String] Service name from config
|
|
1105
|
+
# @return [Integer] Next available index (starts at 1 if no existing deployments)
|
|
1106
|
+
#
|
|
1107
|
+
# @example
|
|
1108
|
+
# # With existing deployments: myapp-1, myapp-2
|
|
1109
|
+
# find_next_index(deployments, "myapp") #=> 3
|
|
1110
|
+
#
|
|
1111
|
+
# # With no existing deployments
|
|
1112
|
+
# find_next_index({}, "myapp") #=> 1
|
|
1113
|
+
def find_next_index(deployments, service)
|
|
1114
|
+
indices = deployments.keys.map do |name|
|
|
1115
|
+
# Extract index from pattern like "service-1", "service-2"
|
|
1116
|
+
if name =~ /^#{Regexp.escape(service)}-(\d+)$/
|
|
1117
|
+
$1.to_i
|
|
1118
|
+
end
|
|
1119
|
+
end.compact
|
|
1120
|
+
|
|
1121
|
+
indices.empty? ? 1 : indices.max + 1
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# Print deployments in formatted table
|
|
1125
|
+
#
|
|
1126
|
+
# Displays deployment information in a human-readable table format.
|
|
1127
|
+
# For compose deployments, shows all containers in the stack.
|
|
1128
|
+
#
|
|
1129
|
+
# @param deployments [Hash] Hash of deployments (name => deployment_data)
|
|
1130
|
+
# @return [void]
|
|
1131
|
+
#
|
|
1132
|
+
# @example Single Container Output
|
|
1133
|
+
# NAME IP STATUS DEPLOYED AT
|
|
1134
|
+
# ----------------------------------------------------------------------
|
|
1135
|
+
# myapp-dev-1 1.2.3.4 running 2025-11-16T10:00:00Z
|
|
1136
|
+
#
|
|
1137
|
+
# @example Compose Stack Output
|
|
1138
|
+
# VM: myapp-1 IP: 1.2.3.4 DEPLOYED AT: 2025-11-16T10:00:00Z
|
|
1139
|
+
# ----------------------------------------------------------------------
|
|
1140
|
+
# ├─ app running ghcr.io/user/myapp:abc123
|
|
1141
|
+
# └─ postgres running postgres:16
|
|
1142
|
+
def print_table(deployments)
|
|
1143
|
+
state_manager = get_state_manager
|
|
1144
|
+
|
|
1145
|
+
deployments.each do |name, deployment|
|
|
1146
|
+
if state_manager.compose_deployment?(name)
|
|
1147
|
+
# Compose deployment - show VM header and containers
|
|
1148
|
+
puts ""
|
|
1149
|
+
puts "VM: #{name.ljust(17)} IP: #{deployment["vm_ip"].ljust(13)} DEPLOYED AT: #{deployment["deployed_at"]}"
|
|
1150
|
+
puts "-" * 80
|
|
1151
|
+
|
|
1152
|
+
containers = deployment["containers"]
|
|
1153
|
+
containers.each_with_index do |container, idx|
|
|
1154
|
+
prefix = (idx == containers.size - 1) ? " └─" : " ├─"
|
|
1155
|
+
status_indicator = (container["status"] == "running") ? "✓" : "✗"
|
|
1156
|
+
puts format(
|
|
1157
|
+
"%s %-15s %s %-13s %s",
|
|
1158
|
+
prefix,
|
|
1159
|
+
container["service"],
|
|
1160
|
+
status_indicator,
|
|
1161
|
+
container["status"],
|
|
1162
|
+
container["image"]
|
|
1163
|
+
)
|
|
1164
|
+
end
|
|
1165
|
+
else
|
|
1166
|
+
# Single container deployment - original format
|
|
1167
|
+
if deployments.values.none? { |d| d["type"] == "compose" }
|
|
1168
|
+
# Only show header once for single-container-only list
|
|
1169
|
+
if name == deployments.keys.first
|
|
1170
|
+
puts "NAME IP STATUS DEPLOYED AT"
|
|
1171
|
+
puts "-" * 80
|
|
1172
|
+
end
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
status = deployment["status"] || "unknown"
|
|
1176
|
+
container_name = deployment["container_name"] || name
|
|
1177
|
+
puts format(
|
|
1178
|
+
"%-20s %-15s %-15s %-20s",
|
|
1179
|
+
container_name,
|
|
1180
|
+
deployment["vm_ip"],
|
|
1181
|
+
status,
|
|
1182
|
+
deployment["deployed_at"]
|
|
1183
|
+
)
|
|
1184
|
+
end
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
puts "" if deployments.any?
|
|
1188
|
+
end
|
|
1189
|
+
end
|
|
1190
|
+
end
|
|
1191
|
+
end
|
|
1192
|
+
end
|