nvoi 0.1.8 → 0.2.1

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -5
  3. data/Gemfile.lock +17 -8
  4. data/Rakefile +1 -1
  5. data/_TODO-rails-example.md +816 -0
  6. data/_TODO-rails-optimization.md +433 -0
  7. data/doc/config-schema.yaml +12 -0
  8. data/examples/apex-wildcard/deploy.yml +1 -0
  9. data/examples/golang-postgres-multi/deploy.yml +1 -0
  10. data/examples/postgres-multi/deploy.yml +1 -0
  11. data/examples/postgres-single/deploy.yml +1 -0
  12. data/examples/rails-single/deploy.yml +1 -0
  13. data/lib/nvoi/cli/config/command.rb +46 -41
  14. data/lib/nvoi/cli/credentials/edit/command.rb +24 -20
  15. data/lib/nvoi/cli/credentials/show/command.rb +1 -1
  16. data/lib/nvoi/cli/db/command.rb +10 -10
  17. data/lib/nvoi/cli/delete/command.rb +2 -2
  18. data/lib/nvoi/cli/deploy/command.rb +2 -2
  19. data/lib/nvoi/cli/deploy/steps/build_image.rb +2 -1
  20. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +2 -2
  21. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +7 -4
  22. data/lib/nvoi/cli/deploy/steps/provision_server.rb +1 -1
  23. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +1 -1
  24. data/lib/nvoi/cli/exec/command.rb +3 -3
  25. data/lib/nvoi/cli/logs/command.rb +2 -2
  26. data/lib/nvoi/cli/onboard/command.rb +176 -622
  27. data/lib/nvoi/cli/onboard/steps/app.rb +108 -0
  28. data/lib/nvoi/cli/onboard/steps/app_name.rb +26 -0
  29. data/lib/nvoi/cli/onboard/steps/compute.rb +186 -0
  30. data/lib/nvoi/cli/onboard/steps/database.rb +97 -0
  31. data/lib/nvoi/cli/onboard/steps/domain.rb +48 -0
  32. data/lib/nvoi/cli/onboard/steps/env.rb +67 -0
  33. data/lib/nvoi/cli/onboard/ui.rb +84 -0
  34. data/lib/nvoi/cli/unlock/command.rb +2 -2
  35. data/lib/nvoi/cli.rb +2 -33
  36. data/lib/nvoi/configuration/app_service.rb +54 -0
  37. data/lib/nvoi/configuration/application.rb +44 -0
  38. data/lib/nvoi/configuration/builder.rb +420 -0
  39. data/lib/nvoi/configuration/database.rb +56 -0
  40. data/lib/nvoi/configuration/deploy.rb +15 -0
  41. data/lib/nvoi/{objects/service_spec.rb → configuration/deployment.rb} +4 -3
  42. data/lib/nvoi/{objects/config_override.rb → configuration/override.rb} +4 -4
  43. data/lib/nvoi/configuration/providers.rb +81 -0
  44. data/lib/nvoi/configuration/result.rb +43 -0
  45. data/lib/nvoi/configuration/root.rb +252 -0
  46. data/lib/nvoi/configuration/server.rb +39 -0
  47. data/lib/nvoi/configuration/service.rb +51 -0
  48. data/lib/nvoi/configuration/ssh_key.rb +16 -0
  49. data/lib/nvoi/external/cloud/aws.rb +26 -16
  50. data/lib/nvoi/external/cloud/hetzner.rb +40 -25
  51. data/lib/nvoi/external/cloud/scaleway.rb +10 -8
  52. data/lib/nvoi/external/cloud/types.rb +42 -0
  53. data/lib/nvoi/external/database/mysql.rb +1 -1
  54. data/lib/nvoi/external/database/postgres.rb +1 -1
  55. data/lib/nvoi/external/database/provider.rb +1 -1
  56. data/lib/nvoi/external/database/sqlite.rb +1 -1
  57. data/lib/nvoi/external/database/types.rb +55 -0
  58. data/lib/nvoi/external/dns/cloudflare.rb +11 -11
  59. data/lib/nvoi/external/dns/types.rb +24 -0
  60. data/lib/nvoi/utils/config_loader.rb +12 -12
  61. data/lib/nvoi/utils/credential_store.rb +4 -4
  62. data/lib/nvoi/utils/env_resolver.rb +3 -3
  63. data/lib/nvoi/utils/namer.rb +8 -3
  64. data/lib/nvoi/utils/presence.rb +23 -0
  65. data/lib/nvoi/version.rb +1 -1
  66. data/lib/nvoi.rb +2 -17
  67. metadata +98 -59
  68. data/.claude/todo/refactor/00-overview.md +0 -171
  69. data/.claude/todo/refactor/01-objects.md +0 -96
  70. data/.claude/todo/refactor/02-utils.md +0 -143
  71. data/.claude/todo/refactor/03-external-cloud.md +0 -164
  72. data/.claude/todo/refactor/04-external-dns.md +0 -104
  73. data/.claude/todo/refactor/05-external.md +0 -133
  74. data/.claude/todo/refactor/06-cli.md +0 -123
  75. data/.claude/todo/refactor/07-cli-deploy-command.md +0 -177
  76. data/.claude/todo/refactor/08-cli-deploy-steps.md +0 -201
  77. data/.claude/todo/refactor/09-cli-delete-command.md +0 -169
  78. data/.claude/todo/refactor/10-cli-exec-command.md +0 -157
  79. data/.claude/todo/refactor/11-cli-credentials-command.md +0 -190
  80. data/.claude/todo/refactor/12-cli-db-command.md +0 -128
  81. data/.claude/todo/refactor/_target.md +0 -79
  82. data/.claude/todo/refactor-execution/00-entrypoint.md +0 -49
  83. data/.claude/todo/refactor-execution/01-objects.md +0 -42
  84. data/.claude/todo/refactor-execution/02-utils.md +0 -41
  85. data/.claude/todo/refactor-execution/03-external-cloud.md +0 -38
  86. data/.claude/todo/refactor-execution/04-external-dns.md +0 -35
  87. data/.claude/todo/refactor-execution/05-external-other.md +0 -46
  88. data/.claude/todo/refactor-execution/06-cli-deploy.md +0 -45
  89. data/.claude/todo/refactor-execution/07-cli-delete.md +0 -43
  90. data/.claude/todo/refactor-execution/08-cli-exec.md +0 -30
  91. data/.claude/todo/refactor-execution/09-cli-credentials.md +0 -34
  92. data/.claude/todo/refactor-execution/10-cli-db.md +0 -31
  93. data/.claude/todo/refactor-execution/11-cli-router.md +0 -44
  94. data/.claude/todo/refactor-execution/12-cleanup.md +0 -120
  95. data/.claude/todo/refactor-execution/_monitoring-strategy.md +0 -126
  96. data/.claude/todo/scaleway.impl.md +0 -644
  97. data/.claude/todo/scaleway.reference.md +0 -520
  98. data/.claude/todos/buckets.md +0 -41
  99. data/.claude/todos.md +0 -550
  100. data/Makefile +0 -26
  101. data/ingest +0 -0
  102. data/lib/nvoi/config_api/actions/app.rb +0 -53
  103. data/lib/nvoi/config_api/actions/compute_provider.rb +0 -55
  104. data/lib/nvoi/config_api/actions/database.rb +0 -70
  105. data/lib/nvoi/config_api/actions/domain_provider.rb +0 -40
  106. data/lib/nvoi/config_api/actions/env.rb +0 -32
  107. data/lib/nvoi/config_api/actions/init.rb +0 -67
  108. data/lib/nvoi/config_api/actions/secret.rb +0 -32
  109. data/lib/nvoi/config_api/actions/server.rb +0 -66
  110. data/lib/nvoi/config_api/actions/service.rb +0 -52
  111. data/lib/nvoi/config_api/actions/volume.rb +0 -40
  112. data/lib/nvoi/config_api/base.rb +0 -38
  113. data/lib/nvoi/config_api/result.rb +0 -26
  114. data/lib/nvoi/config_api.rb +0 -93
  115. data/lib/nvoi/objects/configuration.rb +0 -483
  116. data/lib/nvoi/objects/database.rb +0 -56
  117. data/lib/nvoi/objects/dns.rb +0 -14
  118. data/lib/nvoi/objects/firewall.rb +0 -11
  119. data/lib/nvoi/objects/network.rb +0 -11
  120. data/lib/nvoi/objects/server.rb +0 -14
  121. data/lib/nvoi/objects/tunnel.rb +0 -14
  122. data/lib/nvoi/objects/volume.rb +0 -17
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Configuration
5
+ # Root holds the complete configuration including deployment config and runtime settings
6
+ class Root
7
+ attr_accessor :deploy, :ssh_key_path, :ssh_public_key, :server_name,
8
+ :firewall_name, :network_name, :docker_network_name, :container_prefix, :namer
9
+
10
+ def initialize(deploy_config)
11
+ @deploy = deploy_config
12
+ @namer = nil
13
+ end
14
+
15
+ def namer
16
+ @namer ||= Utils::Namer.new(self)
17
+ end
18
+
19
+ def env_for_service(service_name)
20
+ Utils::EnvResolver.new(self).env_for_service(service_name)
21
+ end
22
+
23
+ def validate_config
24
+ app = @deploy.application
25
+ validate_providers_config
26
+ validate_database_secrets(app.database) if app.database
27
+ inject_database_env_vars
28
+ validate_service_server_bindings
29
+ validate_domain_uniqueness
30
+ end
31
+
32
+ def provider_name
33
+ return "hetzner" if @deploy.application.compute_provider.hetzner
34
+ return "aws" if @deploy.application.compute_provider.aws
35
+ return "scaleway" if @deploy.application.compute_provider.scaleway
36
+
37
+ ""
38
+ end
39
+
40
+ def hetzner
41
+ @deploy.application.compute_provider.hetzner
42
+ end
43
+
44
+ def aws
45
+ @deploy.application.compute_provider.aws
46
+ end
47
+
48
+ def scaleway
49
+ @deploy.application.compute_provider.scaleway
50
+ end
51
+
52
+ def cloudflare
53
+ @deploy.application.domain_provider.cloudflare
54
+ end
55
+
56
+ def architecture
57
+ case provider_name
58
+ when "hetzner" then hetzner&.architecture
59
+ when "aws" then aws&.architecture
60
+ when "scaleway" then scaleway&.architecture
61
+ end
62
+ end
63
+
64
+ def docker_platform
65
+ case architecture
66
+ when "arm", "arm64" then "linux/arm64"
67
+ else "linux/amd64"
68
+ end
69
+ end
70
+
71
+ def keep_count_value
72
+ count = @deploy.application.keep_count
73
+ count && count.positive? ? count : 2
74
+ end
75
+
76
+ private
77
+
78
+ def validate_service_server_bindings
79
+ app = @deploy.application
80
+ defined_servers = {}
81
+ master_count = 0
82
+
83
+ app.servers.each do |server_name, server_config|
84
+ defined_servers[server_name] = true
85
+ master_count += 1 if server_config&.master
86
+ end
87
+
88
+ if app.servers.empty?
89
+ has_services = !app.app.empty? || app.database || !app.services.empty?
90
+ raise Errors::ConfigValidationError, "servers must be defined when deploying services" if has_services
91
+ end
92
+
93
+ if app.servers.size > 1
94
+ raise Errors::ConfigValidationError, "when multiple servers are defined, exactly one must have master: true" if master_count.zero?
95
+ raise Errors::ConfigValidationError, "only one server can have master: true, found #{master_count}" if master_count > 1
96
+ elsif app.servers.size == 1 && master_count > 1
97
+ raise Errors::ConfigValidationError, "only one server can have master: true, found #{master_count}"
98
+ end
99
+
100
+ app.app.each do |svc_name, svc_config|
101
+ raise Errors::ConfigValidationError, "app.#{svc_name}: servers field is required" if svc_config.servers.empty?
102
+
103
+ svc_config.servers.each do |server_ref|
104
+ raise Errors::ConfigValidationError, "app.#{svc_name}: references undefined server '#{server_ref}'" unless defined_servers[server_ref]
105
+ end
106
+ end
107
+
108
+ if app.database
109
+ raise Errors::ConfigValidationError, "database: servers field is required" if app.database.servers.empty?
110
+
111
+ app.database.servers.each do |server_ref|
112
+ raise Errors::ConfigValidationError, "database: references undefined server '#{server_ref}'" unless defined_servers[server_ref]
113
+ end
114
+ end
115
+
116
+ app.services.each do |svc_name, svc_config|
117
+ raise Errors::ConfigValidationError, "services.#{svc_name}: servers field is required" if svc_config.servers.empty?
118
+
119
+ svc_config.servers.each do |server_ref|
120
+ raise Errors::ConfigValidationError, "services.#{svc_name}: references undefined server '#{server_ref}'" unless defined_servers[server_ref]
121
+ end
122
+ end
123
+ end
124
+
125
+ def validate_database_secrets(db)
126
+ adapter = db.adapter&.downcase
127
+ return unless db.url.blank?
128
+
129
+ case adapter
130
+ when "postgres", "postgresql"
131
+ %w[POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB].each do |key|
132
+ raise Errors::ConfigValidationError, "postgres database requires #{key} in secrets (or provide database.url)" unless db.secrets[key]
133
+ end
134
+ when "mysql"
135
+ %w[MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE].each do |key|
136
+ raise Errors::ConfigValidationError, "mysql database requires #{key} in secrets (or provide database.url)" unless db.secrets[key]
137
+ end
138
+ when "sqlite", "sqlite3"
139
+ # SQLite doesn't require secrets
140
+ end
141
+ end
142
+
143
+ def validate_providers_config
144
+ app = @deploy.application
145
+
146
+ unless app.domain_provider.cloudflare
147
+ raise Errors::ConfigValidationError, "domain provider required: currently only cloudflare is supported"
148
+ end
149
+
150
+ cf = app.domain_provider.cloudflare
151
+ raise Errors::ConfigValidationError, "cloudflare api_token is required" if cf.api_token.blank?
152
+ raise Errors::ConfigValidationError, "cloudflare account_id is required" if cf.account_id.blank?
153
+
154
+ has_provider = false
155
+
156
+ if app.compute_provider.hetzner
157
+ has_provider = true
158
+ h = app.compute_provider.hetzner
159
+ raise Errors::ConfigValidationError, "hetzner api_token is required" if h.api_token.blank?
160
+ raise Errors::ConfigValidationError, "hetzner server_type is required" if h.server_type.blank?
161
+ raise Errors::ConfigValidationError, "hetzner server_location is required" if h.server_location.blank?
162
+ raise Errors::ConfigValidationError, "hetzner architecture is required" if h.architecture.blank?
163
+ end
164
+
165
+ if app.compute_provider.aws
166
+ has_provider = true
167
+ a = app.compute_provider.aws
168
+ raise Errors::ConfigValidationError, "aws access_key_id is required" if a.access_key_id.blank?
169
+ raise Errors::ConfigValidationError, "aws secret_access_key is required" if a.secret_access_key.blank?
170
+ raise Errors::ConfigValidationError, "aws region is required" if a.region.blank?
171
+ raise Errors::ConfigValidationError, "aws instance_type is required" if a.instance_type.blank?
172
+ raise Errors::ConfigValidationError, "aws architecture is required" if a.architecture.blank?
173
+ end
174
+
175
+ if app.compute_provider.scaleway
176
+ has_provider = true
177
+ s = app.compute_provider.scaleway
178
+ raise Errors::ConfigValidationError, "scaleway secret_key is required" if s.secret_key.blank?
179
+ raise Errors::ConfigValidationError, "scaleway project_id is required" if s.project_id.blank?
180
+ raise Errors::ConfigValidationError, "scaleway server_type is required" if s.server_type.blank?
181
+ raise Errors::ConfigValidationError, "scaleway architecture is required" if s.architecture.blank?
182
+ end
183
+
184
+ raise Errors::ConfigValidationError, "compute provider required: hetzner, aws, or scaleway must be configured" unless has_provider
185
+ end
186
+
187
+ def inject_database_env_vars
188
+ app = @deploy.application
189
+ return unless app.database
190
+
191
+ db = app.database
192
+ adapter = db.adapter&.downcase
193
+ return unless adapter
194
+
195
+ provider = External::Database.provider_for(adapter)
196
+ return unless provider.needs_container?
197
+
198
+ creds = parse_database_credentials(db, provider)
199
+ return unless creds
200
+
201
+ db_host = namer.database_service_name
202
+ env_vars = provider.app_env(creds, host: db_host)
203
+
204
+ app.app.each_value do |svc_config|
205
+ svc_config.env ||= {}
206
+ env_vars.each { |key, value| svc_config.env[key] ||= value }
207
+ end
208
+ end
209
+
210
+ def parse_database_credentials(db, provider)
211
+ return provider.parse_url(db.url) unless db.url.blank?
212
+
213
+ adapter = db.adapter&.downcase
214
+ case adapter
215
+ when "postgres", "postgresql"
216
+ External::Database::Types::Credentials.new(
217
+ user: db.secrets["POSTGRES_USER"],
218
+ password: db.secrets["POSTGRES_PASSWORD"],
219
+ database: db.secrets["POSTGRES_DB"],
220
+ port: provider.default_port
221
+ )
222
+ when "mysql"
223
+ External::Database::Types::Credentials.new(
224
+ user: db.secrets["MYSQL_USER"],
225
+ password: db.secrets["MYSQL_PASSWORD"],
226
+ database: db.secrets["MYSQL_DATABASE"],
227
+ port: provider.default_port
228
+ )
229
+ end
230
+ end
231
+
232
+ def validate_domain_uniqueness
233
+ app = @deploy.application
234
+ return unless app.app
235
+
236
+ seen = {}
237
+ app.app.each do |name, cfg|
238
+ next if cfg.domain.blank?
239
+
240
+ hostnames = Utils::Namer.build_hostnames(cfg.subdomain, cfg.domain)
241
+ hostnames.each do |hostname|
242
+ if seen[hostname]
243
+ raise Errors::ConfigValidationError,
244
+ "domain '#{hostname}' used by both '#{seen[hostname]}' and '#{name}'"
245
+ end
246
+ seen[hostname] = name
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Configuration
5
+ # ServerVolume defines a volume attached to a server
6
+ class ServerVolume
7
+ attr_accessor :size
8
+
9
+ def initialize(data = nil)
10
+ data ||= {}
11
+ raise ArgumentError, "volume config must be a hash with 'size' key" unless data.is_a?(Hash)
12
+
13
+ @size = data["size"]&.to_i || 10
14
+ end
15
+ end
16
+
17
+ # Server contains server instance configuration
18
+ class Server
19
+ attr_accessor :master, :type, :location, :count, :volumes
20
+
21
+ def initialize(data = nil)
22
+ data ||= {}
23
+ @master = data["master"] || false
24
+ @type = data["type"]
25
+ @location = data["location"]
26
+ @count = data["count"]&.to_i || 1
27
+ @volumes = (data["volumes"] || {}).transform_values { |v| ServerVolume.new(v) }
28
+ end
29
+
30
+ def master?
31
+ @master == true
32
+ end
33
+
34
+ def volume(name)
35
+ @volumes[name.to_s]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Configuration
5
+ # Service defines a generic service
6
+ class Service
7
+ attr_accessor :servers, :image, :port, :command, :env, :mount
8
+
9
+ def initialize(data = nil)
10
+ data ||= {}
11
+ @servers = data["servers"] || []
12
+ @image = data["image"]
13
+ @port = data["port"]&.to_i
14
+ @command = data["command"]
15
+ @env = data["env"] || {}
16
+ @mount = data["mount"] || {}
17
+ end
18
+
19
+ def to_service_spec(app_name, service_name)
20
+ cmd = @command ? @command.split : []
21
+ port = @port && @port.positive? ? @port : infer_port_from_image
22
+
23
+ Configuration::Deployment.new(
24
+ name: "#{app_name}-#{service_name}",
25
+ image: @image,
26
+ port:,
27
+ command: cmd,
28
+ env: @env,
29
+ mounts: @mount,
30
+ replicas: 1,
31
+ stateful_set: false,
32
+ servers: @servers
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def infer_port_from_image
39
+ case @image
40
+ when /redis/ then 6379
41
+ when /postgres/ then 5432
42
+ when /mysql/ then 3306
43
+ when /memcache/ then 11211
44
+ when /mongo/ then 27017
45
+ when /elastic/ then 9200
46
+ else 0
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Configuration
5
+ # SshKey defines SSH key content (stored in encrypted config)
6
+ class SshKey
7
+ attr_accessor :private_key, :public_key
8
+
9
+ def initialize(data = nil)
10
+ data ||= {}
11
+ @private_key = data["private_key"]
12
+ @public_key = data["public_key"]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -21,7 +21,7 @@ module Nvoi
21
21
  def find_or_create_network(name)
22
22
  vpc = find_vpc_by_name(name)
23
23
  if vpc
24
- return Objects::Network::Record.new(
24
+ return Types::Network::Record.new(
25
25
  id: vpc.vpc_id,
26
26
  name:,
27
27
  ip_range: vpc.cidr_block
@@ -89,7 +89,7 @@ module Nvoi
89
89
  subnet_id: subnet_resp.subnet.subnet_id
90
90
  )
91
91
 
92
- Objects::Network::Record.new(
92
+ Types::Network::Record.new(
93
93
  id: vpc_id,
94
94
  name:,
95
95
  ip_range: create_resp.vpc.cidr_block
@@ -100,7 +100,7 @@ module Nvoi
100
100
  vpc = find_vpc_by_name(name)
101
101
  raise Errors::NetworkError, "network not found: #{name}" unless vpc
102
102
 
103
- Objects::Network::Record.new(
103
+ Types::Network::Record.new(
104
104
  id: vpc.vpc_id,
105
105
  name:,
106
106
  ip_range: vpc.cidr_block
@@ -116,7 +116,7 @@ module Nvoi
116
116
  def find_or_create_firewall(name)
117
117
  sg = find_security_group_by_name(name)
118
118
  if sg
119
- return Objects::Firewall::Record.new(id: sg.group_id, name:)
119
+ return Types::Firewall::Record.new(id: sg.group_id, name:)
120
120
  end
121
121
 
122
122
  # Get default VPC
@@ -145,14 +145,14 @@ module Nvoi
145
145
  }]
146
146
  )
147
147
 
148
- Objects::Firewall::Record.new(id: create_resp.group_id, name:)
148
+ Types::Firewall::Record.new(id: create_resp.group_id, name:)
149
149
  end
150
150
 
151
151
  def get_firewall_by_name(name)
152
152
  sg = find_security_group_by_name(name)
153
153
  raise Errors::FirewallError, "firewall not found: #{name}" unless sg
154
154
 
155
- Objects::Firewall::Record.new(id: sg.group_id, name:)
155
+ Types::Firewall::Record.new(id: sg.group_id, name:)
156
156
  end
157
157
 
158
158
  def delete_firewall(id)
@@ -210,7 +210,7 @@ module Nvoi
210
210
  }
211
211
 
212
212
  # Add network configuration if provided
213
- if opts.network_id && !opts.network_id.empty?
213
+ unless opts.network_id.blank?
214
214
  subnets = @client.describe_subnets(
215
215
  filters: [{ name: "vpc-id", values: [opts.network_id] }]
216
216
  )
@@ -218,7 +218,7 @@ module Nvoi
218
218
  end
219
219
 
220
220
  # Add security group if provided
221
- if opts.firewall_id && !opts.firewall_id.empty?
221
+ unless opts.firewall_id.blank?
222
222
  input[:security_group_ids] = [opts.firewall_id]
223
223
  end
224
224
 
@@ -266,7 +266,7 @@ module Nvoi
266
266
  }]
267
267
  )
268
268
 
269
- Objects::Volume::Record.new(
269
+ Types::Volume::Record.new(
270
270
  id: create_resp.volume_id,
271
271
  name: opts.name,
272
272
  size: create_resp.size,
@@ -317,7 +317,7 @@ module Nvoi
317
317
  next nil if vol.attachments.empty?
318
318
 
319
319
  device = vol.attachments[0].device
320
- device if device && !device.empty?
320
+ device unless device.blank?
321
321
  end
322
322
  end
323
323
 
@@ -346,19 +346,29 @@ module Nvoi
346
346
 
347
347
  # List available instance types for onboarding
348
348
  def list_instance_types
349
- # Common instance types (full list is huge)
350
- common_types = %w[t3.micro t3.small t3.medium t3.large t3.xlarge m5.large m5.xlarge c5.large c5.xlarge]
349
+ # Common instance types including ARM (Graviton)
350
+ common_types = %w[
351
+ t3.micro t3.small t3.medium t3.large t3.xlarge
352
+ t4g.micro t4g.small t4g.medium t4g.large t4g.xlarge
353
+ m5.large m5.xlarge m6g.large m6g.xlarge
354
+ c5.large c5.xlarge c6g.large c6g.xlarge
355
+ ]
351
356
  resp = @client.describe_instance_types(instance_types: common_types)
352
357
  resp.instance_types.map do |t|
358
+ arch = t.processor_info&.supported_architectures&.first || "x86_64"
353
359
  {
354
360
  name: t.instance_type,
355
361
  vcpus: t.v_cpu_info.default_v_cpus,
356
- memory: t.memory_info.size_in_mi_b
362
+ memory: t.memory_info.size_in_mi_b,
363
+ architecture: arch.include?("arm") ? "arm64" : "x86"
357
364
  }
358
365
  end
359
366
  rescue StandardError
360
367
  # Fallback to static list if API fails
361
- common_types.map { |t| { name: t, vcpus: nil, memory: nil } }
368
+ common_types.map do |t|
369
+ arch = t.include?("g.") ? "arm64" : "x86" # Graviton types have 'g' suffix
370
+ { name: t, vcpus: nil, memory: nil, architecture: arch }
371
+ end
362
372
  end
363
373
 
364
374
  # List available regions for onboarding
@@ -417,7 +427,7 @@ module Nvoi
417
427
  def instance_to_server(instance)
418
428
  name = instance.tags&.find { |t| t.key == "Name" }&.value || ""
419
429
 
420
- Objects::Server::Record.new(
430
+ Types::Server::Record.new(
421
431
  id: instance.instance_id,
422
432
  name:,
423
433
  status: instance.state.name,
@@ -429,7 +439,7 @@ module Nvoi
429
439
  def volume_to_object(vol)
430
440
  name = vol.tags&.find { |t| t.key == "Name" }&.value || ""
431
441
 
432
- v = Objects::Volume::Record.new(
442
+ v = Types::Volume::Record.new(
433
443
  id: vol.volume_id,
434
444
  name:,
435
445
  size: vol.size,
@@ -109,25 +109,22 @@ module Nvoi
109
109
  image = find_image(opts.image)
110
110
  raise Errors::ValidationError, "invalid image: #{opts.image}" unless image
111
111
 
112
- location = find_location(opts.location)
113
- raise Errors::ValidationError, "invalid location: #{opts.location}" unless location
114
-
115
112
  create_opts = {
116
113
  name: opts.name,
117
114
  server_type: server_type["name"],
118
115
  image: image["name"],
119
- location: location["name"],
116
+ datacenter: opts.location,
120
117
  user_data: opts.user_data,
121
118
  start_after_create: true
122
119
  }
123
120
 
124
121
  # Add network if provided
125
- if opts.network_id && !opts.network_id.empty?
122
+ unless opts.network_id.blank?
126
123
  create_opts[:networks] = [opts.network_id.to_i]
127
124
  end
128
125
 
129
126
  # Add firewall if provided
130
- if opts.firewall_id && !opts.firewall_id.empty?
127
+ unless opts.firewall_id.blank?
131
128
  create_opts[:firewalls] = [{ firewall: opts.firewall_id.to_i }]
132
129
  end
133
130
 
@@ -216,7 +213,7 @@ module Nvoi
216
213
  # Hetzner provides device_path in API response
217
214
  Utils::Retry.poll(max_attempts: 30, interval: 2) do
218
215
  volume = get("/volumes/#{volume_id.to_i}")["volume"]
219
- volume["linux_device"] if volume && volume["linux_device"] && !volume["linux_device"].empty?
216
+ volume&.dig("linux_device").then { |d| d unless d.blank? }
220
217
  end
221
218
  end
222
219
 
@@ -230,8 +227,8 @@ module Nvoi
230
227
  end
231
228
 
232
229
  def validate_region(region)
233
- location = find_location(region)
234
- raise Errors::ValidationError, "invalid hetzner location: #{region}" unless location
230
+ datacenter = find_datacenter(region)
231
+ raise Errors::ValidationError, "invalid hetzner datacenter: #{region}" unless datacenter
235
232
 
236
233
  true
237
234
  end
@@ -244,27 +241,45 @@ module Nvoi
244
241
  end
245
242
 
246
243
  # List available server types for onboarding
247
- def list_server_types
248
- get("/server_types")["server_types"].map do |t|
249
- {
244
+ # When location (datacenter) is provided, filters to only actually available types
245
+ def list_server_types(location: nil)
246
+ all_types = get("/server_types")["server_types"]
247
+
248
+ # If no location specified, return all with basic info
249
+ types_hash = all_types.each_with_object({}) do |t, h|
250
+ h[t["id"]] = {
251
+ id: t["id"],
250
252
  name: t["name"],
251
253
  description: t["description"],
252
254
  cores: t["cores"],
253
255
  memory: t["memory"],
254
256
  disk: t["disk"],
255
- price: t.dig("prices", 0, "price_monthly", "gross")
257
+ cpu_type: t["cpu_type"],
258
+ architecture: t["architecture"],
259
+ prices: t["prices"]
256
260
  }
257
261
  end
262
+
263
+ if location
264
+ # Filter by datacenter's actually available server types
265
+ datacenter = get("/datacenters")["datacenters"].find { |d| d["name"] == location }
266
+ return [] unless datacenter
267
+
268
+ available_ids = datacenter.dig("server_types", "available") || []
269
+ types_hash.values_at(*available_ids).compact
270
+ else
271
+ types_hash.values
272
+ end
258
273
  end
259
274
 
260
- # List available locations for onboarding
275
+ # List available datacenters for onboarding (returns datacenter-level granularity)
261
276
  def list_locations
262
- get("/locations")["locations"].map do |l|
277
+ get("/datacenters")["datacenters"].map do |d|
263
278
  {
264
- name: l["name"],
265
- city: l["city"],
266
- country: l["country"],
267
- description: l["description"]
279
+ name: d["name"],
280
+ city: d.dig("location", "city"),
281
+ country: d.dig("location", "country"),
282
+ description: d["description"]
268
283
  }
269
284
  end
270
285
  end
@@ -331,8 +346,8 @@ module Nvoi
331
346
  response["images"]&.first
332
347
  end
333
348
 
334
- def find_location(name)
335
- get("/locations")["locations"].find { |l| l["name"] == name }
349
+ def find_datacenter(name)
350
+ get("/datacenters")["datacenters"].find { |d| d["name"] == name }
336
351
  end
337
352
 
338
353
  def create_network_api(payload)
@@ -358,7 +373,7 @@ module Nvoi
358
373
  end
359
374
 
360
375
  def to_network(data)
361
- Objects::Network::Record.new(
376
+ Types::Network::Record.new(
362
377
  id: data["id"].to_s,
363
378
  name: data["name"],
364
379
  ip_range: data["ip_range"]
@@ -366,7 +381,7 @@ module Nvoi
366
381
  end
367
382
 
368
383
  def to_firewall(data)
369
- Objects::Firewall::Record.new(
384
+ Types::Firewall::Record.new(
370
385
  id: data["id"].to_s,
371
386
  name: data["name"]
372
387
  )
@@ -376,7 +391,7 @@ module Nvoi
376
391
  # Get private IP from private_net array
377
392
  private_ip = data["private_net"]&.first&.dig("ip")
378
393
 
379
- Objects::Server::Record.new(
394
+ Types::Server::Record.new(
380
395
  id: data["id"].to_s,
381
396
  name: data["name"],
382
397
  status: data["status"],
@@ -386,7 +401,7 @@ module Nvoi
386
401
  end
387
402
 
388
403
  def to_volume(data)
389
- Objects::Volume::Record.new(
404
+ Types::Volume::Record.new(
390
405
  id: data["id"].to_s,
391
406
  name: data["name"],
392
407
  size: data["size"],