nvoi 0.2.0 → 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.
@@ -40,31 +40,66 @@ module Nvoi
40
40
  @client = client
41
41
  end
42
42
 
43
- types, locations = with_spinner("Fetching options...") do
44
- [@client.list_server_types, @client.list_locations]
45
- end
46
-
47
- type_choices = types.sort_by { |t| t[:name] }.map do |t|
48
- price = t[:price] ? " - #{t[:price]}/mo" : ""
49
- { name: "#{t[:name]} (#{t[:cores]} vCPU, #{t[:memory] / 1024}GB#{price})", value: t[:name] }
43
+ locations = with_spinner("Fetching datacenters...") do
44
+ @client.list_locations
50
45
  end
51
46
 
47
+ # Step 1: Pick datacenter first
52
48
  location_choices = locations.map do |l|
53
49
  { name: "#{l[:name]} (#{l[:city]}, #{l[:country]})", value: l[:name] }
54
50
  end
55
51
 
56
- server_type = @prompt.select("Server type:", type_choices, per_page: 10)
57
- location = @prompt.select("Location:", location_choices)
52
+ location = @prompt.select("Datacenter:", location_choices)
53
+
54
+ # Step 2: Fetch server types available at selected datacenter
55
+ available_types = with_spinner("Fetching available server types...") do
56
+ @client.list_server_types(location: location)
57
+ end
58
+
59
+ type_choices = available_types.sort_by { |t| t[:name] }.map do |t|
60
+ price = price_for_datacenter(t[:prices], location)
61
+ price_str = price ? " - €#{price}/mo" : ""
62
+ memory_gb = t[:memory].to_f.round(1)
63
+ cpu_info = cpu_label(t[:cpu_type], t[:architecture])
64
+ { name: "#{t[:name]} (#{t[:cores]} vCPU, #{memory_gb}GB, #{cpu_info}#{price_str})", value: t[:name] }
65
+ end
66
+
67
+ server_type = @prompt.select("Server type:", type_choices, per_page: 10, filter: true)
68
+
69
+ # Get architecture for selected server type
70
+ selected_type = available_types.find { |t| t[:name] == server_type }
71
+ arch = selected_type&.dig(:architecture) || "x86"
58
72
 
59
73
  {
60
74
  "hetzner" => {
61
75
  "api_token" => token,
62
76
  "server_type" => server_type,
63
- "server_location" => location
77
+ "server_location" => location,
78
+ "architecture" => arch
64
79
  }
65
80
  }
66
81
  end
67
82
 
83
+ def price_for_datacenter(prices, datacenter)
84
+ return nil unless prices
85
+
86
+ # Datacenter name is like "fsn1-dc14", location in prices is "fsn1"
87
+ location = datacenter.split("-").first
88
+ price_entry = prices.find { |p| p["location"] == location }
89
+ return nil unless price_entry
90
+
91
+ gross = price_entry.dig("price_monthly", "gross")
92
+ return nil unless gross
93
+
94
+ gross.to_f.round(2)
95
+ end
96
+
97
+ def cpu_label(cpu_type, architecture)
98
+ arch = architecture == "arm" ? "ARM" : "x86"
99
+ type = cpu_type == "dedicated" ? "dedicated" : "shared"
100
+ "#{arch}/#{type}"
101
+ end
102
+
68
103
  def setup_aws
69
104
  access_key = prompt_with_retry("AWS Access Key ID:") do |k|
70
105
  raise Errors::ValidationError, "Invalid format" unless k.match?(/\AAKIA/)
@@ -86,17 +121,23 @@ module Nvoi
86
121
 
87
122
  type_choices = types.map do |t|
88
123
  mem = t[:memory] ? " #{t[:memory] / 1024}GB" : ""
89
- { name: "#{t[:name]} (#{t[:vcpus]} vCPU#{mem})", value: t[:name] }
124
+ arch_label = t[:architecture] == "arm64" ? " ARM" : ""
125
+ { name: "#{t[:name]} (#{t[:vcpus]} vCPU#{mem}#{arch_label})", value: t[:name] }
90
126
  end
91
127
 
92
128
  instance_type = @prompt.select("Instance type:", type_choices)
93
129
 
130
+ # Get architecture for selected instance type
131
+ selected_type = types.find { |t| t[:name] == instance_type }
132
+ arch = selected_type&.dig(:architecture) || "x86"
133
+
94
134
  {
95
135
  "aws" => {
96
136
  "access_key_id" => access_key,
97
137
  "secret_access_key" => secret_key,
98
138
  "region" => region,
99
- "instance_type" => instance_type
139
+ "instance_type" => instance_type,
140
+ "architecture" => arch
100
141
  }
101
142
  }
102
143
  end
@@ -118,17 +159,23 @@ module Nvoi
118
159
  end
119
160
 
120
161
  type_choices = types.map do |t|
121
- { name: "#{t[:name]} (#{t[:cores]} cores)", value: t[:name] }
162
+ arch_label = t[:architecture] == "arm64" ? " ARM" : ""
163
+ { name: "#{t[:name]} (#{t[:cores]} cores#{arch_label})", value: t[:name] }
122
164
  end
123
165
 
124
166
  server_type = @prompt.select("Server type:", type_choices, per_page: 10, filter: true)
125
167
 
168
+ # Get architecture for selected server type
169
+ selected_type = types.find { |t| t[:name] == server_type }
170
+ arch = selected_type&.dig(:architecture) || "x86"
171
+
126
172
  {
127
173
  "scaleway" => {
128
174
  "secret_key" => secret_key,
129
175
  "project_id" => project_id,
130
176
  "zone" => zone,
131
- "server_type" => server_type
177
+ "server_type" => server_type,
178
+ "architecture" => arch
132
179
  }
133
180
  }
134
181
  end
data/lib/nvoi/cli.rb CHANGED
@@ -114,6 +114,7 @@ module Nvoi
114
114
  option :api_token, desc: "API token (hetzner)"
115
115
  option :server_type, desc: "Server type (cx22, etc)"
116
116
  option :server_location, desc: "Location (fsn1, etc)"
117
+ option :architecture, desc: "CPU architecture (x86, arm64)"
117
118
  option :access_key_id, desc: "AWS access key ID"
118
119
  option :secret_access_key, desc: "AWS secret access key"
119
120
  option :region, desc: "AWS region"
@@ -123,7 +124,7 @@ module Nvoi
123
124
  option :zone, desc: "Scaleway zone"
124
125
  def set(provider)
125
126
  Nvoi::Cli::Config::Command.new(options).provider_set(provider, **options.slice(
126
- :api_token, :server_type, :server_location,
127
+ :api_token, :server_type, :server_location, :architecture,
127
128
  :access_key_id, :secret_access_key, :region, :instance_type,
128
129
  :secret_key, :project_id, :zone
129
130
  ).transform_keys(&:to_sym).compact)
@@ -357,21 +357,24 @@ module Nvoi
357
357
  {
358
358
  "api_token" => opts[:api_token],
359
359
  "server_type" => opts[:server_type],
360
- "server_location" => opts[:server_location]
360
+ "server_location" => opts[:server_location],
361
+ "architecture" => opts[:architecture]
361
362
  }.compact
362
363
  when "aws"
363
364
  {
364
365
  "access_key_id" => opts[:access_key_id],
365
366
  "secret_access_key" => opts[:secret_access_key],
366
367
  "region" => opts[:region],
367
- "instance_type" => opts[:instance_type]
368
+ "instance_type" => opts[:instance_type],
369
+ "architecture" => opts[:architecture]
368
370
  }.compact
369
371
  when "scaleway"
370
372
  {
371
373
  "secret_key" => opts[:secret_key],
372
374
  "project_id" => opts[:project_id],
373
375
  "zone" => opts[:zone],
374
- "server_type" => opts[:server_type]
376
+ "server_type" => opts[:server_type],
377
+ "architecture" => opts[:architecture]
375
378
  }.compact
376
379
  end
377
380
  end
@@ -38,19 +38,20 @@ module Nvoi
38
38
 
39
39
  # Hetzner contains Hetzner-specific configuration
40
40
  class Hetzner
41
- attr_accessor :api_token, :server_type, :server_location
41
+ attr_accessor :api_token, :server_type, :server_location, :architecture
42
42
 
43
43
  def initialize(data = nil)
44
44
  data ||= {}
45
45
  @api_token = data["api_token"]
46
46
  @server_type = data["server_type"]
47
47
  @server_location = data["server_location"]
48
+ @architecture = data["architecture"]
48
49
  end
49
50
  end
50
51
 
51
52
  # AwsCfg contains AWS-specific configuration
52
53
  class AwsCfg
53
- attr_accessor :access_key_id, :secret_access_key, :region, :instance_type
54
+ attr_accessor :access_key_id, :secret_access_key, :region, :instance_type, :architecture
54
55
 
55
56
  def initialize(data = nil)
56
57
  data ||= {}
@@ -58,12 +59,13 @@ module Nvoi
58
59
  @secret_access_key = data["secret_access_key"]
59
60
  @region = data["region"]
60
61
  @instance_type = data["instance_type"]
62
+ @architecture = data["architecture"]
61
63
  end
62
64
  end
63
65
 
64
66
  # Scaleway contains Scaleway-specific configuration
65
67
  class Scaleway
66
- attr_accessor :secret_key, :project_id, :zone, :server_type
68
+ attr_accessor :secret_key, :project_id, :zone, :server_type, :architecture
67
69
 
68
70
  def initialize(data = nil)
69
71
  data ||= {}
@@ -71,6 +73,7 @@ module Nvoi
71
73
  @project_id = data["project_id"]
72
74
  @zone = data["zone"] || "fr-par-1"
73
75
  @server_type = data["server_type"]
76
+ @architecture = data["architecture"]
74
77
  end
75
78
  end
76
79
  end
@@ -53,6 +53,21 @@ module Nvoi
53
53
  @deploy.application.domain_provider.cloudflare
54
54
  end
55
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
+
56
71
  def keep_count_value
57
72
  count = @deploy.application.keep_count
58
73
  count && count.positive? ? count : 2
@@ -144,6 +159,7 @@ module Nvoi
144
159
  raise Errors::ConfigValidationError, "hetzner api_token is required" if h.api_token.blank?
145
160
  raise Errors::ConfigValidationError, "hetzner server_type is required" if h.server_type.blank?
146
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?
147
163
  end
148
164
 
149
165
  if app.compute_provider.aws
@@ -153,6 +169,7 @@ module Nvoi
153
169
  raise Errors::ConfigValidationError, "aws secret_access_key is required" if a.secret_access_key.blank?
154
170
  raise Errors::ConfigValidationError, "aws region is required" if a.region.blank?
155
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?
156
173
  end
157
174
 
158
175
  if app.compute_provider.scaleway
@@ -161,6 +178,7 @@ module Nvoi
161
178
  raise Errors::ConfigValidationError, "scaleway secret_key is required" if s.secret_key.blank?
162
179
  raise Errors::ConfigValidationError, "scaleway project_id is required" if s.project_id.blank?
163
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?
164
182
  end
165
183
 
166
184
  raise Errors::ConfigValidationError, "compute provider required: hetzner, aws, or scaleway must be configured" unless has_provider
@@ -47,16 +47,5 @@ module Nvoi
47
47
  end
48
48
  end
49
49
  end
50
-
51
- # SshKey defines SSH key content (stored in encrypted config)
52
- class SshKey
53
- attr_accessor :private_key, :public_key
54
-
55
- def initialize(data = nil)
56
- data ||= {}
57
- @private_key = data["private_key"]
58
- @public_key = data["public_key"]
59
- end
60
- end
61
50
  end
62
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
@@ -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
@@ -109,14 +109,11 @@ 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
  }
@@ -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)
@@ -313,11 +313,13 @@ module Nvoi
313
313
  # List available server types for onboarding
314
314
  def list_server_types
315
315
  list_server_types_api.map do |name, info|
316
+ arch = info.dig("arch") || "x86_64"
316
317
  {
317
318
  name:,
318
319
  cores: info.dig("ncpus"),
319
320
  ram: info.dig("ram"),
320
- hourly_price: info.dig("hourly_price")
321
+ hourly_price: info.dig("hourly_price"),
322
+ architecture: arch.include?("arm") ? "arm64" : "x86"
321
323
  }
322
324
  end
323
325
  end
@@ -69,11 +69,11 @@ module Nvoi
69
69
  url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
70
70
 
71
71
  ingress_rules = hostnames.map do |hostname|
72
- {
73
- hostname:,
74
- service: service_url,
75
- originRequest: { httpHostHeader: hostname.sub(/^\*\./, "") } # Use apex for wildcard
76
- }
72
+ rule = { hostname:, service: service_url }
73
+ # Only set httpHostHeader for non-wildcard hostnames
74
+ # Wildcards should pass through the original Host header
75
+ rule[:originRequest] = { httpHostHeader: hostname } unless hostname.start_with?("*.")
76
+ rule
77
77
  end
78
78
  ingress_rules << { service: "http_status:404" }
79
79
 
@@ -33,7 +33,7 @@ module Nvoi
33
33
 
34
34
  # ServerName returns the server name for a given group and index
35
35
  def server_name(group, index)
36
- "#{@config.deploy.application.name}-#{group}-#{index}"
36
+ "#{sanitize_name(@config.deploy.application.name)}-#{group}-#{index}"
37
37
  end
38
38
 
39
39
  def firewall_name
@@ -196,6 +196,11 @@ module Nvoi
196
196
 
197
197
  private
198
198
 
199
+ # Sanitize name for cloud provider compatibility (no underscores, lowercase, etc.)
200
+ def sanitize_name(name)
201
+ name.to_s.gsub("_", "-").downcase
202
+ end
203
+
199
204
  def hash_string(str)
200
205
  Digest::SHA256.hexdigest(str)[0, 16]
201
206
  end
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.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nvoi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NVOI
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-13 00:00:00.000000000 Z
11
+ date: 2025-12-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -246,8 +246,9 @@ files:
246
246
  - ".rubocop.yml"
247
247
  - Gemfile
248
248
  - Gemfile.lock
249
- - Makefile
250
249
  - Rakefile
250
+ - _TODO-rails-example.md
251
+ - _TODO-rails-optimization.md
251
252
  - doc/config-schema.yaml
252
253
  - examples/apex-wildcard/deploy.yml
253
254
  - examples/golang-postgres-multi/.gitignore
@@ -412,6 +413,7 @@ files:
412
413
  - lib/nvoi/configuration/root.rb
413
414
  - lib/nvoi/configuration/server.rb
414
415
  - lib/nvoi/configuration/service.rb
416
+ - lib/nvoi/configuration/ssh_key.rb
415
417
  - lib/nvoi/errors.rb
416
418
  - lib/nvoi/external/cloud.rb
417
419
  - lib/nvoi/external/cloud/aws.rb
data/Makefile DELETED
@@ -1,26 +0,0 @@
1
- NVOI = ruby -I$(PWD)/lib $(PWD)/exe/nvoi
2
- EXAMPLES = $(PWD)/examples
3
-
4
- deploy-golang:
5
- cd $(EXAMPLES)/golang && $(NVOI) deploy
6
-
7
- exec-golang:
8
- cd $(EXAMPLES)/golang && $(NVOI) exec -i
9
-
10
- show-golang:
11
- cd $(EXAMPLES)/golang && $(NVOI) credentials show
12
-
13
- delete-golang:
14
- cd $(EXAMPLES)/golang && $(NVOI) delete
15
-
16
- deploy-rails:
17
- cd $(EXAMPLES)/rails-single && $(NVOI) deploy
18
-
19
- delete-rails:
20
- cd $(EXAMPLES)/rails-single && $(NVOI) delete
21
-
22
- test:
23
- bundle exec rake test
24
-
25
- lint:
26
- bundle exec rubocop