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
@@ -127,7 +127,7 @@ module Nvoi
127
127
 
128
128
  def create_server(opts)
129
129
  # Validate server type
130
- server_types = list_server_types
130
+ server_types = list_server_types_api
131
131
  unless server_types.key?(opts.type)
132
132
  raise Errors::ValidationError, "invalid server type: #{opts.type}"
133
133
  end
@@ -170,7 +170,7 @@ module Nvoi
170
170
  end
171
171
 
172
172
  def wait_for_server(server_id, max_attempts)
173
- server = Utils::Retry.poll(max_attempts: max_attempts, interval: Utils::Constants::SERVER_READY_INTERVAL) do
173
+ server = Utils::Retry.poll(max_attempts:, interval: Utils::Constants::SERVER_READY_INTERVAL) do
174
174
  s = get_server_api(server_id)
175
175
  to_server(s) if s["state"] == "running" && s.dig("public_ip", "address")
176
176
  end
@@ -281,7 +281,7 @@ module Nvoi
281
281
  # Validation operations
282
282
 
283
283
  def validate_instance_type(instance_type)
284
- server_types = list_server_types
284
+ server_types = list_server_types_api
285
285
  unless server_types.key?(instance_type)
286
286
  raise Errors::ValidationError, "invalid scaleway server type: #{instance_type}"
287
287
  end
@@ -298,7 +298,7 @@ module Nvoi
298
298
  end
299
299
 
300
300
  def validate_credentials
301
- list_server_types
301
+ list_server_types_api
302
302
  true
303
303
  rescue Errors::AuthenticationError => e
304
304
  raise Errors::ValidationError, "scaleway credentials invalid: #{e.message}"
@@ -310,6 +310,32 @@ module Nvoi
310
310
  server&.public_ipv4
311
311
  end
312
312
 
313
+ # List available server types for onboarding
314
+ def list_server_types
315
+ list_server_types_api.map do |name, info|
316
+ {
317
+ name:,
318
+ cores: info.dig("ncpus"),
319
+ ram: info.dig("ram"),
320
+ hourly_price: info.dig("hourly_price")
321
+ }
322
+ end
323
+ end
324
+
325
+ # List available zones for onboarding
326
+ def list_zones
327
+ VALID_ZONES.map do |z|
328
+ parts = z.split("-")
329
+ city = case parts[0..1].join("-")
330
+ when "fr-par" then "Paris"
331
+ when "nl-ams" then "Amsterdam"
332
+ when "pl-waw" then "Warsaw"
333
+ else parts[0..1].join("-")
334
+ end
335
+ { name: z, city: }
336
+ end
337
+ end
338
+
313
339
  private
314
340
 
315
341
  def zone_to_region(zone)
@@ -402,7 +428,7 @@ module Nvoi
402
428
  post(instance_url("/servers/#{id}/action"), { action: })
403
429
  end
404
430
 
405
- def list_server_types
431
+ def list_server_types_api
406
432
  get(instance_url("/products/servers"))["servers"] || {}
407
433
  end
408
434
 
@@ -450,7 +476,7 @@ module Nvoi
450
476
  end
451
477
 
452
478
  def wait_for_server_state(server_id, target_state, max_attempts)
453
- Utils::Retry.poll(max_attempts: max_attempts, interval: 2) do
479
+ Utils::Retry.poll(max_attempts:, interval: 2) do
454
480
  server = get_server_api(server_id)
455
481
  server if server["state"] == target_state
456
482
  end
@@ -19,6 +19,9 @@ module Nvoi
19
19
  raise Errors::SshError, "local build failed"
20
20
  end
21
21
 
22
+ # Tag as :latest for next build's cache
23
+ system("docker", "tag", tag, cache_from) if cache_from
24
+
22
25
  tar_file = "/tmp/#{tag.tr(':', '_')}.tar"
23
26
  unless system("docker", "save", tag, "-o", tar_file)
24
27
  raise Errors::SshError, "docker save failed"
@@ -37,6 +40,7 @@ module Nvoi
37
40
  raise Errors::SshError, "rsync failed"
38
41
  end
39
42
 
43
+ Nvoi.logger.info "Importing image into containerd..."
40
44
  @ssh.execute("sudo ctr -n k8s.io images import #{remote_tar_path}")
41
45
 
42
46
  full_image_ref = "docker.io/library/#{tag}"
@@ -64,24 +64,25 @@ module Nvoi
64
64
  response["result"]
65
65
  end
66
66
 
67
- def update_tunnel_configuration(tunnel_id, hostname, service_url)
67
+ def update_tunnel_configuration(tunnel_id, hostnames, service_url)
68
+ hostnames = Array(hostnames)
68
69
  url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
69
70
 
70
- config = {
71
- ingress: [
72
- {
73
- hostname:,
74
- service: service_url,
75
- originRequest: { httpHostHeader: hostname }
76
- },
77
- { service: "http_status:404" }
78
- ]
79
- }
71
+ ingress_rules = hostnames.map do |hostname|
72
+ {
73
+ hostname:,
74
+ service: service_url,
75
+ originRequest: { httpHostHeader: hostname.sub(/^\*\./, "") } # Use apex for wildcard
76
+ }
77
+ end
78
+ ingress_rules << { service: "http_status:404" }
80
79
 
80
+ config = { ingress: ingress_rules }
81
81
  put(url, { config: })
82
82
  end
83
83
 
84
- def verify_tunnel_configuration(tunnel_id, expected_hostname, expected_service, max_attempts)
84
+ def verify_tunnel_configuration(tunnel_id, expected_hostnames, expected_service, max_attempts)
85
+ expected_hostnames = Array(expected_hostnames)
85
86
  url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
86
87
 
87
88
  max_attempts.times do
@@ -90,10 +91,11 @@ module Nvoi
90
91
 
91
92
  if response["success"]
92
93
  config = response.dig("result", "config")
93
- config&.dig("ingress")&.each do |rule|
94
- if rule["hostname"] == expected_hostname && rule["service"] == expected_service
95
- return true
96
- end
94
+ ingress = config&.dig("ingress") || []
95
+ configured_hostnames = ingress.map { |r| r["hostname"] }.compact
96
+
97
+ if expected_hostnames.all? { |h| configured_hostnames.include?(h) }
98
+ return true
97
99
  end
98
100
  end
99
101
  rescue StandardError
@@ -122,6 +124,16 @@ module Nvoi
122
124
 
123
125
  # DNS operations
124
126
 
127
+ def list_zones
128
+ url = "zones"
129
+ response = get(url)
130
+
131
+ results = response["result"] || []
132
+ results.map do |z|
133
+ { id: z["id"], name: z["name"], status: z["status"] }
134
+ end
135
+ end
136
+
125
137
  def find_zone(domain)
126
138
  url = "zones"
127
139
  response = get(url)
@@ -135,6 +147,12 @@ module Nvoi
135
147
  Objects::Dns::Zone.new(id: zone_data["id"], name: zone_data["name"])
136
148
  end
137
149
 
150
+ def subdomain_available?(zone_id, subdomain, domain)
151
+ fqdn = subdomain.empty? ? domain : "#{subdomain}.#{domain}"
152
+ # Check for CNAME or A record
153
+ !find_dns_record(zone_id, fqdn, "CNAME") && !find_dns_record(zone_id, fqdn, "A")
154
+ end
155
+
138
156
  def find_dns_record(zone_id, name, record_type)
139
157
  url = "zones/#{zone_id}/dns_records"
140
158
  response = get(url)
@@ -28,6 +28,7 @@ module Nvoi
28
28
  validate_database_secrets(app.database) if app.database
29
29
  inject_database_env_vars
30
30
  validate_service_server_bindings
31
+ validate_domain_uniqueness
31
32
  end
32
33
 
33
34
  def provider_name
@@ -211,6 +212,25 @@ module Nvoi
211
212
  )
212
213
  end
213
214
  end
215
+
216
+ def validate_domain_uniqueness
217
+ app = @deploy.application
218
+ return unless app.app
219
+
220
+ seen = {}
221
+ app.app.each do |name, cfg|
222
+ next unless cfg.domain && !cfg.domain.empty?
223
+
224
+ hostnames = Utils::Namer.build_hostnames(cfg.subdomain, cfg.domain)
225
+ hostnames.each do |hostname|
226
+ if seen[hostname]
227
+ raise Errors::ConfigValidationError,
228
+ "domain '#{hostname}' used by both '#{seen[hostname]}' and '#{name}'"
229
+ end
230
+ seen[hostname] = name
231
+ end
232
+ end
233
+ end
214
234
  end
215
235
 
216
236
  # Deploy represents the root deployment configuration
@@ -185,6 +185,15 @@ module Nvoi
185
185
  end
186
186
  end
187
187
 
188
+ # Returns array of hostnames - apex returns [domain, *.domain], subdomain returns [sub.domain]
189
+ def self.build_hostnames(subdomain, domain)
190
+ if subdomain.nil? || subdomain.empty? || subdomain == "@"
191
+ [domain, "*.#{domain}"]
192
+ else
193
+ ["#{subdomain}.#{domain}"]
194
+ end
195
+ end
196
+
188
197
  private
189
198
 
190
199
  def hash_string(str)
@@ -77,7 +77,7 @@ module Nvoi
77
77
 
78
78
  # Poll with error on timeout
79
79
  def self.poll!(max_attempts: 30, interval: 2, error_message: "operation timed out")
80
- result = poll(max_attempts: max_attempts, interval: interval) { yield }
80
+ result = poll(max_attempts:, interval:) { yield }
81
81
  raise Errors::TimeoutError, error_message unless result
82
82
 
83
83
  result
data/lib/nvoi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nvoi
4
- VERSION = "0.1.6"
4
+ VERSION = "0.1.7"
5
5
  end
data/lib/nvoi.rb CHANGED
@@ -16,8 +16,24 @@ require "faraday"
16
16
 
17
17
  loader = Zeitwerk::Loader.for_gem
18
18
  loader.ignore("#{__dir__}/nvoi/cli") # CLI commands are lazy-loaded
19
+ loader.ignore("#{__dir__}/nvoi/config_api") # ConfigApi uses non-standard naming
19
20
  loader.setup
20
21
 
22
+ # Load ConfigApi manually (uses non-standard naming convention)
23
+ require_relative "nvoi/config_api/result"
24
+ require_relative "nvoi/config_api/base"
25
+ require_relative "nvoi/config_api/actions/init"
26
+ require_relative "nvoi/config_api/actions/domain_provider"
27
+ require_relative "nvoi/config_api/actions/compute_provider"
28
+ require_relative "nvoi/config_api/actions/server"
29
+ require_relative "nvoi/config_api/actions/volume"
30
+ require_relative "nvoi/config_api/actions/app"
31
+ require_relative "nvoi/config_api/actions/database"
32
+ require_relative "nvoi/config_api/actions/secret"
33
+ require_relative "nvoi/config_api/actions/env"
34
+ require_relative "nvoi/config_api/actions/service"
35
+ require_relative "nvoi/config_api"
36
+
21
37
  module Nvoi
22
38
  class << self
23
39
  attr_accessor :logger
@@ -8,7 +8,8 @@ metadata:
8
8
  spec:
9
9
  ingressClassName: nginx
10
10
  rules:
11
- - host: <%= domain %>
11
+ <% domains.each do |domain| -%>
12
+ - host: "<%= domain %>"
12
13
  http:
13
14
  paths:
14
15
  - path: /
@@ -18,3 +19,4 @@ spec:
18
19
  name: <%= name %>
19
20
  port:
20
21
  number: <%= port %>
22
+ <% end -%>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nvoi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - NVOI
@@ -173,6 +173,20 @@ executables:
173
173
  extensions: []
174
174
  extra_rdoc_files: []
175
175
  files:
176
+ - ".claude/todo/refactor-execution/00-entrypoint.md"
177
+ - ".claude/todo/refactor-execution/01-objects.md"
178
+ - ".claude/todo/refactor-execution/02-utils.md"
179
+ - ".claude/todo/refactor-execution/03-external-cloud.md"
180
+ - ".claude/todo/refactor-execution/04-external-dns.md"
181
+ - ".claude/todo/refactor-execution/05-external-other.md"
182
+ - ".claude/todo/refactor-execution/06-cli-deploy.md"
183
+ - ".claude/todo/refactor-execution/07-cli-delete.md"
184
+ - ".claude/todo/refactor-execution/08-cli-exec.md"
185
+ - ".claude/todo/refactor-execution/09-cli-credentials.md"
186
+ - ".claude/todo/refactor-execution/10-cli-db.md"
187
+ - ".claude/todo/refactor-execution/11-cli-router.md"
188
+ - ".claude/todo/refactor-execution/12-cleanup.md"
189
+ - ".claude/todo/refactor-execution/_monitoring-strategy.md"
176
190
  - ".claude/todo/refactor/00-overview.md"
177
191
  - ".claude/todo/refactor/01-objects.md"
178
192
  - ".claude/todo/refactor/02-utils.md"
@@ -185,9 +199,11 @@ files:
185
199
  - ".claude/todo/refactor/09-cli-delete-command.md"
186
200
  - ".claude/todo/refactor/10-cli-exec-command.md"
187
201
  - ".claude/todo/refactor/11-cli-credentials-command.md"
202
+ - ".claude/todo/refactor/12-cli-db-command.md"
188
203
  - ".claude/todo/refactor/_target.md"
189
204
  - ".claude/todo/scaleway.impl.md"
190
205
  - ".claude/todo/scaleway.reference.md"
206
+ - ".claude/todos.md"
191
207
  - ".rubocop.yml"
192
208
  - Gemfile
193
209
  - Gemfile.lock
@@ -311,8 +327,10 @@ files:
311
327
  - examples/rails-single/vendor/.keep
312
328
  - examples/rails-single/yarn.lock
313
329
  - exe/nvoi
330
+ - ingest
314
331
  - lib/nvoi.rb
315
332
  - lib/nvoi/cli.rb
333
+ - lib/nvoi/cli/config/command.rb
316
334
  - lib/nvoi/cli/credentials/edit/command.rb
317
335
  - lib/nvoi/cli/credentials/show/command.rb
318
336
  - lib/nvoi/cli/db/command.rb
@@ -334,13 +352,19 @@ files:
334
352
  - lib/nvoi/cli/deploy/steps/provision_volume.rb
335
353
  - lib/nvoi/cli/deploy/steps/setup_k3s.rb
336
354
  - lib/nvoi/cli/exec/command.rb
355
+ - lib/nvoi/cli/logs/command.rb
356
+ - lib/nvoi/cli/onboard/command.rb
357
+ - lib/nvoi/cli/unlock/command.rb
337
358
  - lib/nvoi/config_api.rb
338
359
  - lib/nvoi/config_api/actions/app.rb
339
360
  - lib/nvoi/config_api/actions/compute_provider.rb
340
361
  - lib/nvoi/config_api/actions/database.rb
362
+ - lib/nvoi/config_api/actions/domain_provider.rb
341
363
  - lib/nvoi/config_api/actions/env.rb
364
+ - lib/nvoi/config_api/actions/init.rb
342
365
  - lib/nvoi/config_api/actions/secret.rb
343
366
  - lib/nvoi/config_api/actions/server.rb
367
+ - lib/nvoi/config_api/actions/service.rb
344
368
  - lib/nvoi/config_api/actions/volume.rb
345
369
  - lib/nvoi/config_api/base.rb
346
370
  - lib/nvoi/config_api/result.rb