nvoi 0.1.6 → 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 (55) 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.md +550 -0
  18. data/Gemfile +5 -0
  19. data/Gemfile.lock +35 -4
  20. data/Rakefile +1 -1
  21. data/ingest +0 -0
  22. data/lib/nvoi/cli/config/command.rb +219 -0
  23. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +12 -11
  24. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +15 -13
  25. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +5 -2
  26. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +10 -1
  27. data/lib/nvoi/cli/logs/command.rb +66 -0
  28. data/lib/nvoi/cli/onboard/command.rb +761 -0
  29. data/lib/nvoi/cli/unlock/command.rb +72 -0
  30. data/lib/nvoi/cli.rb +257 -0
  31. data/lib/nvoi/config_api/actions/app.rb +30 -30
  32. data/lib/nvoi/config_api/actions/compute_provider.rb +31 -31
  33. data/lib/nvoi/config_api/actions/database.rb +42 -42
  34. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  35. data/lib/nvoi/config_api/actions/env.rb +12 -12
  36. data/lib/nvoi/config_api/actions/init.rb +67 -0
  37. data/lib/nvoi/config_api/actions/secret.rb +12 -12
  38. data/lib/nvoi/config_api/actions/server.rb +35 -35
  39. data/lib/nvoi/config_api/actions/service.rb +52 -0
  40. data/lib/nvoi/config_api/actions/volume.rb +18 -18
  41. data/lib/nvoi/config_api/base.rb +15 -21
  42. data/lib/nvoi/config_api/result.rb +5 -5
  43. data/lib/nvoi/config_api.rb +51 -28
  44. data/lib/nvoi/external/cloud/aws.rb +26 -1
  45. data/lib/nvoi/external/cloud/hetzner.rb +27 -1
  46. data/lib/nvoi/external/cloud/scaleway.rb +32 -6
  47. data/lib/nvoi/external/containerd.rb +4 -0
  48. data/lib/nvoi/external/dns/cloudflare.rb +34 -16
  49. data/lib/nvoi/objects/configuration.rb +20 -0
  50. data/lib/nvoi/utils/namer.rb +9 -0
  51. data/lib/nvoi/utils/retry.rb +1 -1
  52. data/lib/nvoi/version.rb +1 -1
  53. data/lib/nvoi.rb +16 -0
  54. data/templates/app-ingress.yaml.erb +3 -1
  55. metadata +25 -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
@@ -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
 
@@ -247,11 +247,11 @@ module Nvoi
247
247
 
248
248
  # Deploy ingress if domain is specified
249
249
  if service_config.domain && !service_config.domain.empty?
250
- hostname = Utils::Namer.build_hostname(service_config.subdomain, service_config.domain)
250
+ hostnames = Utils::Namer.build_hostnames(service_config.subdomain, service_config.domain)
251
251
 
252
252
  Utils::Templates.apply_manifest(@ssh, "app-ingress.yaml", {
253
253
  name: deployment_name,
254
- domain: hostname,
254
+ domains: hostnames,
255
255
  port: service_config.port
256
256
  })
257
257
  end
@@ -323,6 +323,9 @@ module Nvoi
323
323
  result = check_public_url(public_url)
324
324
 
325
325
  if result[:success]
326
+ if consecutive_success == 0
327
+ @log.info "[%d/%d] App responding, verifying stability...", attempt + 1, max_attempts
328
+ end
326
329
  consecutive_success += 1
327
330
  @log.success "[%d/%d] Public URL responding: %s", consecutive_success, required_consecutive, result[:http_code]
328
331
 
@@ -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