nvoi 0.1.5 → 0.1.7

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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/00-overview.md +171 -0
  3. data/.claude/todo/refactor/01-objects.md +96 -0
  4. data/.claude/todo/refactor/02-utils.md +143 -0
  5. data/.claude/todo/refactor/03-external-cloud.md +164 -0
  6. data/.claude/todo/refactor/04-external-dns.md +104 -0
  7. data/.claude/todo/refactor/05-external.md +133 -0
  8. data/.claude/todo/refactor/06-cli.md +123 -0
  9. data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
  10. data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
  11. data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
  12. data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
  13. data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
  14. data/.claude/todo/refactor/12-cli-db-command.md +128 -0
  15. data/.claude/todo/refactor/_target.md +79 -0
  16. data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
  17. data/.claude/todo/refactor-execution/01-objects.md +42 -0
  18. data/.claude/todo/refactor-execution/02-utils.md +41 -0
  19. data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
  20. data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
  21. data/.claude/todo/refactor-execution/05-external-other.md +46 -0
  22. data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
  23. data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
  24. data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
  25. data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
  26. data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
  27. data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
  28. data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
  29. data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
  30. data/.claude/todo/scaleway.impl.md +644 -0
  31. data/.claude/todo/scaleway.reference.md +520 -0
  32. data/.claude/todos.md +550 -0
  33. data/Gemfile +6 -0
  34. data/Gemfile.lock +46 -5
  35. data/Rakefile +1 -1
  36. data/doc/config-schema.yaml +44 -11
  37. data/examples/golang/deploy.enc +0 -0
  38. data/examples/golang/main.go +18 -0
  39. data/exe/nvoi +3 -1
  40. data/ingest +0 -0
  41. data/lib/nvoi/cli/config/command.rb +219 -0
  42. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  43. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  44. data/lib/nvoi/cli/db/command.rb +308 -0
  45. data/lib/nvoi/cli/delete/command.rb +75 -0
  46. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  47. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +50 -0
  48. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  49. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  50. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  51. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  52. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  53. data/lib/nvoi/cli/deploy/command.rb +184 -0
  54. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  55. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  56. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +102 -0
  57. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -0
  58. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  59. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  60. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  61. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +490 -0
  62. data/lib/nvoi/cli/exec/command.rb +173 -0
  63. data/lib/nvoi/cli/logs/command.rb +66 -0
  64. data/lib/nvoi/cli/onboard/command.rb +761 -0
  65. data/lib/nvoi/cli/unlock/command.rb +72 -0
  66. data/lib/nvoi/cli.rb +339 -141
  67. data/lib/nvoi/config_api/actions/app.rb +53 -0
  68. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  69. data/lib/nvoi/config_api/actions/database.rb +70 -0
  70. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  71. data/lib/nvoi/config_api/actions/env.rb +32 -0
  72. data/lib/nvoi/config_api/actions/init.rb +67 -0
  73. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  74. data/lib/nvoi/config_api/actions/server.rb +66 -0
  75. data/lib/nvoi/config_api/actions/service.rb +52 -0
  76. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  77. data/lib/nvoi/config_api/base.rb +38 -0
  78. data/lib/nvoi/config_api/result.rb +26 -0
  79. data/lib/nvoi/config_api.rb +93 -0
  80. data/lib/nvoi/errors.rb +68 -50
  81. data/lib/nvoi/external/cloud/aws.rb +450 -0
  82. data/lib/nvoi/external/cloud/base.rb +99 -0
  83. data/lib/nvoi/external/cloud/factory.rb +48 -0
  84. data/lib/nvoi/external/cloud/hetzner.rb +402 -0
  85. data/lib/nvoi/external/cloud/scaleway.rb +559 -0
  86. data/lib/nvoi/external/cloud.rb +15 -0
  87. data/lib/nvoi/external/containerd.rb +86 -0
  88. data/lib/nvoi/external/database/mysql.rb +84 -0
  89. data/lib/nvoi/external/database/postgres.rb +82 -0
  90. data/lib/nvoi/external/database/provider.rb +65 -0
  91. data/lib/nvoi/external/database/sqlite.rb +72 -0
  92. data/lib/nvoi/external/database.rb +22 -0
  93. data/lib/nvoi/external/dns/cloudflare.rb +310 -0
  94. data/lib/nvoi/external/kubectl.rb +65 -0
  95. data/lib/nvoi/external/ssh.rb +106 -0
  96. data/lib/nvoi/objects/config_override.rb +60 -0
  97. data/lib/nvoi/objects/configuration.rb +483 -0
  98. data/lib/nvoi/objects/database.rb +56 -0
  99. data/lib/nvoi/objects/dns.rb +14 -0
  100. data/lib/nvoi/objects/firewall.rb +11 -0
  101. data/lib/nvoi/objects/network.rb +11 -0
  102. data/lib/nvoi/objects/server.rb +14 -0
  103. data/lib/nvoi/objects/service_spec.rb +26 -0
  104. data/lib/nvoi/objects/tunnel.rb +14 -0
  105. data/lib/nvoi/objects/volume.rb +17 -0
  106. data/lib/nvoi/utils/config_loader.rb +172 -0
  107. data/lib/nvoi/utils/constants.rb +61 -0
  108. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  109. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  110. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  111. data/lib/nvoi/utils/logger.rb +84 -0
  112. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +37 -25
  113. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  114. data/lib/nvoi/utils/templates.rb +62 -0
  115. data/lib/nvoi/version.rb +1 -1
  116. data/lib/nvoi.rb +27 -55
  117. data/templates/app-ingress.yaml.erb +3 -1
  118. data/templates/error-backend.yaml.erb +134 -0
  119. metadata +121 -44
  120. data/examples/golang/deploy.yml +0 -54
  121. data/lib/nvoi/cloudflare/client.rb +0 -287
  122. data/lib/nvoi/config/config.rb +0 -248
  123. data/lib/nvoi/config/loader.rb +0 -102
  124. data/lib/nvoi/config/ssh_keys.rb +0 -82
  125. data/lib/nvoi/config/types.rb +0 -274
  126. data/lib/nvoi/constants.rb +0 -59
  127. data/lib/nvoi/credentials/editor.rb +0 -272
  128. data/lib/nvoi/deployer/cleaner.rb +0 -36
  129. data/lib/nvoi/deployer/image_builder.rb +0 -23
  130. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  131. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  132. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  133. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  134. data/lib/nvoi/deployer/types.rb +0 -8
  135. data/lib/nvoi/k8s/renderer.rb +0 -44
  136. data/lib/nvoi/k8s/templates.rb +0 -29
  137. data/lib/nvoi/logger.rb +0 -72
  138. data/lib/nvoi/providers/aws.rb +0 -403
  139. data/lib/nvoi/providers/base.rb +0 -111
  140. data/lib/nvoi/providers/hetzner.rb +0 -288
  141. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  142. data/lib/nvoi/remote/docker_manager.rb +0 -203
  143. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  144. data/lib/nvoi/remote/volume_manager.rb +0 -103
  145. data/lib/nvoi/service/delete.rb +0 -234
  146. data/lib/nvoi/service/deploy.rb +0 -80
  147. data/lib/nvoi/service/exec.rb +0 -144
  148. data/lib/nvoi/service/provider.rb +0 -36
  149. data/lib/nvoi/steps/application_deployer.rb +0 -26
  150. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  151. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  152. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  153. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  154. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  155. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  156. data/lib/nvoi/steps/volume_provisioner.rb +0 -154
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Deploy
6
+ module Steps
7
+ # DeployService handles K8s deployment of all services
8
+ class DeployService
9
+ DEFAULT_RESOURCES = {
10
+ request_memory: "128Mi",
11
+ request_cpu: "100m",
12
+ limit_memory: "512Mi",
13
+ limit_cpu: "500m"
14
+ }.freeze
15
+
16
+ def initialize(config, ssh, tunnels, log)
17
+ @config = config
18
+ @ssh = ssh
19
+ @tunnels = tunnels
20
+ @log = log
21
+ @namer = config.namer
22
+ @kubectl = External::Kubectl.new(ssh)
23
+ end
24
+
25
+ def run(image_tag, timestamp)
26
+ # Push to in-cluster registry
27
+ registry_tag = "localhost:#{Utils::Constants::REGISTRY_PORT}/#{@config.container_prefix}:#{timestamp}"
28
+ push_to_registry(image_tag, registry_tag)
29
+
30
+ # Gather env vars
31
+ first_service = @config.deploy.application.app.keys.first
32
+ all_env = @config.env_for_service(first_service)
33
+
34
+ # Deploy app secret
35
+ deploy_app_secret(all_env)
36
+
37
+ # Deploy database if configured
38
+ db_config = @config.deploy.application.database
39
+ if db_config
40
+ db_spec = db_config.to_service_spec(@namer)
41
+ deploy_database(db_spec) if db_spec
42
+ end
43
+
44
+ # Deploy additional services
45
+ @config.deploy.application.services.each do |service_name, service_config|
46
+ service_spec = service_config.to_service_spec(@config.deploy.application.name, service_name)
47
+ deploy_service(service_name, service_spec)
48
+ end
49
+
50
+ # Deploy app services
51
+ @config.deploy.application.app.each do |service_name, service_config|
52
+ service_env = @config.env_for_service(service_name)
53
+ deploy_app_service(service_name, service_config, registry_tag, service_env)
54
+
55
+ # Deploy cloudflared for services with tunnels
56
+ tunnel = @tunnels.find { |t| t.service_name == service_name }
57
+ if tunnel
58
+ deploy_cloudflared(service_name, tunnel.tunnel_token)
59
+ verify_traffic_switchover(service_config)
60
+ end
61
+ end
62
+
63
+ @log.success "All services deployed"
64
+ end
65
+
66
+ private
67
+
68
+ def push_to_registry(local_tag, registry_tag)
69
+ @log.info "Pushing to in-cluster registry: %s", registry_tag
70
+
71
+ @ssh.execute("sudo ctr -n k8s.io images tag #{local_tag} #{registry_tag}")
72
+ @ssh.execute("sudo ctr -n k8s.io images push --plain-http #{registry_tag}")
73
+
74
+ @log.success "Image pushed to registry"
75
+ end
76
+
77
+ def deploy_app_secret(env_vars)
78
+ secret_name = @namer.app_secret_name
79
+
80
+ @log.info "Deploying app secret: %s", secret_name
81
+
82
+ Utils::Templates.apply_manifest(@ssh, "app-secret.yaml", {
83
+ name: secret_name,
84
+ env_vars:
85
+ })
86
+
87
+ @log.success "App secret deployed"
88
+ end
89
+
90
+ def deploy_database(db_spec)
91
+ @log.info "Deploying database: %s", db_spec.name
92
+
93
+ data = {
94
+ service_name: db_spec.name,
95
+ adapter: @config.deploy.application.database.adapter,
96
+ image: db_spec.image,
97
+ port: db_spec.port,
98
+ secret_name: @namer.database_secret_name,
99
+ secret_keys: db_spec.secrets.keys.sort,
100
+ data_path: "/var/lib/postgresql/data",
101
+ storage_size: "10Gi",
102
+ affinity_server_names: db_spec.servers,
103
+ host_path: nil
104
+ }
105
+
106
+ # Use hostPath for database volume if configured
107
+ db_mount = @config.deploy.application.database.mount
108
+ if db_mount && !db_mount.empty?
109
+ server_name = db_spec.servers.first
110
+ vol_name = db_mount.keys.first
111
+ data[:host_path] = @namer.server_volume_host_path(server_name, vol_name)
112
+ end
113
+
114
+ # Create database secret
115
+ Utils::Templates.apply_manifest(@ssh, "app-secret.yaml", {
116
+ name: @namer.database_secret_name,
117
+ env_vars: db_spec.secrets
118
+ })
119
+
120
+ # Deploy StatefulSet
121
+ Utils::Templates.apply_manifest(@ssh, "db-statefulset.yaml", data)
122
+
123
+ # Wait for database to be ready
124
+ @log.info "Waiting for database to be ready..."
125
+ @kubectl.wait_for_statefulset(db_spec.name)
126
+
127
+ @log.success "Database deployed: %s", db_spec.name
128
+ end
129
+
130
+ def deploy_service(service_name, service_spec)
131
+ @log.info "Deploying service: %s", service_spec.name
132
+
133
+ host_path = nil
134
+ volume_path = nil
135
+ if service_spec.mounts && !service_spec.mounts.empty?
136
+ server_name = service_spec.servers.first
137
+ vol_name, mount_path = service_spec.mounts.first
138
+ host_path = @namer.server_volume_host_path(server_name, vol_name)
139
+ volume_path = mount_path
140
+ end
141
+
142
+ data = {
143
+ name: service_spec.name,
144
+ image: service_spec.image,
145
+ port: service_spec.port,
146
+ command: service_spec.command,
147
+ env_vars: service_spec.env,
148
+ env_keys: service_spec.env.keys.sort,
149
+ volume_path:,
150
+ host_path:,
151
+ affinity_server_names: service_spec.servers
152
+ }
153
+
154
+ Utils::Templates.apply_manifest(@ssh, "service-deployment.yaml", data)
155
+
156
+ @log.success "Service deployed: %s", service_spec.name
157
+ end
158
+
159
+ def deploy_app_service(service_name, service_config, image_tag, env)
160
+ deployment_name = @namer.app_deployment_name(service_name)
161
+ @log.info "Deploying app service: %s", deployment_name
162
+
163
+ has_port = service_config.port && service_config.port.positive?
164
+ template = has_port ? "app-deployment.yaml" : "worker-deployment.yaml"
165
+
166
+ # Build probes
167
+ readiness_probe = nil
168
+ liveness_probe = nil
169
+
170
+ if service_config.healthcheck && has_port
171
+ hc = service_config.healthcheck
172
+ readiness_probe = {
173
+ path: hc.path || "/health",
174
+ port: hc.port || service_config.port,
175
+ initial_delay: 10,
176
+ period: 10,
177
+ timeout: 5,
178
+ failure_threshold: 3
179
+ }
180
+ liveness_probe = readiness_probe.merge(initial_delay: 30)
181
+ end
182
+
183
+ data = {
184
+ name: deployment_name,
185
+ image: image_tag,
186
+ replicas: has_port ? 2 : 1,
187
+ port: service_config.port,
188
+ command: service_config.command&.split || [],
189
+ secret_name: @namer.app_secret_name,
190
+ env_keys: env.keys.sort,
191
+ affinity_server_names: service_config.servers,
192
+ resources: DEFAULT_RESOURCES,
193
+ readiness_probe:,
194
+ liveness_probe:,
195
+ volume_mounts: [],
196
+ host_path_volumes: [],
197
+ volumes: []
198
+ }
199
+
200
+ # Collect all mounts: explicit app mounts + sqlite3 database mount
201
+ all_mounts = (service_config.mounts || {}).dup
202
+
203
+ # For sqlite3, add database.mount to app services
204
+ db = @config.deploy.application.database
205
+ if db&.adapter&.downcase&.start_with?("sqlite") && db.mount && !db.mount.empty?
206
+ db.mount.each { |k, v| all_mounts[k] ||= v }
207
+ end
208
+
209
+ # Add mounts if configured
210
+ if !all_mounts.empty?
211
+ if service_config.servers.length > 1
212
+ raise Errors::DeploymentError.new(
213
+ "validation",
214
+ "app '#{service_name}' runs on multiple servers #{service_config.servers} " \
215
+ "and cannot have mounts. Volumes are server-local and would cause data inconsistency."
216
+ )
217
+ end
218
+
219
+ server_name = service_config.servers.first
220
+ server_config = @config.deploy.application.servers[server_name]
221
+
222
+ all_mounts.each do |vol_name, mount_path|
223
+ unless server_config&.volumes&.key?(vol_name)
224
+ available = server_config&.volumes&.keys&.join(", ") || "none"
225
+ raise Errors::DeploymentError.new(
226
+ "validation",
227
+ "app '#{service_name}' mounts '#{vol_name}' but server '#{server_name}' " \
228
+ "has no volume named '#{vol_name}'. Available: #{available}"
229
+ )
230
+ end
231
+
232
+ host_path = @namer.server_volume_host_path(server_name, vol_name)
233
+ data[:volume_mounts] << { name: vol_name, mount_path: }
234
+ data[:host_path_volumes] << { name: vol_name, host_path: }
235
+ end
236
+ end
237
+
238
+ Utils::Templates.apply_manifest(@ssh, template, data)
239
+
240
+ # Deploy service if it has a port
241
+ if has_port
242
+ Utils::Templates.apply_manifest(@ssh, "app-service.yaml", {
243
+ name: deployment_name,
244
+ port: service_config.port
245
+ })
246
+ end
247
+
248
+ # Deploy ingress if domain is specified
249
+ if service_config.domain && !service_config.domain.empty?
250
+ hostnames = Utils::Namer.build_hostnames(service_config.subdomain, service_config.domain)
251
+
252
+ Utils::Templates.apply_manifest(@ssh, "app-ingress.yaml", {
253
+ name: deployment_name,
254
+ domains: hostnames,
255
+ port: service_config.port
256
+ })
257
+ end
258
+
259
+ # Wait for deployment
260
+ @log.info "Waiting for deployment to be ready..."
261
+ @kubectl.wait_for_deployment(deployment_name)
262
+
263
+ # Run pre-run command if specified
264
+ if service_config.pre_run_command && !service_config.pre_run_command.empty?
265
+ run_pre_run_command(service_name, service_config.pre_run_command)
266
+ end
267
+
268
+ @log.success "App service deployed: %s", deployment_name
269
+ end
270
+
271
+ def deploy_cloudflared(service_name, tunnel_token)
272
+ deployment_name = @namer.cloudflared_deployment_name(service_name)
273
+ @log.info "Deploying cloudflared: %s", deployment_name
274
+
275
+ manifest = <<~YAML
276
+ apiVersion: apps/v1
277
+ kind: Deployment
278
+ metadata:
279
+ name: #{deployment_name}
280
+ namespace: default
281
+ spec:
282
+ replicas: 1
283
+ selector:
284
+ matchLabels:
285
+ app: #{deployment_name}
286
+ template:
287
+ metadata:
288
+ labels:
289
+ app: #{deployment_name}
290
+ spec:
291
+ containers:
292
+ - name: cloudflared
293
+ image: cloudflare/cloudflared:latest
294
+ args:
295
+ - tunnel
296
+ - run
297
+ - --token
298
+ - #{tunnel_token}
299
+ YAML
300
+
301
+ @ssh.execute("cat <<'EOF' | kubectl apply -f -\n#{manifest}\nEOF")
302
+
303
+ @log.success "Cloudflared deployed: %s", deployment_name
304
+ end
305
+
306
+ def verify_traffic_switchover(service_config)
307
+ return unless service_config.domain && !service_config.domain.empty?
308
+
309
+ hostname = Utils::Namer.build_hostname(service_config.subdomain, service_config.domain)
310
+
311
+ health_path = service_config.healthcheck&.path || "/"
312
+ public_url = "https://#{hostname}#{health_path}"
313
+
314
+ @log.info "Verifying public traffic routing"
315
+ @log.info "Testing: %s", public_url
316
+
317
+ consecutive_success = 0
318
+ required_consecutive = Utils::Constants::TRAFFIC_VERIFY_CONSECUTIVE
319
+ max_attempts = Utils::Constants::TRAFFIC_VERIFY_ATTEMPTS
320
+
321
+ max_attempts.times do |attempt|
322
+ begin
323
+ result = check_public_url(public_url)
324
+
325
+ if result[:success]
326
+ if consecutive_success == 0
327
+ @log.info "[%d/%d] App responding, verifying stability...", attempt + 1, max_attempts
328
+ end
329
+ consecutive_success += 1
330
+ @log.success "[%d/%d] Public URL responding: %s", consecutive_success, required_consecutive, result[:http_code]
331
+
332
+ if consecutive_success >= required_consecutive
333
+ @log.success "Traffic switchover verified: public URL accessible"
334
+ return
335
+ end
336
+ else
337
+ if consecutive_success > 0
338
+ @log.warning "Success streak broken at %d, restarting count", consecutive_success
339
+ end
340
+ consecutive_success = 0
341
+ @log.info "[%d/%d] %s", attempt + 1, max_attempts, result[:message]
342
+ end
343
+ rescue Errors::SshCommandError
344
+ consecutive_success = 0
345
+ @log.info "[%d/%d] Public URL check failed", attempt + 1, max_attempts
346
+ end
347
+
348
+ sleep(Utils::Constants::TRAFFIC_VERIFY_INTERVAL)
349
+ end
350
+
351
+ raise Errors::DeploymentError.new(
352
+ "traffic_verification",
353
+ "public URL verification failed after #{max_attempts} attempts. Cloudflare tunnel may not be routing correctly."
354
+ )
355
+ end
356
+
357
+ def check_public_url(url)
358
+ curl_cmd = "curl -si -m 10 '#{url}' 2>/dev/null"
359
+ output = @ssh.execute(curl_cmd).strip
360
+
361
+ http_code = output.lines.first&.match(/HTTP\/[\d.]+ (\d+)/)&.captures&.first || "000"
362
+ has_error_header = output.lines.any? { |line| line.downcase.start_with?("x-nvoi-error:") }
363
+
364
+ if http_code == "200" && !has_error_header
365
+ { success: true, http_code:, message: "OK" }
366
+ elsif has_error_header
367
+ { success: false, http_code:, message: "Error backend responding (X-Nvoi-Error header present) - app is down" }
368
+ else
369
+ { success: false, http_code:, message: "HTTP #{http_code} (expected: 200)" }
370
+ end
371
+ end
372
+
373
+ def run_pre_run_command(service_name, command)
374
+ @log.info "Running pre-run command: %s", command
375
+
376
+ pod_label = @namer.app_pod_label(service_name)
377
+ pod_name = @ssh.execute("kubectl get pod -l #{pod_label} -o jsonpath='{.items[0].metadata.name}'")
378
+ pod_name = pod_name.strip.delete("'")
379
+
380
+ escaped_command = command.gsub("'", "'\"'\"'")
381
+ exec_cmd = "kubectl exec #{pod_name} -- sh -c '#{escaped_command}'"
382
+
383
+ begin
384
+ output = @ssh.execute(exec_cmd)
385
+ @log.info "Pre-run command output:\n%s", output unless output.empty?
386
+ rescue Errors::SshCommandError => e
387
+ @log.error "Pre-run command failed: %s", e.message
388
+
389
+ logs = @ssh.execute("kubectl logs #{pod_name} --tail=50")
390
+ @log.error "Pod logs:\n%s", logs
391
+
392
+ raise Errors::DeploymentError.new("pre_run_command", "deployment aborted: pre-run command failed: #{e.message}")
393
+ end
394
+ end
395
+ end
396
+ end
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Deploy
6
+ module Steps
7
+ # ProvisionNetwork handles network and firewall provisioning
8
+ class ProvisionNetwork
9
+ def initialize(config, provider, log)
10
+ @config = config
11
+ @provider = provider
12
+ @log = log
13
+ end
14
+
15
+ def run
16
+ @log.info "Provisioning network infrastructure"
17
+
18
+ network = provision_network
19
+ firewall = provision_firewall
20
+
21
+ @log.success "Network infrastructure ready"
22
+ [network, firewall]
23
+ end
24
+
25
+ private
26
+
27
+ def provision_network
28
+ @log.info "Provisioning network: %s", @config.network_name
29
+ network = @provider.find_or_create_network(@config.network_name)
30
+ @log.success "Network ready: %s", network.id
31
+ network
32
+ end
33
+
34
+ def provision_firewall
35
+ @log.info "Provisioning firewall: %s", @config.firewall_name
36
+ firewall = @provider.find_or_create_firewall(@config.firewall_name)
37
+ @log.success "Firewall ready: %s", firewall.id
38
+ firewall
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Deploy
6
+ module Steps
7
+ # ProvisionServer handles compute server provisioning
8
+ class ProvisionServer
9
+ def initialize(config, provider, log, network, firewall)
10
+ @config = config
11
+ @provider = provider
12
+ @log = log
13
+ @network = network
14
+ @firewall = firewall
15
+ end
16
+
17
+ def run
18
+ @log.info "Provisioning servers"
19
+
20
+ servers = @config.deploy.application.servers
21
+ main_server_ip = nil
22
+
23
+ servers.each do |group_name, group_config|
24
+ count = group_config&.count&.positive? ? group_config.count : 1
25
+
26
+ (1..count).each do |i|
27
+ server_name = @config.namer.server_name(group_name, i)
28
+ server = provision_server(server_name, group_config)
29
+
30
+ # Track main server IP (first master, or just first server)
31
+ main_server_ip ||= server.public_ipv4 if group_config&.master || i == 1
32
+ end
33
+ end
34
+
35
+ @log.success "All servers provisioned"
36
+ main_server_ip
37
+ end
38
+
39
+ private
40
+
41
+ def provision_server(name, server_config)
42
+ @log.info "Provisioning server: %s", name
43
+
44
+ # Check if server already exists
45
+ existing = @provider.find_server(name)
46
+ if existing
47
+ @log.info "Server already exists: %s (%s)", name, existing.public_ipv4
48
+ return existing
49
+ end
50
+
51
+ # Determine server type and location
52
+ server_type = server_config&.type
53
+ location = server_config&.location
54
+ image = "ubuntu-22.04"
55
+
56
+ case @config.provider_name
57
+ when "hetzner"
58
+ h = @config.hetzner
59
+ server_type ||= h.server_type
60
+ location ||= h.server_location
61
+ when "aws"
62
+ a = @config.aws
63
+ server_type ||= a.instance_type
64
+ location ||= a.region
65
+ when "scaleway"
66
+ s = @config.scaleway
67
+ server_type ||= s.server_type
68
+ location ||= s.zone
69
+ end
70
+
71
+ # Create cloud-init user data
72
+ user_data = generate_user_data
73
+
74
+ opts = Objects::Server::CreateOptions.new(
75
+ name:,
76
+ type: server_type,
77
+ image:,
78
+ location:,
79
+ user_data:,
80
+ network_id: @network.id,
81
+ firewall_id: @firewall.id
82
+ )
83
+
84
+ server = @provider.create_server(opts)
85
+ @log.info "Server created: %s (waiting for ready...)", server.id
86
+
87
+ # Wait for server to be running
88
+ server = @provider.wait_for_server(server.id, Utils::Constants::SERVER_READY_MAX_ATTEMPTS)
89
+ @log.success "Server ready: %s (%s)", name, server.public_ipv4
90
+
91
+ # Wait for SSH to be available
92
+ wait_for_ssh(server.public_ipv4)
93
+
94
+ server
95
+ end
96
+
97
+ def generate_user_data
98
+ ssh_key = @config.ssh_public_key
99
+
100
+ <<~CLOUD_INIT
101
+ #cloud-config
102
+ users:
103
+ - name: deploy
104
+ groups: sudo, docker
105
+ shell: /bin/bash
106
+ sudo: ALL=(ALL) NOPASSWD:ALL
107
+ ssh_authorized_keys:
108
+ - #{ssh_key}
109
+ package_update: true
110
+ package_upgrade: true
111
+ packages:
112
+ - curl
113
+ - git
114
+ - jq
115
+ - rsync
116
+ CLOUD_INIT
117
+ end
118
+
119
+ def wait_for_ssh(ip)
120
+ @log.info "Waiting for SSH on %s...", ip
121
+ ssh = External::Ssh.new(ip, @config.ssh_key_path)
122
+
123
+ Utils::Constants::SSH_READY_MAX_ATTEMPTS.times do |_i|
124
+ begin
125
+ output = ssh.execute("echo 'ready'")
126
+ if output.strip == "ready"
127
+ @log.success "SSH ready"
128
+ return
129
+ end
130
+ rescue Errors::SshCommandError
131
+ # SSH not ready yet
132
+ end
133
+
134
+ sleep(Utils::Constants::SSH_READY_INTERVAL)
135
+ end
136
+
137
+ raise Errors::SshConnectionError, "SSH connection failed after #{Utils::Constants::SSH_READY_MAX_ATTEMPTS} attempts"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end