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,272 @@
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
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Credentials
5
+ # Default filenames
6
+ DEFAULT_ENCRYPTED_FILE = "deploy.enc"
7
+ DEFAULT_KEY_FILE = "deploy.key"
8
+ MASTER_KEY_ENV_VAR = "NVOI_MASTER_KEY"
9
+
10
+ # Manager handles encrypted credentials operations
11
+ class Manager
12
+ attr_reader :encrypted_path, :key_path
13
+
14
+ # Create a new credentials manager
15
+ # working_dir: base directory to search for files
16
+ # encrypted_path: explicit path to encrypted file (optional, nil = auto-discover)
17
+ # key_path: explicit path to key file (optional, nil = auto-discover)
18
+ def initialize(working_dir, encrypted_path = nil, key_path = nil)
19
+ @working_dir = working_dir
20
+ @encrypted_path = encrypted_path && !encrypted_path.empty? ? encrypted_path : find_encrypted_file
21
+ @key_path = nil
22
+ @master_key = nil
23
+
24
+ resolve_key(key_path)
25
+ end
26
+
27
+ # Create a manager for initial setup (no existing files required)
28
+ def self.for_init(working_dir)
29
+ manager = allocate
30
+ manager.instance_variable_set(:@working_dir, working_dir)
31
+ manager.instance_variable_set(:@encrypted_path, File.join(working_dir, DEFAULT_ENCRYPTED_FILE))
32
+ manager.instance_variable_set(:@key_path, nil)
33
+ manager.instance_variable_set(:@master_key, nil)
34
+ manager
35
+ end
36
+
37
+ # Check if the encrypted credentials file exists
38
+ def exists?
39
+ File.exist?(@encrypted_path)
40
+ end
41
+
42
+ # Check if the manager has a master key loaded
43
+ def has_key?
44
+ !@master_key.nil? && !@master_key.empty?
45
+ end
46
+
47
+ # Decrypt and return the credentials content
48
+ def read
49
+ raise CredentialError, "master key not loaded" unless has_key?
50
+
51
+ ciphertext = File.binread(@encrypted_path)
52
+ Crypto.decrypt(ciphertext, @master_key)
53
+ end
54
+
55
+ # Encrypt and save the credentials content
56
+ def write(plaintext)
57
+ raise CredentialError, "master key not loaded" unless has_key?
58
+
59
+ ciphertext = Crypto.encrypt(plaintext, @master_key)
60
+
61
+ # Write atomically: write to temp file, then rename
62
+ tmp_path = "#{@encrypted_path}.tmp"
63
+ File.binwrite(tmp_path, ciphertext, perm: 0o600)
64
+
65
+ begin
66
+ File.rename(tmp_path, @encrypted_path)
67
+ rescue StandardError => e
68
+ File.delete(tmp_path) if File.exist?(tmp_path)
69
+ raise CredentialError, "failed to rename temp file: #{e.message}"
70
+ end
71
+ end
72
+
73
+ # Initialize creates a new encrypted credentials file with a generated key
74
+ # Returns the generated key
75
+ def initialize_credentials(template)
76
+ # Generate new key
77
+ @master_key = Crypto.generate_key
78
+
79
+ # Write key file
80
+ @key_path = File.join(File.dirname(@encrypted_path), DEFAULT_KEY_FILE)
81
+ File.write(@key_path, "#{@master_key}\n", perm: 0o600)
82
+
83
+ begin
84
+ write(template)
85
+ rescue StandardError => e
86
+ File.delete(@key_path) if File.exist?(@key_path)
87
+ raise e
88
+ end
89
+
90
+ @master_key
91
+ end
92
+
93
+ # Add deploy.key to .gitignore if not already present
94
+ def update_gitignore
95
+ gitignore_path = File.join(@working_dir, ".gitignore")
96
+
97
+ content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
98
+
99
+ # Check if already present
100
+ return if content.lines.any? { |line| line.strip == DEFAULT_KEY_FILE }
101
+
102
+ File.open(gitignore_path, "a") do |f|
103
+ # Add newline if file doesn't end with one
104
+ f.write("\n") if !content.empty? && !content.end_with?("\n")
105
+
106
+ # Add comment and entry
107
+ f.write("\n# NVOI master key (do not commit)\n#{DEFAULT_KEY_FILE}\n")
108
+ end
109
+ end
110
+
111
+ # For testing purposes
112
+ def set_master_key_for_testing(key)
113
+ @master_key = key
114
+ end
115
+
116
+ private
117
+
118
+ def find_encrypted_file
119
+ search_paths = [
120
+ File.join(@working_dir, DEFAULT_ENCRYPTED_FILE),
121
+ File.join(@working_dir, "config", DEFAULT_ENCRYPTED_FILE)
122
+ ]
123
+
124
+ search_paths.each do |path|
125
+ return path if File.exist?(path)
126
+ end
127
+
128
+ # Default to working dir location (for new file creation)
129
+ File.join(@working_dir, DEFAULT_ENCRYPTED_FILE)
130
+ end
131
+
132
+ def resolve_key(explicit_key_path)
133
+ # Priority 1: Explicit key file path
134
+ if explicit_key_path && !explicit_key_path.empty?
135
+ @master_key = load_key_from_file(explicit_key_path)
136
+ @key_path = explicit_key_path
137
+ return
138
+ end
139
+
140
+ # Priority 2: Environment variable
141
+ env_key = ENV[MASTER_KEY_ENV_VAR]
142
+ if env_key && !env_key.empty?
143
+ Crypto.validate_key(env_key)
144
+ @master_key = env_key
145
+ return
146
+ end
147
+
148
+ # Priority 3: Key file in standard locations
149
+ key_search_paths = [
150
+ File.join(File.dirname(@encrypted_path), DEFAULT_KEY_FILE),
151
+ File.join(@working_dir, DEFAULT_KEY_FILE),
152
+ File.join(@working_dir, "config", DEFAULT_KEY_FILE)
153
+ ]
154
+
155
+ key_search_paths.each do |path|
156
+ next unless File.exist?(path)
157
+
158
+ @master_key = load_key_from_file(path)
159
+ @key_path = path
160
+ return
161
+ end
162
+
163
+ raise CredentialError, "master key not found: set #{MASTER_KEY_ENV_VAR} or create #{DEFAULT_KEY_FILE}"
164
+ end
165
+
166
+ def load_key_from_file(path)
167
+ content = File.read(path).strip
168
+ Crypto.validate_key(content)
169
+ content
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Deployer
5
+ # Cleaner handles cleanup of old deployments and resources
6
+ class Cleaner
7
+ def initialize(config, docker_manager, log)
8
+ @config = config
9
+ @docker_manager = docker_manager
10
+ @log = log
11
+ end
12
+
13
+ def cleanup_old_images(current_tag)
14
+ keep_count = @config.keep_count_value
15
+ prefix = @config.container_prefix
16
+
17
+ @log.info "Cleaning up old images (keeping %d)", keep_count
18
+
19
+ # List all images
20
+ all_tags = @docker_manager.list_images("reference=#{prefix}:*")
21
+
22
+ # Sort by tag (timestamp), keep newest
23
+ sorted_tags = all_tags.sort.reverse
24
+ keep_tags = sorted_tags.take(keep_count)
25
+
26
+ # Make sure current tag is kept
27
+ keep_tags << current_tag unless keep_tags.include?(current_tag)
28
+ keep_tags << "latest"
29
+
30
+ @docker_manager.cleanup_old_images(prefix, keep_tags.uniq)
31
+
32
+ @log.success "Old images cleaned up"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Deployer
5
+ # ImageBuilder handles Docker image building and pushing
6
+ class ImageBuilder
7
+ def initialize(config, docker_manager, log)
8
+ @config = config
9
+ @docker_manager = docker_manager
10
+ @log = log
11
+ end
12
+
13
+ def build_and_push(working_dir, image_tag)
14
+ @log.info "Building Docker image: %s", image_tag
15
+
16
+ # Build image locally, transfer to remote, load with containerd
17
+ @docker_manager.build_image(working_dir, image_tag, @config.namer.latest_image_tag)
18
+
19
+ @log.success "Image built and pushed: %s", image_tag
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Deployer
5
+ # Infrastructure handles cloud resource provisioning
6
+ class Infrastructure
7
+ def initialize(config, provider, log)
8
+ @config = config
9
+ @provider = provider
10
+ @log = log
11
+ end
12
+
13
+ def provision_network
14
+ @log.info "Provisioning network: %s", @config.network_name
15
+ network = @provider.find_or_create_network(@config.network_name)
16
+ @log.success "Network ready: %s", network.id
17
+ network
18
+ end
19
+
20
+ def provision_firewall
21
+ @log.info "Provisioning firewall: %s", @config.firewall_name
22
+ firewall = @provider.find_or_create_firewall(@config.firewall_name)
23
+ @log.success "Firewall ready: %s", firewall.id
24
+ firewall
25
+ end
26
+
27
+ def provision_server(name, network_id, firewall_id, server_config)
28
+ @log.info "Provisioning server: %s", name
29
+
30
+ # Check if server already exists
31
+ existing = @provider.find_server(name)
32
+ if existing
33
+ @log.info "Server already exists: %s (%s)", name, existing.public_ipv4
34
+ return existing
35
+ end
36
+
37
+ # Determine server type and location
38
+ server_type = server_config&.type
39
+ location = server_config&.location
40
+
41
+ case @config.provider_name
42
+ when "hetzner"
43
+ h = @config.hetzner
44
+ server_type ||= h.server_type
45
+ location ||= h.server_location
46
+ image = "ubuntu-22.04"
47
+ when "aws"
48
+ a = @config.aws
49
+ server_type ||= a.instance_type
50
+ location ||= a.region
51
+ image = "ubuntu-22.04"
52
+ end
53
+
54
+ # Create cloud-init user data
55
+ user_data = generate_user_data
56
+
57
+ opts = Providers::ServerCreateOptions.new(
58
+ name:,
59
+ type: server_type,
60
+ image:,
61
+ location:,
62
+ user_data:,
63
+ network_id:,
64
+ firewall_id:
65
+ )
66
+
67
+ server = @provider.create_server(opts)
68
+ @log.info "Server created: %s (waiting for ready...)", server.id
69
+
70
+ # Wait for server to be running
71
+ server = @provider.wait_for_server(server.id, Constants::SERVER_READY_MAX_ATTEMPTS)
72
+ @log.success "Server ready: %s (%s)", name, server.public_ipv4
73
+
74
+ # Wait for SSH to be available
75
+ wait_for_ssh(server.public_ipv4)
76
+
77
+ server
78
+ end
79
+
80
+ private
81
+
82
+ def generate_user_data
83
+ ssh_key = @config.ssh_public_key
84
+
85
+ <<~CLOUD_INIT
86
+ #cloud-config
87
+ users:
88
+ - name: deploy
89
+ groups: sudo, docker
90
+ shell: /bin/bash
91
+ sudo: ALL=(ALL) NOPASSWD:ALL
92
+ ssh_authorized_keys:
93
+ - #{ssh_key}
94
+ package_update: true
95
+ package_upgrade: true
96
+ packages:
97
+ - curl
98
+ - git
99
+ - jq
100
+ - rsync
101
+ CLOUD_INIT
102
+ end
103
+
104
+ def wait_for_ssh(ip)
105
+ @log.info "Waiting for SSH on %s...", ip
106
+ ssh = Remote::SSHExecutor.new(ip, @config.ssh_key_path)
107
+
108
+ Constants::SSH_READY_MAX_ATTEMPTS.times do |i|
109
+ begin
110
+ output = ssh.execute("echo 'ready'")
111
+ if output.strip == "ready"
112
+ @log.success "SSH ready"
113
+ return
114
+ end
115
+ rescue SSHCommandError
116
+ # SSH not ready yet
117
+ end
118
+
119
+ sleep(Constants::SSH_READY_INTERVAL)
120
+ end
121
+
122
+ raise SSHConnectionError, "SSH connection failed after #{Constants::SSH_READY_MAX_ATTEMPTS} attempts"
123
+ end
124
+ end
125
+ end
126
+ end