nvoi 0.1.6 → 0.1.8

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/12-cli-db-command.md +128 -0
  3. data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
  4. data/.claude/todo/refactor-execution/01-objects.md +42 -0
  5. data/.claude/todo/refactor-execution/02-utils.md +41 -0
  6. data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
  7. data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
  8. data/.claude/todo/refactor-execution/05-external-other.md +46 -0
  9. data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
  10. data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
  11. data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
  12. data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
  13. data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
  14. data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
  15. data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
  16. data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
  17. data/.claude/todos/buckets.md +41 -0
  18. data/.claude/todos.md +550 -0
  19. data/Gemfile +5 -0
  20. data/Gemfile.lock +35 -4
  21. data/Rakefile +1 -1
  22. data/ingest +0 -0
  23. data/lib/nvoi/cli/config/command.rb +219 -0
  24. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +12 -11
  25. data/lib/nvoi/cli/deploy/command.rb +27 -11
  26. data/lib/nvoi/cli/deploy/steps/build_image.rb +48 -6
  27. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +15 -13
  28. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +8 -15
  29. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +10 -1
  30. data/lib/nvoi/cli/logs/command.rb +66 -0
  31. data/lib/nvoi/cli/onboard/command.rb +761 -0
  32. data/lib/nvoi/cli/unlock/command.rb +72 -0
  33. data/lib/nvoi/cli.rb +257 -0
  34. data/lib/nvoi/config_api/actions/app.rb +30 -30
  35. data/lib/nvoi/config_api/actions/compute_provider.rb +31 -31
  36. data/lib/nvoi/config_api/actions/database.rb +42 -42
  37. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  38. data/lib/nvoi/config_api/actions/env.rb +12 -12
  39. data/lib/nvoi/config_api/actions/init.rb +67 -0
  40. data/lib/nvoi/config_api/actions/secret.rb +12 -12
  41. data/lib/nvoi/config_api/actions/server.rb +35 -35
  42. data/lib/nvoi/config_api/actions/service.rb +52 -0
  43. data/lib/nvoi/config_api/actions/volume.rb +18 -18
  44. data/lib/nvoi/config_api/base.rb +15 -21
  45. data/lib/nvoi/config_api/result.rb +5 -5
  46. data/lib/nvoi/config_api.rb +51 -28
  47. data/lib/nvoi/external/cloud/aws.rb +26 -1
  48. data/lib/nvoi/external/cloud/hetzner.rb +27 -1
  49. data/lib/nvoi/external/cloud/scaleway.rb +32 -6
  50. data/lib/nvoi/external/containerd.rb +1 -44
  51. data/lib/nvoi/external/dns/cloudflare.rb +34 -16
  52. data/lib/nvoi/external/ssh.rb +0 -12
  53. data/lib/nvoi/external/ssh_tunnel.rb +100 -0
  54. data/lib/nvoi/objects/configuration.rb +20 -0
  55. data/lib/nvoi/utils/namer.rb +9 -0
  56. data/lib/nvoi/utils/retry.rb +1 -1
  57. data/lib/nvoi/version.rb +1 -1
  58. data/lib/nvoi.rb +16 -0
  59. data/templates/app-ingress.yaml.erb +3 -1
  60. metadata +27 -1
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Config
6
+ # Command helper for all config operations
7
+ # Uses CredentialStore for crypto, ConfigApi for transformations
8
+ class Command
9
+ def initialize(options)
10
+ @options = options
11
+ @working_dir = options[:dir] || "."
12
+ end
13
+
14
+ # Initialize new config
15
+ def init(name, environment)
16
+ result = ConfigApi.init(name:, environment:)
17
+
18
+ if result.failure?
19
+ error("Failed to initialize: #{result.error_message}")
20
+ return
21
+ end
22
+
23
+ # Write encrypted config
24
+ config_path = File.join(@working_dir, Utils::DEFAULT_ENCRYPTED_FILE)
25
+ key_path = File.join(@working_dir, Utils::DEFAULT_KEY_FILE)
26
+
27
+ File.binwrite(config_path, result.config)
28
+ File.write(key_path, "#{result.master_key}\n", perm: 0o600)
29
+
30
+ update_gitignore
31
+
32
+ success("Created #{Utils::DEFAULT_ENCRYPTED_FILE}")
33
+ success("Created #{Utils::DEFAULT_KEY_FILE} (keep safe, never commit)")
34
+ puts
35
+ puts "Next steps:"
36
+ puts " nvoi config domain set cloudflare --api-token=TOKEN --account-id=ID"
37
+ puts " nvoi config provider set hetzner --api-token=TOKEN --server-type=cx22 --location=fsn1"
38
+ puts " nvoi config server set web --master"
39
+ end
40
+
41
+ # Domain provider
42
+ def domain_set(provider, api_token:, account_id:)
43
+ with_config do |data|
44
+ ConfigApi.set_domain_provider(data, provider:, api_token:, account_id:)
45
+ end
46
+ end
47
+
48
+ def domain_rm
49
+ with_config do |data|
50
+ ConfigApi.delete_domain_provider(data)
51
+ end
52
+ end
53
+
54
+ # Compute provider
55
+ def provider_set(provider, **opts)
56
+ with_config do |data|
57
+ ConfigApi.set_compute_provider(data, provider:, **opts)
58
+ end
59
+ end
60
+
61
+ def provider_rm
62
+ with_config do |data|
63
+ ConfigApi.delete_compute_provider(data)
64
+ end
65
+ end
66
+
67
+ # Server
68
+ def server_set(name, master: false, type: nil, location: nil, count: 1)
69
+ with_config do |data|
70
+ ConfigApi.set_server(data, name:, master:, type:, location:, count:)
71
+ end
72
+ end
73
+
74
+ def server_rm(name)
75
+ with_config do |data|
76
+ ConfigApi.delete_server(data, name:)
77
+ end
78
+ end
79
+
80
+ # Volume
81
+ def volume_set(server, name, size: 10)
82
+ with_config do |data|
83
+ ConfigApi.set_volume(data, server:, name:, size:)
84
+ end
85
+ end
86
+
87
+ def volume_rm(server, name)
88
+ with_config do |data|
89
+ ConfigApi.delete_volume(data, server:, name:)
90
+ end
91
+ end
92
+
93
+ # App
94
+ def app_set(name, servers:, **opts)
95
+ with_config do |data|
96
+ ConfigApi.set_app(data, name:, servers:, **opts)
97
+ end
98
+ end
99
+
100
+ def app_rm(name)
101
+ with_config do |data|
102
+ ConfigApi.delete_app(data, name:)
103
+ end
104
+ end
105
+
106
+ # Database
107
+ def database_set(servers:, adapter:, **opts)
108
+ with_config do |data|
109
+ ConfigApi.set_database(data, servers:, adapter:, **opts)
110
+ end
111
+ end
112
+
113
+ def database_rm
114
+ with_config do |data|
115
+ ConfigApi.delete_database(data)
116
+ end
117
+ end
118
+
119
+ # Service
120
+ def service_set(name, servers:, image:, **opts)
121
+ with_config do |data|
122
+ ConfigApi.set_service(data, name:, servers:, image:, **opts)
123
+ end
124
+ end
125
+
126
+ def service_rm(name)
127
+ with_config do |data|
128
+ ConfigApi.delete_service(data, name:)
129
+ end
130
+ end
131
+
132
+ # Secret
133
+ def secret_set(key_name, value)
134
+ with_config do |data|
135
+ ConfigApi.set_secret(data, key: key_name, value:)
136
+ end
137
+ end
138
+
139
+ def secret_rm(key_name)
140
+ with_config do |data|
141
+ ConfigApi.delete_secret(data, key: key_name)
142
+ end
143
+ end
144
+
145
+ # Env
146
+ def env_set(key_name, value)
147
+ with_config do |data|
148
+ ConfigApi.set_env(data, key: key_name, value:)
149
+ end
150
+ end
151
+
152
+ def env_rm(key_name)
153
+ with_config do |data|
154
+ ConfigApi.delete_env(data, key: key_name)
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def with_config
161
+ store = Utils::CredentialStore.new(
162
+ @working_dir,
163
+ @options[:credentials],
164
+ @options[:master_key]
165
+ )
166
+
167
+ unless store.exists?
168
+ error("Config not found: #{store.encrypted_path}")
169
+ error("Run 'nvoi config init --name=myapp' first")
170
+ return
171
+ end
172
+
173
+ # Read and parse
174
+ yaml = store.read
175
+ data = YAML.safe_load(yaml, permitted_classes: [Symbol])
176
+
177
+ # Transform
178
+ result = yield(data)
179
+
180
+ if result.failure?
181
+ error("#{result.error_type}: #{result.error_message}")
182
+ else
183
+ # Serialize and write
184
+ store.write(YAML.dump(result.data))
185
+ success("Config updated")
186
+ end
187
+ rescue Errors::CredentialError => e
188
+ error(e.message)
189
+ rescue Errors::DecryptionError => e
190
+ error("Decryption failed: #{e.message}")
191
+ end
192
+
193
+ def update_gitignore
194
+ gitignore_path = File.join(@working_dir, ".gitignore")
195
+ entries = ["deploy.key", ".env", ".env.*", "!.env.example", "!.env.*.example"]
196
+
197
+ existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
198
+ additions = entries.reject { |e| existing.include?(e) }
199
+
200
+ return if additions.empty?
201
+
202
+ File.open(gitignore_path, "a") do |f|
203
+ f.puts "" unless existing.end_with?("\n") || existing.empty?
204
+ f.puts "# NVOI - sensitive files"
205
+ additions.each { |e| f.puts e }
206
+ end
207
+ end
208
+
209
+ def success(msg)
210
+ puts "\e[32m✓\e[0m #{msg}"
211
+ end
212
+
213
+ def error(msg)
214
+ warn "\e[31m✗\e[0m #{msg}"
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -15,18 +15,15 @@ module Nvoi
15
15
  def run
16
16
  @config.deploy.application.app.each do |_service_name, service|
17
17
  next unless service&.domain && !service.domain.empty?
18
- next if service.subdomain.nil?
19
18
 
20
- delete_dns_record(service.domain, service.subdomain)
19
+ delete_dns_records(service.domain, service.subdomain)
21
20
  end
22
21
  end
23
22
 
24
23
  private
25
24
 
26
- def delete_dns_record(domain, subdomain)
27
- hostname = Utils::Namer.build_hostname(subdomain, domain)
28
-
29
- @log.info "Deleting DNS record: %s", hostname
25
+ def delete_dns_records(domain, subdomain)
26
+ hostnames = Utils::Namer.build_hostnames(subdomain, domain)
30
27
 
31
28
  zone = @cf_client.find_zone(domain)
32
29
  unless zone
@@ -34,13 +31,17 @@ module Nvoi
34
31
  return
35
32
  end
36
33
 
37
- record = @cf_client.find_dns_record(zone.id, hostname, "CNAME")
38
- if record
39
- @cf_client.delete_dns_record(zone.id, record.id)
40
- @log.success "DNS record deleted: %s", hostname
34
+ hostnames.each do |hostname|
35
+ @log.info "Deleting DNS record: %s", hostname
36
+
37
+ record = @cf_client.find_dns_record(zone.id, hostname, "CNAME")
38
+ if record
39
+ @cf_client.delete_dns_record(zone.id, record.id)
40
+ @log.success "DNS record deleted: %s", hostname
41
+ end
41
42
  end
42
43
  rescue StandardError => e
43
- @log.warning "Failed to delete DNS record: %s", e.message
44
+ @log.warning "Failed to delete DNS records: %s", e.message
44
45
  end
45
46
  end
46
47
  end
@@ -123,24 +123,40 @@ module Nvoi
123
123
  require_relative "steps/cleanup_images"
124
124
 
125
125
  ssh = External::Ssh.new(server_ip, @config.ssh_key_path)
126
+ registry_port = Utils::Constants::REGISTRY_PORT
126
127
 
127
- # Acquire deployment lock
128
- acquire_lock(ssh)
128
+ # Start SSH tunnel to registry
129
+ registry_tunnel = External::SshTunnel.new(
130
+ ip: server_ip,
131
+ user: "deploy",
132
+ key_path: @config.ssh_key_path,
133
+ local_port: registry_port,
134
+ remote_port: registry_port
135
+ )
136
+
137
+ registry_tunnel.start
129
138
 
130
139
  begin
131
- # Build and push image
132
- timestamp = Time.now.strftime("%Y%m%d%H%M%S")
133
- image_tag = @config.namer.image_tag(timestamp)
140
+ # Acquire deployment lock
141
+ acquire_lock(ssh)
142
+
143
+ begin
144
+ # Build and push image via tunnel
145
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
146
+ image_tag = @config.namer.image_tag(timestamp)
134
147
 
135
- Steps::BuildImage.new(@config, ssh, @log).run(working_dir, image_tag)
148
+ registry_tag = Steps::BuildImage.new(@config, @log).run(working_dir, image_tag)
136
149
 
137
- # Deploy all services
138
- Steps::DeployService.new(@config, ssh, tunnels, @log).run(image_tag, timestamp)
150
+ # Deploy all services (image already in registry)
151
+ Steps::DeployService.new(@config, ssh, tunnels, @log).run(registry_tag, timestamp)
139
152
 
140
- # Cleanup old images
141
- Steps::CleanupImages.new(@config, ssh, @log).run(timestamp)
153
+ # Cleanup old images
154
+ Steps::CleanupImages.new(@config, ssh, @log).run(timestamp)
155
+ ensure
156
+ release_lock(ssh)
157
+ end
142
158
  ensure
143
- release_lock(ssh)
159
+ registry_tunnel.stop
144
160
  end
145
161
  end
146
162
 
@@ -4,22 +4,64 @@ module Nvoi
4
4
  class Cli
5
5
  module Deploy
6
6
  module Steps
7
- # BuildImage handles Docker image building and pushing to cluster
7
+ # BuildImage handles Docker image building and pushing to registry
8
8
  class BuildImage
9
- def initialize(config, ssh, log)
9
+ def initialize(config, log)
10
10
  @config = config
11
- @ssh = ssh
12
11
  @log = log
13
12
  end
14
13
 
14
+ # Build locally and push to registry via SSH tunnel
15
+ # Returns the registry tag for use in k8s deployments
15
16
  def run(working_dir, image_tag)
16
17
  @log.info "Building Docker image: %s", image_tag
17
18
 
18
- containerd = External::Containerd.new(@ssh)
19
- containerd.build_and_deploy_image(working_dir, image_tag, cache_from: @config.namer.latest_image_tag)
19
+ build_image(working_dir, image_tag)
20
+ registry_tag = push_to_registry(image_tag)
20
21
 
21
- @log.success "Image built: %s", image_tag
22
+ @log.success "Image built and pushed: %s", registry_tag
23
+ registry_tag
22
24
  end
25
+
26
+ private
27
+
28
+ def build_image(working_dir, tag)
29
+ cache_from = @config.namer.latest_image_tag
30
+ cache_args = "--cache-from #{cache_from}"
31
+
32
+ build_cmd = [
33
+ "cd #{working_dir} &&",
34
+ "DOCKER_BUILDKIT=1 docker build",
35
+ "--platform linux/amd64",
36
+ cache_args,
37
+ "--build-arg BUILDKIT_INLINE_CACHE=1",
38
+ "-t #{tag} ."
39
+ ].join(" ")
40
+
41
+ unless system("bash", "-c", build_cmd)
42
+ raise Errors::SshError, "docker build failed"
43
+ end
44
+
45
+ # Tag as :latest for next build's cache
46
+ system("docker", "tag", tag, cache_from)
47
+ end
48
+
49
+ def push_to_registry(tag)
50
+ registry_port = Utils::Constants::REGISTRY_PORT
51
+ registry_tag = "localhost:#{registry_port}/#{@config.container_prefix}:#{tag.split(':').last}"
52
+
53
+ @log.info "Tagging for registry: %s", registry_tag
54
+ unless system("docker", "tag", tag, registry_tag)
55
+ raise Errors::SshError, "docker tag failed"
56
+ end
57
+
58
+ @log.info "Pushing to registry via SSH tunnel..."
59
+ unless system("docker", "push", registry_tag)
60
+ raise Errors::SshError, "docker push failed - is the SSH tunnel active?"
61
+ end
62
+
63
+ registry_tag
64
+ end
23
65
  end
24
66
  end
25
67
  end
@@ -22,7 +22,6 @@ module Nvoi
22
22
  @config.deploy.application.app.each do |service_name, service_config|
23
23
  next unless service_config.domain && !service_config.domain.empty?
24
24
  next unless service_config.port && service_config.port.positive?
25
- next if service_config.subdomain.nil?
26
25
 
27
26
  tunnel_info = configure_service_tunnel(service_name, service_config)
28
27
  tunnels << tunnel_info
@@ -36,23 +35,24 @@ module Nvoi
36
35
 
37
36
  def configure_service_tunnel(service_name, service_config)
38
37
  tunnel_name = @config.namer.tunnel_name(service_name)
39
- hostname = Utils::Namer.build_hostname(service_config.subdomain, service_config.domain)
38
+ hostnames = Utils::Namer.build_hostnames(service_config.subdomain, service_config.domain)
39
+ primary_hostname = hostnames.first
40
40
 
41
41
  # Service URL points to the NGINX Ingress Controller
42
42
  service_url = "http://ingress-nginx-controller.ingress-nginx.svc.cluster.local:80"
43
43
 
44
- tunnel = setup_tunnel(tunnel_name, hostname, service_url, service_config.domain)
44
+ tunnel = setup_tunnel(tunnel_name, hostnames, service_url, service_config.domain)
45
45
 
46
46
  Objects::Tunnel::Info.new(
47
47
  service_name:,
48
- hostname:,
48
+ hostname: primary_hostname,
49
49
  tunnel_id: tunnel.tunnel_id,
50
50
  tunnel_token: tunnel.tunnel_token
51
51
  )
52
52
  end
53
53
 
54
- def setup_tunnel(tunnel_name, hostname, service_url, domain)
55
- @log.info "Setting up tunnel: %s -> %s", tunnel_name, hostname
54
+ def setup_tunnel(tunnel_name, hostnames, service_url, domain)
55
+ @log.info "Setting up tunnel: %s -> %s", tunnel_name, hostnames.join(", ")
56
56
 
57
57
  # Find or create tunnel
58
58
  tunnel = @cf_client.find_tunnel(tunnel_name)
@@ -70,21 +70,23 @@ module Nvoi
70
70
  token = @cf_client.get_tunnel_token(tunnel.id)
71
71
  end
72
72
 
73
- # Configure tunnel ingress
74
- @log.info "Configuring tunnel ingress: %s -> %s", hostname, service_url
75
- @cf_client.update_tunnel_configuration(tunnel.id, hostname, service_url)
73
+ # Configure tunnel ingress for all hostnames
74
+ @log.info "Configuring tunnel ingress: %s -> %s", hostnames.join(", "), service_url
75
+ @cf_client.update_tunnel_configuration(tunnel.id, hostnames, service_url)
76
76
 
77
77
  # Verify configuration propagated
78
78
  @log.info "Verifying tunnel configuration..."
79
- @cf_client.verify_tunnel_configuration(tunnel.id, hostname, service_url, Utils::Constants::TUNNEL_CONFIG_VERIFY_ATTEMPTS)
79
+ @cf_client.verify_tunnel_configuration(tunnel.id, hostnames, service_url, Utils::Constants::TUNNEL_CONFIG_VERIFY_ATTEMPTS)
80
80
 
81
- # Create DNS record
82
- @log.info "Creating DNS CNAME record: %s", hostname
81
+ # Create DNS records for all hostnames
83
82
  zone = @cf_client.find_zone(domain)
84
83
  raise Errors::CloudflareError, "zone not found: #{domain}" unless zone
85
84
 
86
85
  tunnel_cname = "#{tunnel.id}.cfargotunnel.com"
87
- @cf_client.create_or_update_dns_record(zone.id, hostname, "CNAME", tunnel_cname, proxied: true)
86
+ hostnames.each do |hostname|
87
+ @log.info "Creating DNS CNAME record: %s", hostname
88
+ @cf_client.create_or_update_dns_record(zone.id, hostname, "CNAME", tunnel_cname, proxied: true)
89
+ end
88
90
 
89
91
  @log.success "Tunnel configured: %s", tunnel_name
90
92
 
@@ -22,10 +22,9 @@ module Nvoi
22
22
  @kubectl = External::Kubectl.new(ssh)
23
23
  end
24
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)
25
+ def run(registry_tag, timestamp)
26
+ # Image is already in registry (pushed via SSH tunnel in BuildImage step)
27
+ @log.info "Using image from registry: %s", registry_tag
29
28
 
30
29
  # Gather env vars
31
30
  first_service = @config.deploy.application.app.keys.first
@@ -65,15 +64,6 @@ module Nvoi
65
64
 
66
65
  private
67
66
 
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
67
  def deploy_app_secret(env_vars)
78
68
  secret_name = @namer.app_secret_name
79
69
 
@@ -247,11 +237,11 @@ module Nvoi
247
237
 
248
238
  # Deploy ingress if domain is specified
249
239
  if service_config.domain && !service_config.domain.empty?
250
- hostname = Utils::Namer.build_hostname(service_config.subdomain, service_config.domain)
240
+ hostnames = Utils::Namer.build_hostnames(service_config.subdomain, service_config.domain)
251
241
 
252
242
  Utils::Templates.apply_manifest(@ssh, "app-ingress.yaml", {
253
243
  name: deployment_name,
254
- domain: hostname,
244
+ domains: hostnames,
255
245
  port: service_config.port
256
246
  })
257
247
  end
@@ -323,6 +313,9 @@ module Nvoi
323
313
  result = check_public_url(public_url)
324
314
 
325
315
  if result[:success]
316
+ if consecutive_success == 0
317
+ @log.info "[%d/%d] App responding, verifying stability...", attempt + 1, max_attempts
318
+ end
326
319
  consecutive_success += 1
327
320
  @log.success "[%d/%d] Public URL responding: %s", consecutive_success, required_consecutive, result[:http_code]
328
321
 
@@ -467,7 +467,16 @@ module Nvoi
467
467
  ssh.execute(patch_deployment)
468
468
 
469
469
  @log.info "Waiting for ingress controller to restart..."
470
- ssh.execute("kubectl rollout status deployment/ingress-nginx-controller -n ingress-nginx --timeout=120s")
470
+ ready = Utils::Retry.poll(max_attempts: 60, interval: 2) do
471
+ begin
472
+ ready_replicas = ssh.execute("kubectl get deployment ingress-nginx-controller -n ingress-nginx -o jsonpath='{.status.readyReplicas}'").strip
473
+ desired_replicas = ssh.execute("kubectl get deployment ingress-nginx-controller -n ingress-nginx -o jsonpath='{.spec.replicas}'").strip
474
+ !ready_replicas.empty? && !desired_replicas.empty? && ready_replicas == desired_replicas
475
+ rescue Errors::SshCommandError
476
+ false
477
+ end
478
+ end
479
+ raise Errors::K8sError, "Ingress controller failed to restart" unless ready
471
480
  else
472
481
  @log.info "Custom error backend already configured"
473
482
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Logs
6
+ # Command streams logs from app pods
7
+ class Command
8
+ def initialize(options)
9
+ @options = options
10
+ @log = Nvoi.logger
11
+ end
12
+
13
+ def run(app_name)
14
+ config_path = resolve_config_path
15
+ @config = Utils::ConfigLoader.load(config_path)
16
+
17
+ # Apply branch override if specified
18
+ apply_branch_override if @options[:branch]
19
+
20
+ # Initialize cloud provider
21
+ @provider = External::Cloud.for(@config)
22
+
23
+ # Find main server
24
+ server = @provider.find_server(@config.server_name)
25
+ raise Errors::ServiceError, "server not found: #{@config.server_name}" unless server
26
+
27
+ # Build deployment name from app name
28
+ deployment_name = @config.namer.app_deployment_name(app_name)
29
+
30
+ # Build kubectl logs command
31
+ # --prefix shows pod name, --all-containers handles multi-container pods
32
+ follow_flag = @options[:follow] ? "-f" : ""
33
+ tail_flag = "--tail=#{@options[:tail]}"
34
+
35
+ kubectl_cmd = "kubectl logs -l app=#{deployment_name} --prefix --all-containers #{follow_flag} #{tail_flag}".strip.squeeze(" ")
36
+
37
+ @log.info "Streaming logs for %s", deployment_name
38
+
39
+ ssh = External::Ssh.new(server.public_ipv4, @config.ssh_key_path)
40
+ ssh.execute(kubectl_cmd, stream: true)
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_config_path
46
+ config_path = @options[:config] || "deploy.enc"
47
+ working_dir = @options[:dir]
48
+
49
+ if config_path == "deploy.enc" && working_dir && working_dir != "."
50
+ File.join(working_dir, "deploy.enc")
51
+ else
52
+ config_path
53
+ end
54
+ end
55
+
56
+ def apply_branch_override
57
+ branch = @options[:branch]
58
+ return if branch.nil? || branch.empty?
59
+
60
+ override = Objects::ConfigOverride.new(branch:)
61
+ override.apply(@config)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end