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