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.
- checksums.yaml +7 -0
- data/.rubocop.yml +19 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +151 -0
- data/Makefile +26 -0
- data/Rakefile +16 -0
- data/doc/config-schema.yaml +357 -0
- data/examples/apex-wildcard/deploy.yml +68 -0
- data/examples/golang/.gitignore +19 -0
- data/examples/golang/Dockerfile +43 -0
- data/examples/golang/README.md +59 -0
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/deploy.yml +54 -0
- data/examples/golang/go.mod +39 -0
- data/examples/golang/go.sum +96 -0
- data/examples/golang/main.go +177 -0
- data/examples/golang/models/user.go +17 -0
- data/examples/golang-postgres-multi/.gitignore +18 -0
- data/examples/golang-postgres-multi/Dockerfile +39 -0
- data/examples/golang-postgres-multi/README.md +211 -0
- data/examples/golang-postgres-multi/deploy.yml +67 -0
- data/examples/golang-postgres-multi/go.mod +45 -0
- data/examples/golang-postgres-multi/go.sum +108 -0
- data/examples/golang-postgres-multi/main.go +197 -0
- data/examples/golang-postgres-multi/models/user.go +17 -0
- data/examples/postgres-multi/.env.production.example +11 -0
- data/examples/postgres-multi/README.md +112 -0
- data/examples/postgres-multi/deploy.yml +74 -0
- data/examples/postgres-single/.env.production.example +11 -0
- data/examples/postgres-single/.gitignore +15 -0
- data/examples/postgres-single/Dockerfile +35 -0
- data/examples/postgres-single/README.md +76 -0
- data/examples/postgres-single/deploy.yml +56 -0
- data/examples/postgres-single/go.mod +45 -0
- data/examples/postgres-single/go.sum +108 -0
- data/examples/postgres-single/main.go +184 -0
- data/examples/rails-single/.dockerignore +51 -0
- data/examples/rails-single/.env.production.example +11 -0
- data/examples/rails-single/.github/dependabot.yml +12 -0
- data/examples/rails-single/.github/workflows/ci.yml +39 -0
- data/examples/rails-single/.gitignore +20 -0
- data/examples/rails-single/.node-version +1 -0
- data/examples/rails-single/.rubocop.yml +8 -0
- data/examples/rails-single/.ruby-version +1 -0
- data/examples/rails-single/Dockerfile +86 -0
- data/examples/rails-single/Gemfile +56 -0
- data/examples/rails-single/Gemfile.lock +350 -0
- data/examples/rails-single/Procfile.dev +3 -0
- data/examples/rails-single/README.md +17 -0
- data/examples/rails-single/Rakefile +6 -0
- data/examples/rails-single/app/assets/builds/.keep +0 -0
- data/examples/rails-single/app/assets/images/.keep +0 -0
- data/examples/rails-single/app/assets/stylesheets/application.tailwind.css +1 -0
- data/examples/rails-single/app/controllers/application_controller.rb +4 -0
- data/examples/rails-single/app/controllers/concerns/.keep +0 -0
- data/examples/rails-single/app/controllers/users_controller.rb +19 -0
- data/examples/rails-single/app/helpers/application_helper.rb +2 -0
- data/examples/rails-single/app/javascript/application.js +3 -0
- data/examples/rails-single/app/javascript/controllers/application.js +9 -0
- data/examples/rails-single/app/javascript/controllers/hello_controller.js +7 -0
- data/examples/rails-single/app/javascript/controllers/index.js +8 -0
- data/examples/rails-single/app/jobs/application_job.rb +7 -0
- data/examples/rails-single/app/mailers/application_mailer.rb +4 -0
- data/examples/rails-single/app/models/application_record.rb +3 -0
- data/examples/rails-single/app/models/concerns/.keep +0 -0
- data/examples/rails-single/app/models/user.rb +2 -0
- data/examples/rails-single/app/views/layouts/application.html.erb +28 -0
- data/examples/rails-single/app/views/layouts/mailer.html.erb +13 -0
- data/examples/rails-single/app/views/layouts/mailer.text.erb +1 -0
- data/examples/rails-single/app/views/pwa/manifest.json.erb +22 -0
- data/examples/rails-single/app/views/pwa/service-worker.js +26 -0
- data/examples/rails-single/app/views/users/index.html.erb +38 -0
- data/examples/rails-single/bin/brakeman +7 -0
- data/examples/rails-single/bin/bundle +109 -0
- data/examples/rails-single/bin/dev +11 -0
- data/examples/rails-single/bin/docker-entrypoint +14 -0
- data/examples/rails-single/bin/jobs +6 -0
- data/examples/rails-single/bin/kamal +27 -0
- data/examples/rails-single/bin/rails +4 -0
- data/examples/rails-single/bin/rake +4 -0
- data/examples/rails-single/bin/rubocop +8 -0
- data/examples/rails-single/bin/setup +37 -0
- data/examples/rails-single/bin/thrust +5 -0
- data/examples/rails-single/bun.lock +224 -0
- data/examples/rails-single/config/application.rb +42 -0
- data/examples/rails-single/config/boot.rb +4 -0
- data/examples/rails-single/config/cable.yml +17 -0
- data/examples/rails-single/config/cache.yml +16 -0
- data/examples/rails-single/config/credentials.yml.enc +1 -0
- data/examples/rails-single/config/database.yml +100 -0
- data/examples/rails-single/config/environment.rb +5 -0
- data/examples/rails-single/config/environments/development.rb +69 -0
- data/examples/rails-single/config/environments/production.rb +87 -0
- data/examples/rails-single/config/environments/test.rb +50 -0
- data/examples/rails-single/config/initializers/assets.rb +7 -0
- data/examples/rails-single/config/initializers/content_security_policy.rb +25 -0
- data/examples/rails-single/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/rails-single/config/initializers/inflections.rb +16 -0
- data/examples/rails-single/config/locales/en.yml +31 -0
- data/examples/rails-single/config/puma.rb +41 -0
- data/examples/rails-single/config/queue.yml +18 -0
- data/examples/rails-single/config/recurring.yml +15 -0
- data/examples/rails-single/config/routes.rb +4 -0
- data/examples/rails-single/config.ru +6 -0
- data/examples/rails-single/db/cable_schema.rb +11 -0
- data/examples/rails-single/db/cache_schema.rb +12 -0
- data/examples/rails-single/db/migrate/20251123095526_create_users.rb +10 -0
- data/examples/rails-single/db/queue_schema.rb +129 -0
- data/examples/rails-single/db/seeds.rb +9 -0
- data/examples/rails-single/deploy.yml +57 -0
- data/examples/rails-single/lib/tasks/.keep +0 -0
- data/examples/rails-single/log/.keep +0 -0
- data/examples/rails-single/package.json +17 -0
- data/examples/rails-single/public/400.html +114 -0
- data/examples/rails-single/public/404.html +114 -0
- data/examples/rails-single/public/406-unsupported-browser.html +114 -0
- data/examples/rails-single/public/422.html +114 -0
- data/examples/rails-single/public/500.html +114 -0
- data/examples/rails-single/public/icon.png +0 -0
- data/examples/rails-single/public/icon.svg +3 -0
- data/examples/rails-single/public/robots.txt +1 -0
- data/examples/rails-single/script/.keep +0 -0
- data/examples/rails-single/vendor/.keep +0 -0
- data/examples/rails-single/yarn.lock +188 -0
- data/exe/nvoi +6 -0
- data/lib/nvoi/cli.rb +190 -0
- data/lib/nvoi/cloudflare/client.rb +287 -0
- data/lib/nvoi/config/config.rb +248 -0
- data/lib/nvoi/config/env_resolver.rb +63 -0
- data/lib/nvoi/config/loader.rb +102 -0
- data/lib/nvoi/config/naming.rb +196 -0
- data/lib/nvoi/config/ssh_keys.rb +82 -0
- data/lib/nvoi/config/types.rb +274 -0
- data/lib/nvoi/constants.rb +59 -0
- data/lib/nvoi/credentials/crypto.rb +88 -0
- data/lib/nvoi/credentials/editor.rb +272 -0
- data/lib/nvoi/credentials/manager.rb +173 -0
- data/lib/nvoi/deployer/cleaner.rb +36 -0
- data/lib/nvoi/deployer/image_builder.rb +23 -0
- data/lib/nvoi/deployer/infrastructure.rb +126 -0
- data/lib/nvoi/deployer/orchestrator.rb +146 -0
- data/lib/nvoi/deployer/retry.rb +67 -0
- data/lib/nvoi/deployer/service_deployer.rb +311 -0
- data/lib/nvoi/deployer/tunnel_manager.rb +57 -0
- data/lib/nvoi/deployer/types.rb +8 -0
- data/lib/nvoi/errors.rb +67 -0
- data/lib/nvoi/k8s/renderer.rb +44 -0
- data/lib/nvoi/k8s/templates.rb +29 -0
- data/lib/nvoi/logger.rb +72 -0
- data/lib/nvoi/providers/aws.rb +403 -0
- data/lib/nvoi/providers/base.rb +111 -0
- data/lib/nvoi/providers/hetzner.rb +288 -0
- data/lib/nvoi/providers/hetzner_client.rb +170 -0
- data/lib/nvoi/remote/docker_manager.rb +203 -0
- data/lib/nvoi/remote/ssh_executor.rb +72 -0
- data/lib/nvoi/remote/volume_manager.rb +103 -0
- data/lib/nvoi/service/delete.rb +234 -0
- data/lib/nvoi/service/deploy.rb +80 -0
- data/lib/nvoi/service/exec.rb +144 -0
- data/lib/nvoi/service/provider.rb +36 -0
- data/lib/nvoi/steps/application_deployer.rb +26 -0
- data/lib/nvoi/steps/database_provisioner.rb +60 -0
- data/lib/nvoi/steps/k3s_cluster_setup.rb +105 -0
- data/lib/nvoi/steps/k3s_provisioner.rb +351 -0
- data/lib/nvoi/steps/server_provisioner.rb +43 -0
- data/lib/nvoi/steps/services_provisioner.rb +29 -0
- data/lib/nvoi/steps/tunnel_configurator.rb +66 -0
- data/lib/nvoi/steps/volume_provisioner.rb +154 -0
- data/lib/nvoi/version.rb +5 -0
- data/lib/nvoi.rb +79 -0
- data/templates/app-deployment.yaml.erb +102 -0
- data/templates/app-ingress.yaml.erb +20 -0
- data/templates/app-secret.yaml.erb +10 -0
- data/templates/app-service.yaml.erb +12 -0
- data/templates/db-statefulset.yaml.erb +76 -0
- data/templates/service-deployment.yaml.erb +91 -0
- data/templates/worker-deployment.yaml.erb +50 -0
- 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
|