nvoi 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/00-overview.md +171 -0
  3. data/.claude/todo/refactor/01-objects.md +96 -0
  4. data/.claude/todo/refactor/02-utils.md +143 -0
  5. data/.claude/todo/refactor/03-external-cloud.md +164 -0
  6. data/.claude/todo/refactor/04-external-dns.md +104 -0
  7. data/.claude/todo/refactor/05-external.md +133 -0
  8. data/.claude/todo/refactor/06-cli.md +123 -0
  9. data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
  10. data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
  11. data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
  12. data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
  13. data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
  14. data/.claude/todo/refactor/12-cli-db-command.md +128 -0
  15. data/.claude/todo/refactor/_target.md +79 -0
  16. data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
  17. data/.claude/todo/refactor-execution/01-objects.md +42 -0
  18. data/.claude/todo/refactor-execution/02-utils.md +41 -0
  19. data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
  20. data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
  21. data/.claude/todo/refactor-execution/05-external-other.md +46 -0
  22. data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
  23. data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
  24. data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
  25. data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
  26. data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
  27. data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
  28. data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
  29. data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
  30. data/.claude/todo/scaleway.impl.md +644 -0
  31. data/.claude/todo/scaleway.reference.md +520 -0
  32. data/.claude/todos.md +550 -0
  33. data/Gemfile +6 -0
  34. data/Gemfile.lock +46 -5
  35. data/Rakefile +1 -1
  36. data/doc/config-schema.yaml +44 -11
  37. data/examples/golang/deploy.enc +0 -0
  38. data/examples/golang/main.go +18 -0
  39. data/exe/nvoi +3 -1
  40. data/ingest +0 -0
  41. data/lib/nvoi/cli/config/command.rb +219 -0
  42. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  43. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  44. data/lib/nvoi/cli/db/command.rb +308 -0
  45. data/lib/nvoi/cli/delete/command.rb +75 -0
  46. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  47. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +50 -0
  48. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  49. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  50. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  51. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  52. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  53. data/lib/nvoi/cli/deploy/command.rb +184 -0
  54. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  55. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  56. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +102 -0
  57. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -0
  58. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  59. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  60. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  61. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +490 -0
  62. data/lib/nvoi/cli/exec/command.rb +173 -0
  63. data/lib/nvoi/cli/logs/command.rb +66 -0
  64. data/lib/nvoi/cli/onboard/command.rb +761 -0
  65. data/lib/nvoi/cli/unlock/command.rb +72 -0
  66. data/lib/nvoi/cli.rb +339 -141
  67. data/lib/nvoi/config_api/actions/app.rb +53 -0
  68. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  69. data/lib/nvoi/config_api/actions/database.rb +70 -0
  70. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  71. data/lib/nvoi/config_api/actions/env.rb +32 -0
  72. data/lib/nvoi/config_api/actions/init.rb +67 -0
  73. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  74. data/lib/nvoi/config_api/actions/server.rb +66 -0
  75. data/lib/nvoi/config_api/actions/service.rb +52 -0
  76. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  77. data/lib/nvoi/config_api/base.rb +38 -0
  78. data/lib/nvoi/config_api/result.rb +26 -0
  79. data/lib/nvoi/config_api.rb +93 -0
  80. data/lib/nvoi/errors.rb +68 -50
  81. data/lib/nvoi/external/cloud/aws.rb +450 -0
  82. data/lib/nvoi/external/cloud/base.rb +99 -0
  83. data/lib/nvoi/external/cloud/factory.rb +48 -0
  84. data/lib/nvoi/external/cloud/hetzner.rb +402 -0
  85. data/lib/nvoi/external/cloud/scaleway.rb +559 -0
  86. data/lib/nvoi/external/cloud.rb +15 -0
  87. data/lib/nvoi/external/containerd.rb +86 -0
  88. data/lib/nvoi/external/database/mysql.rb +84 -0
  89. data/lib/nvoi/external/database/postgres.rb +82 -0
  90. data/lib/nvoi/external/database/provider.rb +65 -0
  91. data/lib/nvoi/external/database/sqlite.rb +72 -0
  92. data/lib/nvoi/external/database.rb +22 -0
  93. data/lib/nvoi/external/dns/cloudflare.rb +310 -0
  94. data/lib/nvoi/external/kubectl.rb +65 -0
  95. data/lib/nvoi/external/ssh.rb +106 -0
  96. data/lib/nvoi/objects/config_override.rb +60 -0
  97. data/lib/nvoi/objects/configuration.rb +483 -0
  98. data/lib/nvoi/objects/database.rb +56 -0
  99. data/lib/nvoi/objects/dns.rb +14 -0
  100. data/lib/nvoi/objects/firewall.rb +11 -0
  101. data/lib/nvoi/objects/network.rb +11 -0
  102. data/lib/nvoi/objects/server.rb +14 -0
  103. data/lib/nvoi/objects/service_spec.rb +26 -0
  104. data/lib/nvoi/objects/tunnel.rb +14 -0
  105. data/lib/nvoi/objects/volume.rb +17 -0
  106. data/lib/nvoi/utils/config_loader.rb +172 -0
  107. data/lib/nvoi/utils/constants.rb +61 -0
  108. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  109. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  110. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  111. data/lib/nvoi/utils/logger.rb +84 -0
  112. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +37 -25
  113. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  114. data/lib/nvoi/utils/templates.rb +62 -0
  115. data/lib/nvoi/version.rb +1 -1
  116. data/lib/nvoi.rb +27 -55
  117. data/templates/app-ingress.yaml.erb +3 -1
  118. data/templates/error-backend.yaml.erb +134 -0
  119. metadata +121 -44
  120. data/examples/golang/deploy.yml +0 -54
  121. data/lib/nvoi/cloudflare/client.rb +0 -287
  122. data/lib/nvoi/config/config.rb +0 -248
  123. data/lib/nvoi/config/loader.rb +0 -102
  124. data/lib/nvoi/config/ssh_keys.rb +0 -82
  125. data/lib/nvoi/config/types.rb +0 -274
  126. data/lib/nvoi/constants.rb +0 -59
  127. data/lib/nvoi/credentials/editor.rb +0 -272
  128. data/lib/nvoi/deployer/cleaner.rb +0 -36
  129. data/lib/nvoi/deployer/image_builder.rb +0 -23
  130. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  131. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  132. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  133. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  134. data/lib/nvoi/deployer/types.rb +0 -8
  135. data/lib/nvoi/k8s/renderer.rb +0 -44
  136. data/lib/nvoi/k8s/templates.rb +0 -29
  137. data/lib/nvoi/logger.rb +0 -72
  138. data/lib/nvoi/providers/aws.rb +0 -403
  139. data/lib/nvoi/providers/base.rb +0 -111
  140. data/lib/nvoi/providers/hetzner.rb +0 -288
  141. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  142. data/lib/nvoi/remote/docker_manager.rb +0 -203
  143. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  144. data/lib/nvoi/remote/volume_manager.rb +0 -103
  145. data/lib/nvoi/service/delete.rb +0 -234
  146. data/lib/nvoi/service/deploy.rb +0 -80
  147. data/lib/nvoi/service/exec.rb +0 -144
  148. data/lib/nvoi/service/provider.rb +0 -36
  149. data/lib/nvoi/steps/application_deployer.rb +0 -26
  150. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  151. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  152. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  153. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  154. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  155. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  156. data/lib/nvoi/steps/volume_provisioner.rb +0 -154
@@ -1,82 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "tempfile"
4
- require "fileutils"
5
-
6
- module Nvoi
7
- module Config
8
- # SSHKeyLoader handles SSH key loading from config content
9
- # Keys are stored as content in deploy.enc, written to temp files for SSH usage
10
- class SSHKeyLoader
11
- def initialize(config)
12
- @config = config
13
- @temp_dir = nil
14
- @private_key_path = nil
15
- @public_key_path = nil
16
- end
17
-
18
- # Load SSH keys from config content and write to temp files
19
- def load_keys
20
- ssh_keys = @config.deploy.application.ssh_keys
21
-
22
- unless ssh_keys
23
- raise ConfigError, "ssh_keys section is required in config. Run 'nvoi credentials edit' to generate keys."
24
- end
25
-
26
- unless ssh_keys.private_key && !ssh_keys.private_key.empty?
27
- raise ConfigError, "ssh_keys.private_key is required"
28
- end
29
-
30
- unless ssh_keys.public_key && !ssh_keys.public_key.empty?
31
- raise ConfigError, "ssh_keys.public_key is required"
32
- end
33
-
34
- # Create temp directory for keys
35
- @temp_dir = Dir.mktmpdir("nvoi-ssh-")
36
-
37
- # Write private key
38
- @private_key_path = File.join(@temp_dir, "id_nvoi")
39
- File.write(@private_key_path, ssh_keys.private_key)
40
- File.chmod(0o600, @private_key_path)
41
-
42
- # Write public key
43
- @public_key_path = File.join(@temp_dir, "id_nvoi.pub")
44
- File.write(@public_key_path, ssh_keys.public_key)
45
- File.chmod(0o644, @public_key_path)
46
-
47
- # Set config values
48
- @config.ssh_key_path = @private_key_path
49
- @config.ssh_public_key = ssh_keys.public_key.strip
50
- end
51
-
52
- # Cleanup temp files
53
- def cleanup
54
- FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir)
55
- end
56
-
57
- class << self
58
- # Generate a new Ed25519 keypair using ssh-keygen
59
- def generate_keypair
60
- temp_dir = Dir.mktmpdir("nvoi-keygen-")
61
- key_path = File.join(temp_dir, "id_nvoi")
62
-
63
- begin
64
- result = system(
65
- "ssh-keygen", "-t", "ed25519", "-N", "", "-C", "nvoi-deploy", "-f", key_path,
66
- out: File::NULL, err: File::NULL
67
- )
68
-
69
- raise ConfigError, "Failed to generate SSH keypair (ssh-keygen not available?)" unless result
70
-
71
- private_key = File.read(key_path)
72
- public_key = File.read("#{key_path}.pub").strip
73
-
74
- [private_key, public_key]
75
- ensure
76
- FileUtils.rm_rf(temp_dir)
77
- end
78
- end
79
- end
80
- end
81
- end
82
- end
@@ -1,274 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Nvoi
4
- module Config
5
- # DeployConfig represents the root deployment configuration
6
- class DeployConfig
7
- attr_accessor :application
8
-
9
- def initialize(data = {})
10
- @application = Application.new(data["application"] || {})
11
- end
12
- end
13
-
14
- # Application contains application-level configuration
15
- class Application
16
- attr_accessor :name, :environment, :domain_provider, :compute_provider,
17
- :keep_count, :servers, :app, :database, :services, :env,
18
- :secrets, :ssh_keys
19
-
20
- def initialize(data = {})
21
- @name = data["name"]
22
- @environment = data["environment"] || "production"
23
- @domain_provider = DomainProviderConfig.new(data["domain_provider"] || {})
24
- @compute_provider = ComputeProviderConfig.new(data["compute_provider"] || {})
25
- @keep_count = data["keep_count"]&.to_i
26
- @servers = parse_servers(data["servers"] || {})
27
- @app = parse_app_config(data["app"] || {})
28
- @database = data["database"] ? DatabaseConfig.new(data["database"]) : nil
29
- @services = parse_services(data["services"] || {})
30
- @env = data["env"] || {}
31
- @secrets = data["secrets"] || {}
32
- @ssh_keys = data["ssh_keys"] ? SSHKeyConfig.new(data["ssh_keys"]) : nil
33
- end
34
-
35
- private
36
-
37
- def parse_servers(data)
38
- data.transform_values { |v| ServerConfig.new(v || {}) }
39
- end
40
-
41
- def parse_app_config(data)
42
- data.transform_values { |v| AppServiceConfig.new(v || {}) }
43
- end
44
-
45
- def parse_services(data)
46
- data.transform_values { |v| ServiceConfig.new(v || {}) }
47
- end
48
- end
49
-
50
- # DomainProviderConfig contains domain provider configuration
51
- class DomainProviderConfig
52
- attr_accessor :cloudflare
53
-
54
- def initialize(data = {})
55
- @cloudflare = data["cloudflare"] ? CloudflareConfig.new(data["cloudflare"]) : nil
56
- end
57
- end
58
-
59
- # ComputeProviderConfig contains compute provider configuration
60
- class ComputeProviderConfig
61
- attr_accessor :hetzner, :aws
62
-
63
- def initialize(data = {})
64
- @hetzner = data["hetzner"] ? HetznerConfig.new(data["hetzner"]) : nil
65
- @aws = data["aws"] ? AWSConfig.new(data["aws"]) : nil
66
- end
67
- end
68
-
69
- # CloudflareConfig contains Cloudflare-specific configuration
70
- class CloudflareConfig
71
- attr_accessor :api_token, :account_id
72
-
73
- def initialize(data = {})
74
- @api_token = data["api_token"]
75
- @account_id = data["account_id"]
76
- end
77
- end
78
-
79
- # HetznerConfig contains Hetzner-specific configuration
80
- class HetznerConfig
81
- attr_accessor :api_token, :server_type, :server_location
82
-
83
- def initialize(data = {})
84
- @api_token = data["api_token"]
85
- @server_type = data["server_type"]
86
- @server_location = data["server_location"]
87
- end
88
- end
89
-
90
- # AWSConfig contains AWS-specific configuration
91
- class AWSConfig
92
- attr_accessor :access_key_id, :secret_access_key, :region, :instance_type
93
-
94
- def initialize(data = {})
95
- @access_key_id = data["access_key_id"]
96
- @secret_access_key = data["secret_access_key"]
97
- @region = data["region"]
98
- @instance_type = data["instance_type"]
99
- end
100
- end
101
-
102
- # ServerConfig contains server instance configuration
103
- class ServerConfig
104
- attr_accessor :master, :type, :location, :count
105
-
106
- def initialize(data = {})
107
- @master = data["master"] || false
108
- @type = data["type"]
109
- @location = data["location"]
110
- @count = data["count"]&.to_i || 1
111
- end
112
- end
113
-
114
- # AppServiceConfig defines a service in the app section
115
- class AppServiceConfig
116
- attr_accessor :servers, :domain, :subdomain, :port, :healthcheck,
117
- :command, :pre_run_command, :env, :volumes
118
-
119
- def initialize(data = {})
120
- @servers = data["servers"] || []
121
- @domain = data["domain"]
122
- @subdomain = data["subdomain"]
123
- @port = data["port"]&.to_i
124
- @healthcheck = data["healthcheck"] ? HealthCheckConfig.new(data["healthcheck"]) : nil
125
- @command = data["command"]
126
- @pre_run_command = data["pre_run_command"]
127
- @env = data["env"] || {}
128
- @volumes = data["volumes"] || {}
129
- end
130
-
131
- # Convert to ServiceSpec
132
- def to_service_spec(app_name, service_name, image_tag)
133
- cmd = @command ? @command.split : []
134
-
135
- spec = ServiceSpec.new(
136
- name: "#{app_name}-#{service_name}",
137
- image: image_tag,
138
- port: @port,
139
- command: cmd,
140
- env: @env,
141
- volumes: @volumes,
142
- replicas: @port.nil? || @port.zero? ? 1 : 2,
143
- healthcheck: @healthcheck,
144
- stateful_set: false,
145
- servers: @servers
146
- )
147
- spec
148
- end
149
- end
150
-
151
- # HealthCheckConfig defines health check configuration
152
- class HealthCheckConfig
153
- attr_accessor :type, :path, :port, :command, :interval, :timeout, :retries
154
-
155
- def initialize(data = {})
156
- @type = data["type"]
157
- @path = data["path"]
158
- @port = data["port"]&.to_i
159
- @command = data["command"]
160
- @interval = data["interval"]
161
- @timeout = data["timeout"]
162
- @retries = data["retries"]&.to_i
163
- end
164
- end
165
-
166
- # DatabaseConfig defines database configuration
167
- class DatabaseConfig
168
- attr_accessor :servers, :adapter, :url, :image, :volume, :secrets
169
-
170
- def initialize(data = {})
171
- @servers = data["servers"] || []
172
- @adapter = data["adapter"]
173
- @url = data["url"]
174
- @image = data["image"]
175
- @volume = data["volume"]
176
- @secrets = data["secrets"] || {}
177
- end
178
-
179
- # Convert to ServiceSpec
180
- def to_service_spec(namer)
181
- port = case @adapter&.downcase
182
- when "mysql" then 3306
183
- else 5432
184
- end
185
-
186
- vols = {}
187
- vols["data"] = @volume if @volume
188
-
189
- ServiceSpec.new(
190
- name: namer.database_service_name,
191
- image: @image,
192
- port:,
193
- env: nil,
194
- volumes: vols,
195
- replicas: 1,
196
- stateful_set: true,
197
- secrets: @secrets,
198
- servers: @servers
199
- )
200
- end
201
- end
202
-
203
- # ServiceConfig defines a generic service
204
- class ServiceConfig
205
- attr_accessor :servers, :image, :command, :env, :volume
206
-
207
- def initialize(data = {})
208
- @servers = data["servers"] || []
209
- @image = data["image"]
210
- @command = data["command"]
211
- @env = data["env"] || {}
212
- @volume = data["volume"]
213
- end
214
-
215
- # Convert to ServiceSpec
216
- def to_service_spec(app_name, service_name)
217
- cmd = @command ? @command.split : []
218
- vols = {}
219
- vols["data"] = @volume if @volume
220
-
221
- # Infer port from image
222
- port = case @image
223
- when /redis/ then 6379
224
- when /postgres/ then 5432
225
- when /mysql/ then 3306
226
- else 0
227
- end
228
-
229
- ServiceSpec.new(
230
- name: "#{app_name}-#{service_name}",
231
- image: @image,
232
- port:,
233
- command: cmd,
234
- env: @env,
235
- volumes: vols,
236
- replicas: 1,
237
- stateful_set: false,
238
- servers: @servers
239
- )
240
- end
241
- end
242
-
243
- # SSHKeyConfig defines SSH key content (stored in encrypted config)
244
- class SSHKeyConfig
245
- attr_accessor :private_key, :public_key
246
-
247
- def initialize(data = {})
248
- @private_key = data["private_key"]
249
- @public_key = data["public_key"]
250
- end
251
- end
252
-
253
- # ServiceSpec is the CORE primitive - pure K8s deployment specification
254
- class ServiceSpec
255
- attr_accessor :name, :image, :port, :command, :env, :volumes, :replicas,
256
- :healthcheck, :stateful_set, :secrets, :servers
257
-
258
- def initialize(name:, image:, port: 0, command: [], env: nil, volumes: nil,
259
- replicas: 1, healthcheck: nil, stateful_set: false, secrets: nil, servers: [])
260
- @name = name
261
- @image = image
262
- @port = port
263
- @command = command || []
264
- @env = env || {}
265
- @volumes = volumes || {}
266
- @replicas = replicas
267
- @healthcheck = healthcheck
268
- @stateful_set = stateful_set
269
- @secrets = secrets || {}
270
- @servers = servers || []
271
- end
272
- end
273
- end
274
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Nvoi
4
- module Constants
5
- # Default deployment configuration file
6
- DEFAULT_CONFIG_FILE = "deploy.enc"
7
-
8
- # Network configuration
9
- NETWORK_CIDR = "10.0.0.0/16"
10
- SUBNET_CIDR = "10.0.1.0/24"
11
-
12
- # Server configuration
13
- DEFAULT_IMAGE = "ubuntu-24.04"
14
- SERVER_READY_INTERVAL = 10 # seconds
15
- SERVER_READY_MAX_ATTEMPTS = 60
16
- SSH_READY_INTERVAL = 5 # seconds
17
- SSH_READY_MAX_ATTEMPTS = 60
18
-
19
- # Deployment configuration
20
- MAX_DEPLOYMENT_RETRIES = 3
21
- STALE_DEPLOYMENT_LOCK_AGE = 3600 # 1 hour in seconds
22
- KEEP_COUNT_DEFAULT = 3
23
-
24
- # K3s configuration
25
- DEFAULT_K3S_VERSION = "v1.28.5+k3s1"
26
-
27
- # Registry configuration
28
- REGISTRY_PORT = 30500
29
- REGISTRY_NAME = "nvoi-registry"
30
-
31
- # Cloudflare
32
- CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
33
- TUNNEL_CONFIG_VERIFY_ATTEMPTS = 10
34
-
35
- # Traffic verification
36
- TRAFFIC_VERIFY_ATTEMPTS = 10
37
- TRAFFIC_VERIFY_CONSECUTIVE = 3
38
- TRAFFIC_VERIFY_INTERVAL = 5 # seconds
39
-
40
- # Paths
41
- DEPLOYMENT_LOCK_FILE = "/tmp/nvoi-deployment.lock"
42
- APP_BASE_DIR = "/opt/nvoi"
43
-
44
- # Database defaults
45
- DATABASE_PORTS = {
46
- "postgresql" => 5432,
47
- "postgres" => 5432,
48
- "mysql" => 3306,
49
- "redis" => 6379
50
- }.freeze
51
-
52
- # Default database images
53
- DATABASE_IMAGES = {
54
- "postgresql" => "postgres:15-alpine",
55
- "postgres" => "postgres:15-alpine",
56
- "mysql" => "mysql:8.0"
57
- }.freeze
58
- end
59
- end
@@ -1,272 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Nvoi
4
- module Credentials
5
- DEFAULT_EDITOR = "vim"
6
- TEMP_FILE_PATTERN = "nvoi-credentials-"
7
-
8
- # Editor handles the edit workflow
9
- class Editor
10
- def initialize(manager)
11
- @manager = manager
12
- @editor = ENV["EDITOR"] || DEFAULT_EDITOR
13
- end
14
-
15
- # Perform the full edit cycle: decrypt -> edit -> validate -> encrypt
16
- def edit
17
- is_first_time = !@manager.exists?
18
-
19
- content = if is_first_time
20
- default_template
21
- else
22
- @manager.read
23
- end
24
-
25
- # Create temp file
26
- tmp_file = Tempfile.new([TEMP_FILE_PATTERN, ".yaml"])
27
- tmp_path = tmp_file.path
28
-
29
- begin
30
- tmp_file.write(content)
31
- tmp_file.close
32
-
33
- # Edit loop: keep opening editor until valid or user quits
34
- loop do
35
- # Get file mtime before edit
36
- before_mtime = File.mtime(tmp_path)
37
-
38
- # Open editor
39
- unless system(@editor, tmp_path)
40
- raise CredentialError, "editor failed"
41
- end
42
-
43
- # Check if file was modified
44
- after_mtime = File.mtime(tmp_path)
45
- if after_mtime == before_mtime
46
- puts "No changes made, aborting."
47
- return
48
- end
49
-
50
- # Read edited content
51
- edited_content = File.read(tmp_path)
52
-
53
- # Validate
54
- validation_error = validate(edited_content)
55
- if validation_error
56
- puts "\n\e[31mValidation failed:\e[0m #{validation_error}"
57
- puts "\nPress Enter to re-edit, or Ctrl+C to abort..."
58
- $stdin.gets
59
- next
60
- end
61
-
62
- # Valid: save
63
- if is_first_time
64
- @manager.initialize_credentials(edited_content)
65
- else
66
- @manager.write(edited_content)
67
- end
68
-
69
- puts "\e[32mCredentials saved:\e[0m #{@manager.encrypted_path}"
70
- return
71
- end
72
- ensure
73
- tmp_file.close
74
- tmp_file.unlink
75
- end
76
- end
77
-
78
- # Print the decrypted credentials to stdout
79
- def show
80
- unless @manager.exists?
81
- raise CredentialError, "credentials file not found: #{@manager.encrypted_path}\nRun 'nvoi credentials edit' to create one"
82
- end
83
-
84
- content = @manager.read
85
- print content
86
- end
87
-
88
- private
89
-
90
- def validate(content)
91
- # First: basic YAML parse
92
- begin
93
- data = YAML.safe_load(content, permitted_classes: [Symbol])
94
- rescue Psych::SyntaxError => e
95
- return "invalid YAML syntax: #{e.message}"
96
- end
97
-
98
- return "config must be a hash" unless data.is_a?(Hash)
99
-
100
- # Second: validate required fields
101
- validate_required_fields(data)
102
- end
103
-
104
- def validate_required_fields(cfg)
105
- app = cfg["application"]
106
- return "application section is required" unless app.is_a?(Hash)
107
-
108
- # Application name
109
- return "application.name is required" if app["name"].nil? || app["name"].to_s.empty?
110
-
111
- # Environment
112
- return "application.environment is required" if app["environment"].nil? || app["environment"].to_s.empty?
113
-
114
- # Domain provider
115
- domain_provider = app["domain_provider"]
116
- return "application.domain_provider.cloudflare is required" unless domain_provider&.dig("cloudflare")
117
-
118
- cf = domain_provider["cloudflare"]
119
- return "application.domain_provider.cloudflare.api_token is required" if cf["api_token"].nil? || cf["api_token"].to_s.empty?
120
- return "application.domain_provider.cloudflare.account_id is required" if cf["account_id"].nil? || cf["account_id"].to_s.empty?
121
-
122
- # Compute provider
123
- compute_provider = app["compute_provider"]
124
- has_compute = compute_provider&.dig("hetzner") || compute_provider&.dig("aws")
125
- return "compute_provider (hetzner or aws) is required" unless has_compute
126
-
127
- if (h = compute_provider&.dig("hetzner"))
128
- return "application.compute_provider.hetzner.api_token is required" if h["api_token"].nil? || h["api_token"].to_s.empty?
129
- return "application.compute_provider.hetzner.server_type is required" if h["server_type"].nil? || h["server_type"].to_s.empty?
130
- return "application.compute_provider.hetzner.server_location is required" if h["server_location"].nil? || h["server_location"].to_s.empty?
131
- end
132
-
133
- if (a = compute_provider&.dig("aws"))
134
- return "application.compute_provider.aws.access_key_id is required" if a["access_key_id"].nil? || a["access_key_id"].to_s.empty?
135
- return "application.compute_provider.aws.secret_access_key is required" if a["secret_access_key"].nil? || a["secret_access_key"].to_s.empty?
136
- return "application.compute_provider.aws.region is required" if a["region"].nil? || a["region"].to_s.empty?
137
- return "application.compute_provider.aws.instance_type is required" if a["instance_type"].nil? || a["instance_type"].to_s.empty?
138
- end
139
-
140
- # Servers (if any services defined)
141
- servers = app["servers"] || {}
142
- app_services = app["app"] || {}
143
- database = app["database"]
144
- services = app["services"] || {}
145
-
146
- has_services = !app_services.empty? || database || !services.empty?
147
- return "servers must be defined when deploying services" if has_services && servers.empty?
148
-
149
- defined_servers = servers.keys.to_set
150
-
151
- # Validate app services
152
- app_services.each do |service_name, svc|
153
- next unless svc
154
-
155
- return "app.#{service_name}.servers is required" if svc["servers"].nil? || svc["servers"].empty?
156
-
157
- svc["servers"].each do |ref|
158
- return "app.#{service_name} references undefined server: #{ref}" unless defined_servers.include?(ref)
159
- end
160
- end
161
-
162
- # Validate database
163
- if database
164
- return "database.servers is required" if database["servers"].nil? || database["servers"].empty?
165
-
166
- database["servers"].each do |ref|
167
- return "database references undefined server: #{ref}" unless defined_servers.include?(ref)
168
- end
169
-
170
- db_error = validate_database_secrets(database)
171
- return db_error if db_error
172
- end
173
-
174
- # Validate SSH keys
175
- ssh_keys = app["ssh_keys"]
176
- return "application.ssh_keys is required" unless ssh_keys.is_a?(Hash)
177
- return "application.ssh_keys.private_key is required" if ssh_keys["private_key"].nil? || ssh_keys["private_key"].to_s.strip.empty?
178
- return "application.ssh_keys.public_key is required" if ssh_keys["public_key"].nil? || ssh_keys["public_key"].to_s.strip.empty?
179
-
180
- nil
181
- end
182
-
183
- def validate_database_secrets(db)
184
- adapter = db["adapter"]&.downcase
185
-
186
- case adapter
187
- when "postgres", "postgresql"
188
- %w[POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB].each do |key|
189
- return "database.secrets.#{key} is required for postgres" unless db.dig("secrets", key)
190
- end
191
- when "mysql"
192
- %w[MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE].each do |key|
193
- return "database.secrets.#{key} is required for mysql" unless db.dig("secrets", key)
194
- end
195
- when "sqlite3"
196
- # SQLite doesn't require secrets
197
- when nil, ""
198
- return "database.adapter is required"
199
- end
200
-
201
- nil
202
- end
203
-
204
- def default_template
205
- # Generate SSH keypair for first-time setup
206
- private_key, public_key = Config::SSHKeyLoader.generate_keypair
207
-
208
- <<~YAML
209
- # NVOI Deployment Configuration
210
- # This file is encrypted - never commit deploy.key!
211
-
212
- application:
213
- name: myapp
214
- environment: production
215
-
216
- domain_provider:
217
- cloudflare:
218
- api_token: YOUR_CLOUDFLARE_API_TOKEN
219
- account_id: YOUR_CLOUDFLARE_ACCOUNT_ID
220
-
221
- compute_provider:
222
- hetzner:
223
- api_token: YOUR_HETZNER_API_TOKEN
224
- server_type: cx22
225
- server_location: fsn1
226
-
227
- servers:
228
- master:
229
- type: cx22
230
- location: fsn1
231
-
232
- keep_count: 2
233
-
234
- app:
235
- web:
236
- servers: [master]
237
- domain: example.com
238
- subdomain: app
239
- port: 3000
240
- healthcheck:
241
- type: http
242
- path: /health
243
- port: 3000
244
-
245
- # database:
246
- # servers: [master]
247
- # adapter: postgres
248
- # image: postgres:16-alpine
249
- # volume: postgres_data
250
- # secrets:
251
- # POSTGRES_DB: myapp_production
252
- # POSTGRES_USER: myapp
253
- # POSTGRES_PASSWORD: YOUR_DB_PASSWORD
254
-
255
- env:
256
- # Add environment variables here
257
- # RAILS_ENV: production
258
-
259
- secrets:
260
- # Add secrets here (will be injected as env vars)
261
- # SECRET_KEY_BASE: YOUR_SECRET_KEY_BASE
262
-
263
- # SSH keys (auto-generated, do not modify)
264
- ssh_keys:
265
- private_key: |
266
- #{private_key.lines.map { |l| " #{l}" }.join}
267
- public_key: #{public_key}
268
- YAML
269
- end
270
- end
271
- end
272
- end