nvoi 0.1.5

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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +19 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +151 -0
  5. data/Makefile +26 -0
  6. data/Rakefile +16 -0
  7. data/doc/config-schema.yaml +357 -0
  8. data/examples/apex-wildcard/deploy.yml +68 -0
  9. data/examples/golang/.gitignore +19 -0
  10. data/examples/golang/Dockerfile +43 -0
  11. data/examples/golang/README.md +59 -0
  12. data/examples/golang/deploy.enc +0 -0
  13. data/examples/golang/deploy.yml +54 -0
  14. data/examples/golang/go.mod +39 -0
  15. data/examples/golang/go.sum +96 -0
  16. data/examples/golang/main.go +177 -0
  17. data/examples/golang/models/user.go +17 -0
  18. data/examples/golang-postgres-multi/.gitignore +18 -0
  19. data/examples/golang-postgres-multi/Dockerfile +39 -0
  20. data/examples/golang-postgres-multi/README.md +211 -0
  21. data/examples/golang-postgres-multi/deploy.yml +67 -0
  22. data/examples/golang-postgres-multi/go.mod +45 -0
  23. data/examples/golang-postgres-multi/go.sum +108 -0
  24. data/examples/golang-postgres-multi/main.go +197 -0
  25. data/examples/golang-postgres-multi/models/user.go +17 -0
  26. data/examples/postgres-multi/.env.production.example +11 -0
  27. data/examples/postgres-multi/README.md +112 -0
  28. data/examples/postgres-multi/deploy.yml +74 -0
  29. data/examples/postgres-single/.env.production.example +11 -0
  30. data/examples/postgres-single/.gitignore +15 -0
  31. data/examples/postgres-single/Dockerfile +35 -0
  32. data/examples/postgres-single/README.md +76 -0
  33. data/examples/postgres-single/deploy.yml +56 -0
  34. data/examples/postgres-single/go.mod +45 -0
  35. data/examples/postgres-single/go.sum +108 -0
  36. data/examples/postgres-single/main.go +184 -0
  37. data/examples/rails-single/.dockerignore +51 -0
  38. data/examples/rails-single/.env.production.example +11 -0
  39. data/examples/rails-single/.github/dependabot.yml +12 -0
  40. data/examples/rails-single/.github/workflows/ci.yml +39 -0
  41. data/examples/rails-single/.gitignore +20 -0
  42. data/examples/rails-single/.node-version +1 -0
  43. data/examples/rails-single/.rubocop.yml +8 -0
  44. data/examples/rails-single/.ruby-version +1 -0
  45. data/examples/rails-single/Dockerfile +86 -0
  46. data/examples/rails-single/Gemfile +56 -0
  47. data/examples/rails-single/Gemfile.lock +350 -0
  48. data/examples/rails-single/Procfile.dev +3 -0
  49. data/examples/rails-single/README.md +17 -0
  50. data/examples/rails-single/Rakefile +6 -0
  51. data/examples/rails-single/app/assets/builds/.keep +0 -0
  52. data/examples/rails-single/app/assets/images/.keep +0 -0
  53. data/examples/rails-single/app/assets/stylesheets/application.tailwind.css +1 -0
  54. data/examples/rails-single/app/controllers/application_controller.rb +4 -0
  55. data/examples/rails-single/app/controllers/concerns/.keep +0 -0
  56. data/examples/rails-single/app/controllers/users_controller.rb +19 -0
  57. data/examples/rails-single/app/helpers/application_helper.rb +2 -0
  58. data/examples/rails-single/app/javascript/application.js +3 -0
  59. data/examples/rails-single/app/javascript/controllers/application.js +9 -0
  60. data/examples/rails-single/app/javascript/controllers/hello_controller.js +7 -0
  61. data/examples/rails-single/app/javascript/controllers/index.js +8 -0
  62. data/examples/rails-single/app/jobs/application_job.rb +7 -0
  63. data/examples/rails-single/app/mailers/application_mailer.rb +4 -0
  64. data/examples/rails-single/app/models/application_record.rb +3 -0
  65. data/examples/rails-single/app/models/concerns/.keep +0 -0
  66. data/examples/rails-single/app/models/user.rb +2 -0
  67. data/examples/rails-single/app/views/layouts/application.html.erb +28 -0
  68. data/examples/rails-single/app/views/layouts/mailer.html.erb +13 -0
  69. data/examples/rails-single/app/views/layouts/mailer.text.erb +1 -0
  70. data/examples/rails-single/app/views/pwa/manifest.json.erb +22 -0
  71. data/examples/rails-single/app/views/pwa/service-worker.js +26 -0
  72. data/examples/rails-single/app/views/users/index.html.erb +38 -0
  73. data/examples/rails-single/bin/brakeman +7 -0
  74. data/examples/rails-single/bin/bundle +109 -0
  75. data/examples/rails-single/bin/dev +11 -0
  76. data/examples/rails-single/bin/docker-entrypoint +14 -0
  77. data/examples/rails-single/bin/jobs +6 -0
  78. data/examples/rails-single/bin/kamal +27 -0
  79. data/examples/rails-single/bin/rails +4 -0
  80. data/examples/rails-single/bin/rake +4 -0
  81. data/examples/rails-single/bin/rubocop +8 -0
  82. data/examples/rails-single/bin/setup +37 -0
  83. data/examples/rails-single/bin/thrust +5 -0
  84. data/examples/rails-single/bun.lock +224 -0
  85. data/examples/rails-single/config/application.rb +42 -0
  86. data/examples/rails-single/config/boot.rb +4 -0
  87. data/examples/rails-single/config/cable.yml +17 -0
  88. data/examples/rails-single/config/cache.yml +16 -0
  89. data/examples/rails-single/config/credentials.yml.enc +1 -0
  90. data/examples/rails-single/config/database.yml +100 -0
  91. data/examples/rails-single/config/environment.rb +5 -0
  92. data/examples/rails-single/config/environments/development.rb +69 -0
  93. data/examples/rails-single/config/environments/production.rb +87 -0
  94. data/examples/rails-single/config/environments/test.rb +50 -0
  95. data/examples/rails-single/config/initializers/assets.rb +7 -0
  96. data/examples/rails-single/config/initializers/content_security_policy.rb +25 -0
  97. data/examples/rails-single/config/initializers/filter_parameter_logging.rb +8 -0
  98. data/examples/rails-single/config/initializers/inflections.rb +16 -0
  99. data/examples/rails-single/config/locales/en.yml +31 -0
  100. data/examples/rails-single/config/puma.rb +41 -0
  101. data/examples/rails-single/config/queue.yml +18 -0
  102. data/examples/rails-single/config/recurring.yml +15 -0
  103. data/examples/rails-single/config/routes.rb +4 -0
  104. data/examples/rails-single/config.ru +6 -0
  105. data/examples/rails-single/db/cable_schema.rb +11 -0
  106. data/examples/rails-single/db/cache_schema.rb +12 -0
  107. data/examples/rails-single/db/migrate/20251123095526_create_users.rb +10 -0
  108. data/examples/rails-single/db/queue_schema.rb +129 -0
  109. data/examples/rails-single/db/seeds.rb +9 -0
  110. data/examples/rails-single/deploy.yml +57 -0
  111. data/examples/rails-single/lib/tasks/.keep +0 -0
  112. data/examples/rails-single/log/.keep +0 -0
  113. data/examples/rails-single/package.json +17 -0
  114. data/examples/rails-single/public/400.html +114 -0
  115. data/examples/rails-single/public/404.html +114 -0
  116. data/examples/rails-single/public/406-unsupported-browser.html +114 -0
  117. data/examples/rails-single/public/422.html +114 -0
  118. data/examples/rails-single/public/500.html +114 -0
  119. data/examples/rails-single/public/icon.png +0 -0
  120. data/examples/rails-single/public/icon.svg +3 -0
  121. data/examples/rails-single/public/robots.txt +1 -0
  122. data/examples/rails-single/script/.keep +0 -0
  123. data/examples/rails-single/vendor/.keep +0 -0
  124. data/examples/rails-single/yarn.lock +188 -0
  125. data/exe/nvoi +6 -0
  126. data/lib/nvoi/cli.rb +190 -0
  127. data/lib/nvoi/cloudflare/client.rb +287 -0
  128. data/lib/nvoi/config/config.rb +248 -0
  129. data/lib/nvoi/config/env_resolver.rb +63 -0
  130. data/lib/nvoi/config/loader.rb +102 -0
  131. data/lib/nvoi/config/naming.rb +196 -0
  132. data/lib/nvoi/config/ssh_keys.rb +82 -0
  133. data/lib/nvoi/config/types.rb +274 -0
  134. data/lib/nvoi/constants.rb +59 -0
  135. data/lib/nvoi/credentials/crypto.rb +88 -0
  136. data/lib/nvoi/credentials/editor.rb +272 -0
  137. data/lib/nvoi/credentials/manager.rb +173 -0
  138. data/lib/nvoi/deployer/cleaner.rb +36 -0
  139. data/lib/nvoi/deployer/image_builder.rb +23 -0
  140. data/lib/nvoi/deployer/infrastructure.rb +126 -0
  141. data/lib/nvoi/deployer/orchestrator.rb +146 -0
  142. data/lib/nvoi/deployer/retry.rb +67 -0
  143. data/lib/nvoi/deployer/service_deployer.rb +311 -0
  144. data/lib/nvoi/deployer/tunnel_manager.rb +57 -0
  145. data/lib/nvoi/deployer/types.rb +8 -0
  146. data/lib/nvoi/errors.rb +67 -0
  147. data/lib/nvoi/k8s/renderer.rb +44 -0
  148. data/lib/nvoi/k8s/templates.rb +29 -0
  149. data/lib/nvoi/logger.rb +72 -0
  150. data/lib/nvoi/providers/aws.rb +403 -0
  151. data/lib/nvoi/providers/base.rb +111 -0
  152. data/lib/nvoi/providers/hetzner.rb +288 -0
  153. data/lib/nvoi/providers/hetzner_client.rb +170 -0
  154. data/lib/nvoi/remote/docker_manager.rb +203 -0
  155. data/lib/nvoi/remote/ssh_executor.rb +72 -0
  156. data/lib/nvoi/remote/volume_manager.rb +103 -0
  157. data/lib/nvoi/service/delete.rb +234 -0
  158. data/lib/nvoi/service/deploy.rb +80 -0
  159. data/lib/nvoi/service/exec.rb +144 -0
  160. data/lib/nvoi/service/provider.rb +36 -0
  161. data/lib/nvoi/steps/application_deployer.rb +26 -0
  162. data/lib/nvoi/steps/database_provisioner.rb +60 -0
  163. data/lib/nvoi/steps/k3s_cluster_setup.rb +105 -0
  164. data/lib/nvoi/steps/k3s_provisioner.rb +351 -0
  165. data/lib/nvoi/steps/server_provisioner.rb +43 -0
  166. data/lib/nvoi/steps/services_provisioner.rb +29 -0
  167. data/lib/nvoi/steps/tunnel_configurator.rb +66 -0
  168. data/lib/nvoi/steps/volume_provisioner.rb +154 -0
  169. data/lib/nvoi/version.rb +5 -0
  170. data/lib/nvoi.rb +79 -0
  171. data/templates/app-deployment.yaml.erb +102 -0
  172. data/templates/app-ingress.yaml.erb +20 -0
  173. data/templates/app-secret.yaml.erb +10 -0
  174. data/templates/app-service.yaml.erb +12 -0
  175. data/templates/db-statefulset.yaml.erb +76 -0
  176. data/templates/service-deployment.yaml.erb +91 -0
  177. data/templates/worker-deployment.yaml.erb +50 -0
  178. metadata +361 -0
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "base64"
5
+
6
+ module Nvoi
7
+ module Cloudflare
8
+ # Tunnel represents a Cloudflare tunnel
9
+ Tunnel = Struct.new(:id, :name, :token, keyword_init: true)
10
+
11
+ # Zone represents a Cloudflare DNS zone
12
+ Zone = Struct.new(:id, :name, keyword_init: true)
13
+
14
+ # DNSRecord represents a Cloudflare DNS record
15
+ DNSRecord = Struct.new(:id, :type, :name, :content, :proxied, :ttl, keyword_init: true)
16
+
17
+ # Client handles Cloudflare API operations
18
+ class Client
19
+ BASE_URL = "https://api.cloudflare.com/client/v4"
20
+
21
+ def initialize(token, account_id)
22
+ @token = token
23
+ @account_id = account_id
24
+ @conn = Faraday.new(url: BASE_URL) do |f|
25
+ f.request :json
26
+ f.response :json
27
+ f.adapter Faraday.default_adapter
28
+ end
29
+ end
30
+
31
+ # Tunnel operations
32
+
33
+ def create_tunnel(name)
34
+ url = "accounts/#{@account_id}/cfd_tunnel"
35
+ tunnel_secret = generate_tunnel_secret
36
+
37
+ response = post(url, {
38
+ name:,
39
+ tunnel_secret:,
40
+ config_src: "cloudflare"
41
+ })
42
+
43
+ result = response["result"]
44
+ Tunnel.new(
45
+ id: result["id"],
46
+ name: result["name"],
47
+ token: result["token"]
48
+ )
49
+ end
50
+
51
+ def find_tunnel(name)
52
+ url = "accounts/#{@account_id}/cfd_tunnel"
53
+ response = get(url, { name:, is_deleted: false })
54
+
55
+ results = response["result"]
56
+ return nil if results.nil? || results.empty?
57
+
58
+ result = results[0]
59
+ Tunnel.new(
60
+ id: result["id"],
61
+ name: result["name"],
62
+ token: result["token"]
63
+ )
64
+ end
65
+
66
+ def get_tunnel_token(tunnel_id)
67
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/token"
68
+ response = get(url)
69
+ response["result"]
70
+ end
71
+
72
+ def update_tunnel_configuration(tunnel_id, hostname, service_url)
73
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
74
+
75
+ config = {
76
+ ingress: [
77
+ {
78
+ hostname:,
79
+ service: service_url,
80
+ originRequest: { httpHostHeader: hostname }
81
+ },
82
+ { service: "http_status:404" }
83
+ ]
84
+ }
85
+
86
+ put(url, { config: })
87
+ end
88
+
89
+ def verify_tunnel_configuration(tunnel_id, expected_hostname, expected_service, max_attempts)
90
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
91
+
92
+ max_attempts.times do
93
+ begin
94
+ response = get(url)
95
+
96
+ if response["success"]
97
+ config = response.dig("result", "config")
98
+ config&.dig("ingress")&.each do |rule|
99
+ if rule["hostname"] == expected_hostname && rule["service"] == expected_service
100
+ return true
101
+ end
102
+ end
103
+ end
104
+ rescue StandardError
105
+ # Continue retrying
106
+ end
107
+
108
+ sleep(2)
109
+ end
110
+
111
+ raise TunnelError, "tunnel configuration not propagated after #{max_attempts} attempts"
112
+ end
113
+
114
+ def delete_tunnel(tunnel_id)
115
+ # Clean up all connections first
116
+ connections_url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/connections"
117
+ begin
118
+ delete(connections_url)
119
+ rescue StandardError
120
+ # Ignore connection cleanup errors
121
+ end
122
+
123
+ # Now delete the tunnel
124
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}"
125
+ delete(url)
126
+ end
127
+
128
+ # DNS operations
129
+
130
+ def find_zone(domain)
131
+ url = "zones"
132
+ response = get(url)
133
+
134
+ results = response["result"]
135
+ return nil unless results
136
+
137
+ zone_data = results.find { |z| z["name"] == domain }
138
+ return nil unless zone_data
139
+
140
+ Zone.new(id: zone_data["id"], name: zone_data["name"])
141
+ end
142
+
143
+ def find_dns_record(zone_id, name, record_type)
144
+ url = "zones/#{zone_id}/dns_records"
145
+ response = get(url)
146
+
147
+ results = response["result"]
148
+ return nil unless results
149
+
150
+ record_data = results.find { |r| r["name"] == name && r["type"] == record_type }
151
+ return nil unless record_data
152
+
153
+ DNSRecord.new(
154
+ id: record_data["id"],
155
+ type: record_data["type"],
156
+ name: record_data["name"],
157
+ content: record_data["content"],
158
+ proxied: record_data["proxied"],
159
+ ttl: record_data["ttl"]
160
+ )
161
+ end
162
+
163
+ def create_dns_record(zone_id, name, record_type, content, proxied: true)
164
+ url = "zones/#{zone_id}/dns_records"
165
+
166
+ response = post(url, {
167
+ type: record_type,
168
+ name:,
169
+ content:,
170
+ proxied:,
171
+ ttl: 1
172
+ })
173
+
174
+ result = response["result"]
175
+ DNSRecord.new(
176
+ id: result["id"],
177
+ type: result["type"],
178
+ name: result["name"],
179
+ content: result["content"],
180
+ proxied: result["proxied"],
181
+ ttl: result["ttl"]
182
+ )
183
+ end
184
+
185
+ def update_dns_record(zone_id, record_id, name, record_type, content, proxied: true)
186
+ url = "zones/#{zone_id}/dns_records/#{record_id}"
187
+
188
+ response = patch(url, {
189
+ type: record_type,
190
+ name:,
191
+ content:,
192
+ proxied:,
193
+ ttl: 1
194
+ })
195
+
196
+ result = response["result"]
197
+ DNSRecord.new(
198
+ id: result["id"],
199
+ type: result["type"],
200
+ name: result["name"],
201
+ content: result["content"],
202
+ proxied: result["proxied"],
203
+ ttl: result["ttl"]
204
+ )
205
+ end
206
+
207
+ def create_or_update_dns_record(zone_id, name, record_type, content, proxied: true)
208
+ existing = find_dns_record(zone_id, name, record_type)
209
+
210
+ if existing
211
+ update_dns_record(zone_id, existing.id, name, record_type, content, proxied:)
212
+ else
213
+ create_dns_record(zone_id, name, record_type, content, proxied:)
214
+ end
215
+ end
216
+
217
+ def delete_dns_record(zone_id, record_id)
218
+ url = "zones/#{zone_id}/dns_records/#{record_id}"
219
+ delete(url)
220
+ end
221
+
222
+ private
223
+
224
+ def get(url, params = {})
225
+ response = @conn.get(url) do |req|
226
+ req.headers["Authorization"] = "Bearer #{@token}"
227
+ req.params = params unless params.empty?
228
+ end
229
+ handle_response(response)
230
+ end
231
+
232
+ def post(url, body)
233
+ response = @conn.post(url) do |req|
234
+ req.headers["Authorization"] = "Bearer #{@token}"
235
+ req.body = body
236
+ end
237
+ handle_response(response)
238
+ end
239
+
240
+ def put(url, body)
241
+ response = @conn.put(url) do |req|
242
+ req.headers["Authorization"] = "Bearer #{@token}"
243
+ req.body = body
244
+ end
245
+ handle_response(response)
246
+ end
247
+
248
+ def patch(url, body)
249
+ response = @conn.patch(url) do |req|
250
+ req.headers["Authorization"] = "Bearer #{@token}"
251
+ req.body = body
252
+ end
253
+ handle_response(response)
254
+ end
255
+
256
+ def delete(url)
257
+ response = @conn.delete(url) do |req|
258
+ req.headers["Authorization"] = "Bearer #{@token}"
259
+ end
260
+
261
+ # 404 is ok for idempotent delete
262
+ return { "success" => true } if response.status == 404
263
+
264
+ handle_response(response)
265
+ end
266
+
267
+ def handle_response(response)
268
+ body = response.body
269
+
270
+ unless body.is_a?(Hash)
271
+ raise CloudflareError, "unexpected response format"
272
+ end
273
+
274
+ unless body["success"]
275
+ errors = body["errors"]&.map { |e| e["message"] }&.join(", ") || "unknown error"
276
+ raise CloudflareError, "API error: #{errors}"
277
+ end
278
+
279
+ body
280
+ end
281
+
282
+ def generate_tunnel_secret
283
+ Base64.strict_encode64(SecureRandom.random_bytes(32))
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Config
5
+ # Configuration holds the complete configuration including deployment config and runtime settings
6
+ class Configuration
7
+ attr_accessor :deploy, :ssh_key_path, :ssh_public_key, :server_name,
8
+ :firewall_name, :network_name, :docker_network_name, :container_prefix
9
+
10
+ def initialize(deploy_config)
11
+ @deploy = deploy_config
12
+ @namer = nil
13
+ end
14
+
15
+ # Returns the ResourceNamer for centralized naming
16
+ def namer
17
+ @namer ||= ResourceNamer.new(self)
18
+ end
19
+
20
+ # Returns environment variables for a specific service
21
+ def env_for_service(service_name)
22
+ resolver = EnvResolver.new(self)
23
+ resolver.env_for_service(service_name)
24
+ end
25
+
26
+ # Validate config structure
27
+ def validate_config
28
+ app = @deploy.application
29
+
30
+ # Validate provider configuration
31
+ validate_providers_config
32
+
33
+ # Validate database secrets (if database configured)
34
+ validate_database_secrets(app.database) if app.database
35
+
36
+ # Auto-inject database environment variables into app services
37
+ inject_database_env_vars
38
+
39
+ # Validate service-to-server bindings
40
+ validate_service_server_bindings
41
+ end
42
+
43
+ # ProviderName returns the name of the configured compute provider
44
+ def provider_name
45
+ return "hetzner" if @deploy.application.compute_provider.hetzner
46
+ return "aws" if @deploy.application.compute_provider.aws
47
+
48
+ ""
49
+ end
50
+
51
+ def hetzner
52
+ @deploy.application.compute_provider.hetzner
53
+ end
54
+
55
+ def aws
56
+ @deploy.application.compute_provider.aws
57
+ end
58
+
59
+ def cloudflare
60
+ @deploy.application.domain_provider.cloudflare
61
+ end
62
+
63
+ def keep_count_value
64
+ count = @deploy.application.keep_count
65
+ count && count.positive? ? count : 2
66
+ end
67
+
68
+ private
69
+
70
+ def validate_service_server_bindings
71
+ app = @deploy.application
72
+
73
+ # Collect all defined server names and validate single master
74
+ defined_servers = {}
75
+ master_count = 0
76
+
77
+ app.servers.each do |server_name, server_config|
78
+ defined_servers[server_name] = true
79
+ master_count += 1 if server_config&.master
80
+ end
81
+
82
+ # Require servers to be defined if any services exist
83
+ if app.servers.empty?
84
+ has_services = !app.app.empty? || app.database || !app.services.empty?
85
+ raise ConfigValidationError, "servers must be defined when deploying services" if has_services
86
+ end
87
+
88
+ # Validate master designation
89
+ if app.servers.size > 1
90
+ raise ConfigValidationError, "when multiple servers are defined, exactly one must have master: true" if master_count.zero?
91
+ raise ConfigValidationError, "only one server can have master: true, found #{master_count}" if master_count > 1
92
+ elsif app.servers.size == 1 && master_count > 1
93
+ raise ConfigValidationError, "only one server can have master: true, found #{master_count}"
94
+ end
95
+
96
+ # Validate app services
97
+ app.app.each do |service_name, service_config|
98
+ raise ConfigValidationError, "app.#{service_name}: servers field is required" if service_config.servers.empty?
99
+
100
+ service_config.servers.each do |server_ref|
101
+ unless defined_servers[server_ref]
102
+ raise ConfigValidationError, "app.#{service_name}: references undefined server '#{server_ref}'"
103
+ end
104
+ end
105
+ end
106
+
107
+ # Validate database
108
+ if app.database
109
+ raise ConfigValidationError, "database: servers field is required" if app.database.servers.empty?
110
+
111
+ app.database.servers.each do |server_ref|
112
+ raise ConfigValidationError, "database: references undefined server '#{server_ref}'" unless defined_servers[server_ref]
113
+ end
114
+ end
115
+
116
+ # Validate additional services
117
+ app.services.each do |service_name, service_config|
118
+ raise ConfigValidationError, "services.#{service_name}: servers field is required" if service_config.servers.empty?
119
+
120
+ service_config.servers.each do |server_ref|
121
+ unless defined_servers[server_ref]
122
+ raise ConfigValidationError, "services.#{service_name}: references undefined server '#{server_ref}'"
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ def validate_database_secrets(db)
129
+ adapter = db.adapter&.downcase
130
+
131
+ case adapter
132
+ when "postgres", "postgresql"
133
+ required_keys = %w[POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB]
134
+ required_keys.each do |key|
135
+ raise ConfigValidationError, "postgres database requires #{key} in secrets" unless db.secrets[key]
136
+ end
137
+ when "mysql"
138
+ required_keys = %w[MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE]
139
+ required_keys.each do |key|
140
+ raise ConfigValidationError, "mysql database requires #{key} in secrets" unless db.secrets[key]
141
+ end
142
+ when "sqlite3"
143
+ # SQLite doesn't require secrets
144
+ end
145
+ end
146
+
147
+ def validate_providers_config
148
+ app = @deploy.application
149
+
150
+ # Validate domain provider (required)
151
+ unless app.domain_provider.cloudflare
152
+ raise ConfigValidationError, "domain provider required: currently only cloudflare is supported"
153
+ end
154
+
155
+ cf = app.domain_provider.cloudflare
156
+ raise ConfigValidationError, "cloudflare api_token is required" if cf.api_token.nil? || cf.api_token.empty?
157
+ raise ConfigValidationError, "cloudflare account_id is required" if cf.account_id.nil? || cf.account_id.empty?
158
+
159
+ # Validate compute provider (at least one required)
160
+ has_provider = false
161
+
162
+ if app.compute_provider.hetzner
163
+ has_provider = true
164
+ h = app.compute_provider.hetzner
165
+ raise ConfigValidationError, "hetzner api_token is required" if h.api_token.nil? || h.api_token.empty?
166
+ raise ConfigValidationError, "hetzner server_type is required" if h.server_type.nil? || h.server_type.empty?
167
+ raise ConfigValidationError, "hetzner server_location is required" if h.server_location.nil? || h.server_location.empty?
168
+ end
169
+
170
+ if app.compute_provider.aws
171
+ has_provider = true
172
+ a = app.compute_provider.aws
173
+ raise ConfigValidationError, "aws access_key_id is required" if a.access_key_id.nil? || a.access_key_id.empty?
174
+ raise ConfigValidationError, "aws secret_access_key is required" if a.secret_access_key.nil? || a.secret_access_key.empty?
175
+ raise ConfigValidationError, "aws region is required" if a.region.nil? || a.region.empty?
176
+ raise ConfigValidationError, "aws instance_type is required" if a.instance_type.nil? || a.instance_type.empty?
177
+ end
178
+
179
+ raise ConfigValidationError, "compute provider required: hetzner or aws must be configured" unless has_provider
180
+ end
181
+
182
+ def inject_database_env_vars
183
+ app = @deploy.application
184
+
185
+ # Skip if no database configured
186
+ return unless app.database
187
+
188
+ db = app.database
189
+ adapter = db.adapter&.downcase
190
+
191
+ # SQLite doesn't need network connection vars
192
+ return if adapter == "sqlite3"
193
+
194
+ # Build connection variables
195
+ db_host = namer.database_service_name
196
+ db_port, db_user, db_password, db_name = extract_db_credentials(adapter, db)
197
+
198
+ return unless db_user && db_password && db_name
199
+
200
+ # Build DATABASE_URL
201
+ database_url = case adapter
202
+ when "postgres", "postgresql"
203
+ "postgresql://#{db_user}:#{db_password}@#{db_host}:#{db_port}/#{db_name}"
204
+ when "mysql"
205
+ "mysql://#{db_user}:#{db_password}@#{db_host}:#{db_port}/#{db_name}"
206
+ end
207
+
208
+ # Inject into all app services
209
+ app.app.each_value do |service_config|
210
+ service_config.env ||= {}
211
+
212
+ # Only inject if not already set by user
213
+ service_config.env["DATABASE_URL"] ||= database_url
214
+
215
+ case adapter
216
+ when "postgres", "postgresql"
217
+ unless service_config.env.key?("POSTGRES_HOST")
218
+ service_config.env["POSTGRES_HOST"] = db_host
219
+ service_config.env["POSTGRES_PORT"] = db_port
220
+ service_config.env["POSTGRES_USER"] = db_user
221
+ service_config.env["POSTGRES_PASSWORD"] = db_password
222
+ service_config.env["POSTGRES_DB"] = db_name
223
+ end
224
+ when "mysql"
225
+ unless service_config.env.key?("MYSQL_HOST")
226
+ service_config.env["MYSQL_HOST"] = db_host
227
+ service_config.env["MYSQL_PORT"] = db_port
228
+ service_config.env["MYSQL_USER"] = db_user
229
+ service_config.env["MYSQL_PASSWORD"] = db_password
230
+ service_config.env["MYSQL_DATABASE"] = db_name
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ def extract_db_credentials(adapter, db)
237
+ case adapter
238
+ when "postgres", "postgresql"
239
+ ["5432", db.secrets["POSTGRES_USER"], db.secrets["POSTGRES_PASSWORD"], db.secrets["POSTGRES_DB"]]
240
+ when "mysql"
241
+ ["3306", db.secrets["MYSQL_USER"], db.secrets["MYSQL_PASSWORD"], db.secrets["MYSQL_DATABASE"]]
242
+ else
243
+ [nil, nil, nil, nil]
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Config
5
+ # EnvResolver handles environment variable resolution and injection
6
+ class EnvResolver
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ # Returns environment variables for a specific service
12
+ def env_for_service(service_name)
13
+ env = {
14
+ "DEPLOY_ENV" => @config.deploy.application.environment
15
+ }
16
+
17
+ # Database env injection
18
+ inject_database_env(env)
19
+
20
+ # Global env vars
21
+ @config.deploy.application.env&.each do |k, v|
22
+ env[k] = v
23
+ end
24
+
25
+ # Global secrets
26
+ @config.deploy.application.secrets&.each do |k, v|
27
+ env[k] = v
28
+ end
29
+
30
+ # Service-specific env
31
+ service = @config.deploy.application.app[service_name]
32
+ if service
33
+ service.env&.each do |k, v|
34
+ env[k] = v
35
+ end
36
+ end
37
+
38
+ env
39
+ end
40
+
41
+ private
42
+
43
+ def inject_database_env(env)
44
+ db = @config.deploy.application.database
45
+ return unless db
46
+
47
+ env["DATABASE_ADAPTER"] = db.adapter if db.adapter && !db.adapter.empty?
48
+
49
+ # Handle database URL
50
+ if db.adapter == "sqlite3"
51
+ env["DATABASE_URL"] = "sqlite://data/db/production.sqlite3"
52
+ elsif db.url && !db.url.empty?
53
+ env["DATABASE_URL"] = db.url
54
+ end
55
+
56
+ # Inject database secrets
57
+ db.secrets&.each do |key, value|
58
+ env[key] = value
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Config
5
+ # ConfigLoader handles loading and initializing configuration
6
+ class ConfigLoader
7
+ attr_accessor :credentials_path, :master_key_path
8
+
9
+ def initialize
10
+ @credentials_path = nil
11
+ @master_key_path = nil
12
+ end
13
+
14
+ # Set explicit path to encrypted credentials file
15
+ def with_credentials_path(path)
16
+ @credentials_path = path
17
+ self
18
+ end
19
+
20
+ # Set explicit path to master key file
21
+ def with_master_key_path(path)
22
+ @master_key_path = path
23
+ self
24
+ end
25
+
26
+ # Load reads and parses the deployment configuration from encrypted file
27
+ def load(config_path)
28
+ # Determine working directory
29
+ working_dir = config_path && !config_path.empty? ? File.dirname(config_path) : "."
30
+
31
+ # Use explicit credentials path or derive from config_path
32
+ enc_path = @credentials_path
33
+ enc_path = config_path if enc_path.nil? || enc_path.empty?
34
+
35
+ # Create credentials manager
36
+ manager = Credentials::Manager.new(working_dir, enc_path, @master_key_path)
37
+
38
+ # Decrypt credentials
39
+ plaintext = manager.read
40
+ raise ConfigError, "Failed to decrypt credentials" unless plaintext
41
+
42
+ # Parse YAML
43
+ data = YAML.safe_load(plaintext, permitted_classes: [Symbol])
44
+ raise ConfigError, "Invalid config format" unless data.is_a?(Hash)
45
+
46
+ deploy_config = DeployConfig.new(data)
47
+
48
+ # Create config
49
+ cfg = Configuration.new(deploy_config)
50
+
51
+ # Load SSH keys from config content
52
+ key_loader = SSHKeyLoader.new(cfg)
53
+ key_loader.load_keys
54
+
55
+ # Validate config structure
56
+ cfg.validate_config
57
+
58
+ # Generate resource names
59
+ namer = ResourceNamer.new(cfg)
60
+ cfg.container_prefix = namer.infer_container_prefix
61
+ master_group = find_master_server_group(cfg)
62
+ cfg.server_name = namer.server_name(master_group, 1)
63
+ cfg.firewall_name = namer.firewall_name
64
+ cfg.network_name = namer.network_name
65
+ cfg.docker_network_name = namer.docker_network_name
66
+
67
+ cfg
68
+ end
69
+
70
+ private
71
+
72
+ def find_master_server_group(cfg)
73
+ servers = cfg.deploy.application.servers
74
+ return "master" if servers.empty?
75
+
76
+ # Find explicit master
77
+ servers.each do |name, server_cfg|
78
+ return name if server_cfg&.master
79
+ end
80
+
81
+ # Single server group: use it as master
82
+ return servers.keys.first if servers.size == 1
83
+
84
+ # Fallback
85
+ "master"
86
+ end
87
+ end
88
+
89
+ # Module-level load function
90
+ def self.load(config_path)
91
+ ConfigLoader.new.load(config_path)
92
+ end
93
+
94
+ # Module-level load with explicit key paths
95
+ def self.load_with_keys(config_path, credentials_path, master_key_path)
96
+ ConfigLoader.new
97
+ .with_credentials_path(credentials_path)
98
+ .with_master_key_path(master_key_path)
99
+ .load(config_path)
100
+ end
101
+ end
102
+ end