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.
- checksums.yaml +4 -4
- data/Gemfile +1 -5
- data/Gemfile.lock +17 -8
- data/Rakefile +1 -1
- data/_TODO-rails-example.md +816 -0
- data/_TODO-rails-optimization.md +433 -0
- data/doc/config-schema.yaml +12 -0
- data/examples/apex-wildcard/deploy.yml +1 -0
- data/examples/golang-postgres-multi/deploy.yml +1 -0
- data/examples/postgres-multi/deploy.yml +1 -0
- data/examples/postgres-single/deploy.yml +1 -0
- data/examples/rails-single/deploy.yml +1 -0
- data/lib/nvoi/cli/config/command.rb +46 -41
- data/lib/nvoi/cli/credentials/edit/command.rb +24 -20
- data/lib/nvoi/cli/credentials/show/command.rb +1 -1
- data/lib/nvoi/cli/db/command.rb +10 -10
- data/lib/nvoi/cli/delete/command.rb +2 -2
- data/lib/nvoi/cli/deploy/command.rb +2 -2
- data/lib/nvoi/cli/deploy/steps/build_image.rb +2 -1
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +2 -2
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +7 -4
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +1 -1
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +1 -1
- data/lib/nvoi/cli/exec/command.rb +3 -3
- data/lib/nvoi/cli/logs/command.rb +2 -2
- data/lib/nvoi/cli/onboard/command.rb +176 -622
- data/lib/nvoi/cli/onboard/steps/app.rb +108 -0
- data/lib/nvoi/cli/onboard/steps/app_name.rb +26 -0
- data/lib/nvoi/cli/onboard/steps/compute.rb +186 -0
- data/lib/nvoi/cli/onboard/steps/database.rb +97 -0
- data/lib/nvoi/cli/onboard/steps/domain.rb +48 -0
- data/lib/nvoi/cli/onboard/steps/env.rb +67 -0
- data/lib/nvoi/cli/onboard/ui.rb +84 -0
- data/lib/nvoi/cli/unlock/command.rb +2 -2
- data/lib/nvoi/cli.rb +2 -33
- data/lib/nvoi/configuration/app_service.rb +54 -0
- data/lib/nvoi/configuration/application.rb +44 -0
- data/lib/nvoi/configuration/builder.rb +420 -0
- data/lib/nvoi/configuration/database.rb +56 -0
- data/lib/nvoi/configuration/deploy.rb +15 -0
- data/lib/nvoi/{objects/service_spec.rb → configuration/deployment.rb} +4 -3
- data/lib/nvoi/{objects/config_override.rb → configuration/override.rb} +4 -4
- data/lib/nvoi/configuration/providers.rb +81 -0
- data/lib/nvoi/configuration/result.rb +43 -0
- data/lib/nvoi/configuration/root.rb +252 -0
- data/lib/nvoi/configuration/server.rb +39 -0
- data/lib/nvoi/configuration/service.rb +51 -0
- data/lib/nvoi/configuration/ssh_key.rb +16 -0
- data/lib/nvoi/external/cloud/aws.rb +26 -16
- data/lib/nvoi/external/cloud/hetzner.rb +40 -25
- data/lib/nvoi/external/cloud/scaleway.rb +10 -8
- data/lib/nvoi/external/cloud/types.rb +42 -0
- data/lib/nvoi/external/database/mysql.rb +1 -1
- data/lib/nvoi/external/database/postgres.rb +1 -1
- data/lib/nvoi/external/database/provider.rb +1 -1
- data/lib/nvoi/external/database/sqlite.rb +1 -1
- data/lib/nvoi/external/database/types.rb +55 -0
- data/lib/nvoi/external/dns/cloudflare.rb +11 -11
- data/lib/nvoi/external/dns/types.rb +24 -0
- data/lib/nvoi/utils/config_loader.rb +12 -12
- data/lib/nvoi/utils/credential_store.rb +4 -4
- data/lib/nvoi/utils/env_resolver.rb +3 -3
- data/lib/nvoi/utils/namer.rb +8 -3
- data/lib/nvoi/utils/presence.rb +23 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +2 -17
- metadata +98 -59
- data/.claude/todo/refactor/00-overview.md +0 -171
- data/.claude/todo/refactor/01-objects.md +0 -96
- data/.claude/todo/refactor/02-utils.md +0 -143
- data/.claude/todo/refactor/03-external-cloud.md +0 -164
- data/.claude/todo/refactor/04-external-dns.md +0 -104
- data/.claude/todo/refactor/05-external.md +0 -133
- data/.claude/todo/refactor/06-cli.md +0 -123
- data/.claude/todo/refactor/07-cli-deploy-command.md +0 -177
- data/.claude/todo/refactor/08-cli-deploy-steps.md +0 -201
- data/.claude/todo/refactor/09-cli-delete-command.md +0 -169
- data/.claude/todo/refactor/10-cli-exec-command.md +0 -157
- data/.claude/todo/refactor/11-cli-credentials-command.md +0 -190
- data/.claude/todo/refactor/12-cli-db-command.md +0 -128
- data/.claude/todo/refactor/_target.md +0 -79
- data/.claude/todo/refactor-execution/00-entrypoint.md +0 -49
- data/.claude/todo/refactor-execution/01-objects.md +0 -42
- data/.claude/todo/refactor-execution/02-utils.md +0 -41
- data/.claude/todo/refactor-execution/03-external-cloud.md +0 -38
- data/.claude/todo/refactor-execution/04-external-dns.md +0 -35
- data/.claude/todo/refactor-execution/05-external-other.md +0 -46
- data/.claude/todo/refactor-execution/06-cli-deploy.md +0 -45
- data/.claude/todo/refactor-execution/07-cli-delete.md +0 -43
- data/.claude/todo/refactor-execution/08-cli-exec.md +0 -30
- data/.claude/todo/refactor-execution/09-cli-credentials.md +0 -34
- data/.claude/todo/refactor-execution/10-cli-db.md +0 -31
- data/.claude/todo/refactor-execution/11-cli-router.md +0 -44
- data/.claude/todo/refactor-execution/12-cleanup.md +0 -120
- data/.claude/todo/refactor-execution/_monitoring-strategy.md +0 -126
- data/.claude/todo/scaleway.impl.md +0 -644
- data/.claude/todo/scaleway.reference.md +0 -520
- data/.claude/todos/buckets.md +0 -41
- data/.claude/todos.md +0 -550
- data/Makefile +0 -26
- data/ingest +0 -0
- data/lib/nvoi/config_api/actions/app.rb +0 -53
- data/lib/nvoi/config_api/actions/compute_provider.rb +0 -55
- data/lib/nvoi/config_api/actions/database.rb +0 -70
- data/lib/nvoi/config_api/actions/domain_provider.rb +0 -40
- data/lib/nvoi/config_api/actions/env.rb +0 -32
- data/lib/nvoi/config_api/actions/init.rb +0 -67
- data/lib/nvoi/config_api/actions/secret.rb +0 -32
- data/lib/nvoi/config_api/actions/server.rb +0 -66
- data/lib/nvoi/config_api/actions/service.rb +0 -52
- data/lib/nvoi/config_api/actions/volume.rb +0 -40
- data/lib/nvoi/config_api/base.rb +0 -38
- data/lib/nvoi/config_api/result.rb +0 -26
- data/lib/nvoi/config_api.rb +0 -93
- data/lib/nvoi/objects/configuration.rb +0 -483
- data/lib/nvoi/objects/database.rb +0 -56
- data/lib/nvoi/objects/dns.rb +0 -14
- data/lib/nvoi/objects/firewall.rb +0 -11
- data/lib/nvoi/objects/network.rb +0 -11
- data/lib/nvoi/objects/server.rb +0 -14
- data/lib/nvoi/objects/tunnel.rb +0 -14
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
350
|
-
common_types = %w[
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
raise Errors::ValidationError, "invalid hetzner
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
275
|
+
# List available datacenters for onboarding (returns datacenter-level granularity)
|
|
261
276
|
def list_locations
|
|
262
|
-
get("/
|
|
277
|
+
get("/datacenters")["datacenters"].map do |d|
|
|
263
278
|
{
|
|
264
|
-
name:
|
|
265
|
-
city:
|
|
266
|
-
country:
|
|
267
|
-
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
|
|
335
|
-
get("/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
+
Types::Volume::Record.new(
|
|
390
405
|
id: data["id"].to_s,
|
|
391
406
|
name: data["name"],
|
|
392
407
|
size: data["size"],
|