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.
- checksums.yaml +4 -4
- data/.claude/todo/refactor/00-overview.md +171 -0
- data/.claude/todo/refactor/01-objects.md +96 -0
- data/.claude/todo/refactor/02-utils.md +143 -0
- data/.claude/todo/refactor/03-external-cloud.md +164 -0
- data/.claude/todo/refactor/04-external-dns.md +104 -0
- data/.claude/todo/refactor/05-external.md +133 -0
- data/.claude/todo/refactor/06-cli.md +123 -0
- data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
- data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
- data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
- data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
- data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
- data/.claude/todo/refactor/12-cli-db-command.md +128 -0
- data/.claude/todo/refactor/_target.md +79 -0
- data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
- data/.claude/todo/refactor-execution/01-objects.md +42 -0
- data/.claude/todo/refactor-execution/02-utils.md +41 -0
- data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
- data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
- data/.claude/todo/refactor-execution/05-external-other.md +46 -0
- data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
- data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
- data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
- data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
- data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
- data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
- data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
- data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
- data/.claude/todo/scaleway.impl.md +644 -0
- data/.claude/todo/scaleway.reference.md +520 -0
- data/.claude/todos.md +550 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +46 -5
- data/Rakefile +1 -1
- data/doc/config-schema.yaml +44 -11
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/main.go +18 -0
- data/exe/nvoi +3 -1
- data/ingest +0 -0
- data/lib/nvoi/cli/config/command.rb +219 -0
- data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
- data/lib/nvoi/cli/credentials/show/command.rb +35 -0
- data/lib/nvoi/cli/db/command.rb +308 -0
- data/lib/nvoi/cli/delete/command.rb +75 -0
- data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
- data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
- data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
- data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
- data/lib/nvoi/cli/deploy/command.rb +184 -0
- data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
- data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +102 -0
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -0
- data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +490 -0
- data/lib/nvoi/cli/exec/command.rb +173 -0
- data/lib/nvoi/cli/logs/command.rb +66 -0
- data/lib/nvoi/cli/onboard/command.rb +761 -0
- data/lib/nvoi/cli/unlock/command.rb +72 -0
- data/lib/nvoi/cli.rb +339 -141
- data/lib/nvoi/config_api/actions/app.rb +53 -0
- data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
- data/lib/nvoi/config_api/actions/database.rb +70 -0
- data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
- data/lib/nvoi/config_api/actions/env.rb +32 -0
- data/lib/nvoi/config_api/actions/init.rb +67 -0
- data/lib/nvoi/config_api/actions/secret.rb +32 -0
- data/lib/nvoi/config_api/actions/server.rb +66 -0
- data/lib/nvoi/config_api/actions/service.rb +52 -0
- data/lib/nvoi/config_api/actions/volume.rb +40 -0
- data/lib/nvoi/config_api/base.rb +38 -0
- data/lib/nvoi/config_api/result.rb +26 -0
- data/lib/nvoi/config_api.rb +93 -0
- data/lib/nvoi/errors.rb +68 -50
- data/lib/nvoi/external/cloud/aws.rb +450 -0
- data/lib/nvoi/external/cloud/base.rb +99 -0
- data/lib/nvoi/external/cloud/factory.rb +48 -0
- data/lib/nvoi/external/cloud/hetzner.rb +402 -0
- data/lib/nvoi/external/cloud/scaleway.rb +559 -0
- data/lib/nvoi/external/cloud.rb +15 -0
- data/lib/nvoi/external/containerd.rb +86 -0
- data/lib/nvoi/external/database/mysql.rb +84 -0
- data/lib/nvoi/external/database/postgres.rb +82 -0
- data/lib/nvoi/external/database/provider.rb +65 -0
- data/lib/nvoi/external/database/sqlite.rb +72 -0
- data/lib/nvoi/external/database.rb +22 -0
- data/lib/nvoi/external/dns/cloudflare.rb +310 -0
- data/lib/nvoi/external/kubectl.rb +65 -0
- data/lib/nvoi/external/ssh.rb +106 -0
- data/lib/nvoi/objects/config_override.rb +60 -0
- data/lib/nvoi/objects/configuration.rb +483 -0
- data/lib/nvoi/objects/database.rb +56 -0
- data/lib/nvoi/objects/dns.rb +14 -0
- data/lib/nvoi/objects/firewall.rb +11 -0
- data/lib/nvoi/objects/network.rb +11 -0
- data/lib/nvoi/objects/server.rb +14 -0
- data/lib/nvoi/objects/service_spec.rb +26 -0
- data/lib/nvoi/objects/tunnel.rb +14 -0
- data/lib/nvoi/objects/volume.rb +17 -0
- data/lib/nvoi/utils/config_loader.rb +172 -0
- data/lib/nvoi/utils/constants.rb +61 -0
- data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
- data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
- data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
- data/lib/nvoi/utils/logger.rb +84 -0
- data/lib/nvoi/{config/naming.rb → utils/namer.rb} +37 -25
- data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
- data/lib/nvoi/utils/templates.rb +62 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +27 -55
- data/templates/app-ingress.yaml.erb +3 -1
- data/templates/error-backend.yaml.erb +134 -0
- metadata +121 -44
- data/examples/golang/deploy.yml +0 -54
- data/lib/nvoi/cloudflare/client.rb +0 -287
- data/lib/nvoi/config/config.rb +0 -248
- data/lib/nvoi/config/loader.rb +0 -102
- data/lib/nvoi/config/ssh_keys.rb +0 -82
- data/lib/nvoi/config/types.rb +0 -274
- data/lib/nvoi/constants.rb +0 -59
- data/lib/nvoi/credentials/editor.rb +0 -272
- data/lib/nvoi/deployer/cleaner.rb +0 -36
- data/lib/nvoi/deployer/image_builder.rb +0 -23
- data/lib/nvoi/deployer/infrastructure.rb +0 -126
- data/lib/nvoi/deployer/orchestrator.rb +0 -146
- data/lib/nvoi/deployer/service_deployer.rb +0 -311
- data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
- data/lib/nvoi/deployer/types.rb +0 -8
- data/lib/nvoi/k8s/renderer.rb +0 -44
- data/lib/nvoi/k8s/templates.rb +0 -29
- data/lib/nvoi/logger.rb +0 -72
- data/lib/nvoi/providers/aws.rb +0 -403
- data/lib/nvoi/providers/base.rb +0 -111
- data/lib/nvoi/providers/hetzner.rb +0 -288
- data/lib/nvoi/providers/hetzner_client.rb +0 -170
- data/lib/nvoi/remote/docker_manager.rb +0 -203
- data/lib/nvoi/remote/ssh_executor.rb +0 -72
- data/lib/nvoi/remote/volume_manager.rb +0 -103
- data/lib/nvoi/service/delete.rb +0 -234
- data/lib/nvoi/service/deploy.rb +0 -80
- data/lib/nvoi/service/exec.rb +0 -144
- data/lib/nvoi/service/provider.rb +0 -36
- data/lib/nvoi/steps/application_deployer.rb +0 -26
- data/lib/nvoi/steps/database_provisioner.rb +0 -60
- data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
- data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
- data/lib/nvoi/steps/server_provisioner.rb +0 -43
- data/lib/nvoi/steps/services_provisioner.rb +0 -29
- data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
- data/lib/nvoi/steps/volume_provisioner.rb +0 -154
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Nvoi
|
|
7
|
+
class Cli
|
|
8
|
+
module Credentials
|
|
9
|
+
module Edit
|
|
10
|
+
# Command handles editing encrypted credentials
|
|
11
|
+
class Command
|
|
12
|
+
DEFAULT_ENCRYPTED_FILE = "deploy.enc"
|
|
13
|
+
DEFAULT_KEY_FILE = "deploy.key"
|
|
14
|
+
DEFAULT_EDITOR = "vim"
|
|
15
|
+
TEMP_FILE_PATTERN = "nvoi-credentials-"
|
|
16
|
+
|
|
17
|
+
def initialize(options)
|
|
18
|
+
@options = options
|
|
19
|
+
@log = Nvoi.logger
|
|
20
|
+
@editor = ENV["EDITOR"] || DEFAULT_EDITOR
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
@log.info "Credentials Editor"
|
|
25
|
+
|
|
26
|
+
working_dir = resolve_working_dir
|
|
27
|
+
enc_path = resolve_enc_path(working_dir)
|
|
28
|
+
is_first_time = !File.exist?(enc_path)
|
|
29
|
+
|
|
30
|
+
manager = if is_first_time
|
|
31
|
+
@log.info "Creating new encrypted credentials file"
|
|
32
|
+
Utils::CredentialStore.for_init(working_dir)
|
|
33
|
+
else
|
|
34
|
+
Utils::CredentialStore.new(working_dir, @options[:credentials], @options[:master_key])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get initial content
|
|
38
|
+
content = if is_first_time
|
|
39
|
+
default_template
|
|
40
|
+
else
|
|
41
|
+
manager.read
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Create temp file
|
|
45
|
+
tmp_file = Tempfile.new([TEMP_FILE_PATTERN, ".yaml"])
|
|
46
|
+
tmp_path = tmp_file.path
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
tmp_file.write(content)
|
|
50
|
+
tmp_file.close
|
|
51
|
+
|
|
52
|
+
# Edit loop: keep opening editor until valid or user quits
|
|
53
|
+
loop do
|
|
54
|
+
# Get file mtime before edit
|
|
55
|
+
before_mtime = File.mtime(tmp_path)
|
|
56
|
+
|
|
57
|
+
# Open editor
|
|
58
|
+
unless system(@editor, tmp_path)
|
|
59
|
+
raise Errors::CredentialError, "editor failed"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if file was modified
|
|
63
|
+
after_mtime = File.mtime(tmp_path)
|
|
64
|
+
if after_mtime == before_mtime
|
|
65
|
+
puts "No changes made, aborting."
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Read edited content
|
|
70
|
+
edited_content = File.read(tmp_path)
|
|
71
|
+
|
|
72
|
+
# Validate
|
|
73
|
+
validation_error = validate(edited_content)
|
|
74
|
+
if validation_error
|
|
75
|
+
puts "\n\e[31mValidation failed:\e[0m #{validation_error}"
|
|
76
|
+
puts "\nPress Enter to re-edit, or Ctrl+C to abort..."
|
|
77
|
+
$stdin.gets
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Valid: save
|
|
82
|
+
if is_first_time
|
|
83
|
+
manager.initialize_credentials(edited_content)
|
|
84
|
+
else
|
|
85
|
+
manager.write(edited_content)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
puts "\e[32mCredentials saved:\e[0m #{manager.encrypted_path}"
|
|
89
|
+
break
|
|
90
|
+
end
|
|
91
|
+
ensure
|
|
92
|
+
tmp_file.close rescue nil
|
|
93
|
+
tmp_file.unlink rescue nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Update .gitignore on first run
|
|
97
|
+
if manager.key_path
|
|
98
|
+
begin
|
|
99
|
+
update_gitignore(working_dir)
|
|
100
|
+
@log.info "Added %s to .gitignore", DEFAULT_KEY_FILE
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
@log.warning "Failed to update .gitignore: %s", e.message
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@log.success "Master key saved to: %s", manager.key_path
|
|
106
|
+
@log.warning "Keep this key safe! You cannot decrypt credentials without it."
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def set(path, value)
|
|
111
|
+
@log.info "Setting credential value"
|
|
112
|
+
|
|
113
|
+
working_dir = resolve_working_dir
|
|
114
|
+
manager = Utils::CredentialStore.new(working_dir, @options[:credentials], @options[:master_key])
|
|
115
|
+
|
|
116
|
+
# Read current content
|
|
117
|
+
content = manager.read
|
|
118
|
+
data = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
119
|
+
|
|
120
|
+
# Navigate path and set value
|
|
121
|
+
keys = path.split(".")
|
|
122
|
+
current = data
|
|
123
|
+
|
|
124
|
+
# Handle 'application.' prefix - it's implied
|
|
125
|
+
keys.shift if keys.first == "application"
|
|
126
|
+
|
|
127
|
+
# Navigate to parent
|
|
128
|
+
keys[0..-2].each do |key|
|
|
129
|
+
current["application"] ||= {}
|
|
130
|
+
current = current["application"]
|
|
131
|
+
current[key] ||= {}
|
|
132
|
+
current = current[key]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Set the value
|
|
136
|
+
if keys.length == 1
|
|
137
|
+
data["application"] ||= {}
|
|
138
|
+
data["application"][keys.last] = value
|
|
139
|
+
else
|
|
140
|
+
current[keys.last] = value
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Write back
|
|
144
|
+
new_content = YAML.dump(data)
|
|
145
|
+
manager.write(new_content)
|
|
146
|
+
|
|
147
|
+
@log.success "Updated: %s = %s", path, value
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def resolve_working_dir
|
|
153
|
+
wd = @options[:dir]
|
|
154
|
+
if wd.nil? || wd.empty? || wd == "."
|
|
155
|
+
Dir.pwd
|
|
156
|
+
else
|
|
157
|
+
File.expand_path(wd)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolve_enc_path(working_dir)
|
|
162
|
+
enc_path = @options[:credentials]
|
|
163
|
+
return File.join(working_dir, DEFAULT_ENCRYPTED_FILE) if enc_path.nil? || enc_path.empty?
|
|
164
|
+
|
|
165
|
+
enc_path
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate(content)
|
|
169
|
+
# First: basic YAML parse
|
|
170
|
+
begin
|
|
171
|
+
data = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
172
|
+
rescue Psych::SyntaxError => e
|
|
173
|
+
return "invalid YAML syntax: #{e.message}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
return "config must be a hash" unless data.is_a?(Hash)
|
|
177
|
+
|
|
178
|
+
# Second: validate required fields
|
|
179
|
+
validate_required_fields(data)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_required_fields(cfg)
|
|
183
|
+
app = cfg["application"]
|
|
184
|
+
return "application section is required" unless app.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
# Application name
|
|
187
|
+
return "application.name is required" if app["name"].nil? || app["name"].to_s.empty?
|
|
188
|
+
|
|
189
|
+
# Environment
|
|
190
|
+
return "application.environment is required" if app["environment"].nil? || app["environment"].to_s.empty?
|
|
191
|
+
|
|
192
|
+
# Domain provider
|
|
193
|
+
domain_provider = app["domain_provider"]
|
|
194
|
+
return "application.domain_provider.cloudflare is required" unless domain_provider&.dig("cloudflare")
|
|
195
|
+
|
|
196
|
+
cf = domain_provider["cloudflare"]
|
|
197
|
+
return "application.domain_provider.cloudflare.api_token is required" if cf["api_token"].nil? || cf["api_token"].to_s.empty?
|
|
198
|
+
return "application.domain_provider.cloudflare.account_id is required" if cf["account_id"].nil? || cf["account_id"].to_s.empty?
|
|
199
|
+
|
|
200
|
+
# Compute provider
|
|
201
|
+
compute_provider = app["compute_provider"]
|
|
202
|
+
has_compute = compute_provider&.dig("hetzner") || compute_provider&.dig("aws") || compute_provider&.dig("scaleway")
|
|
203
|
+
return "compute_provider (hetzner, aws, or scaleway) is required" unless has_compute
|
|
204
|
+
|
|
205
|
+
if (h = compute_provider&.dig("hetzner"))
|
|
206
|
+
return "application.compute_provider.hetzner.api_token is required" if h["api_token"].nil? || h["api_token"].to_s.empty?
|
|
207
|
+
return "application.compute_provider.hetzner.server_type is required" if h["server_type"].nil? || h["server_type"].to_s.empty?
|
|
208
|
+
return "application.compute_provider.hetzner.server_location is required" if h["server_location"].nil? || h["server_location"].to_s.empty?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if (a = compute_provider&.dig("aws"))
|
|
212
|
+
return "application.compute_provider.aws.access_key_id is required" if a["access_key_id"].nil? || a["access_key_id"].to_s.empty?
|
|
213
|
+
return "application.compute_provider.aws.secret_access_key is required" if a["secret_access_key"].nil? || a["secret_access_key"].to_s.empty?
|
|
214
|
+
return "application.compute_provider.aws.region is required" if a["region"].nil? || a["region"].to_s.empty?
|
|
215
|
+
return "application.compute_provider.aws.instance_type is required" if a["instance_type"].nil? || a["instance_type"].to_s.empty?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
if (s = compute_provider&.dig("scaleway"))
|
|
219
|
+
return "application.compute_provider.scaleway.secret_key is required" if s["secret_key"].nil? || s["secret_key"].to_s.empty?
|
|
220
|
+
return "application.compute_provider.scaleway.project_id is required" if s["project_id"].nil? || s["project_id"].to_s.empty?
|
|
221
|
+
return "application.compute_provider.scaleway.server_type is required" if s["server_type"].nil? || s["server_type"].to_s.empty?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Servers (if any services defined)
|
|
225
|
+
servers = app["servers"] || {}
|
|
226
|
+
app_services = app["app"] || {}
|
|
227
|
+
database = app["database"]
|
|
228
|
+
services = app["services"] || {}
|
|
229
|
+
|
|
230
|
+
has_services = !app_services.empty? || database || !services.empty?
|
|
231
|
+
return "servers must be defined when deploying services" if has_services && servers.empty?
|
|
232
|
+
|
|
233
|
+
defined_servers = servers.keys.to_set
|
|
234
|
+
|
|
235
|
+
# Validate app services
|
|
236
|
+
app_services.each do |service_name, svc|
|
|
237
|
+
next unless svc
|
|
238
|
+
|
|
239
|
+
return "app.#{service_name}.servers is required" if svc["servers"].nil? || svc["servers"].empty?
|
|
240
|
+
|
|
241
|
+
svc["servers"].each do |ref|
|
|
242
|
+
return "app.#{service_name} references undefined server: #{ref}" unless defined_servers.include?(ref)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Validate database
|
|
247
|
+
if database
|
|
248
|
+
return "database.servers is required" if database["servers"].nil? || database["servers"].empty?
|
|
249
|
+
|
|
250
|
+
database["servers"].each do |ref|
|
|
251
|
+
return "database references undefined server: #{ref}" unless defined_servers.include?(ref)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
db_error = validate_database_secrets(database)
|
|
255
|
+
return db_error if db_error
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Validate SSH keys
|
|
259
|
+
ssh_keys = app["ssh_keys"]
|
|
260
|
+
return "application.ssh_keys is required" unless ssh_keys.is_a?(Hash)
|
|
261
|
+
return "application.ssh_keys.private_key is required" if ssh_keys["private_key"].nil? || ssh_keys["private_key"].to_s.strip.empty?
|
|
262
|
+
return "application.ssh_keys.public_key is required" if ssh_keys["public_key"].nil? || ssh_keys["public_key"].to_s.strip.empty?
|
|
263
|
+
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def validate_database_secrets(db)
|
|
268
|
+
adapter = db["adapter"]&.downcase
|
|
269
|
+
url = db["url"]
|
|
270
|
+
|
|
271
|
+
return "database.adapter is required" if adapter.nil? || adapter.empty?
|
|
272
|
+
|
|
273
|
+
# URL takes precedence - if provided, no secrets needed
|
|
274
|
+
has_url = url && !url.to_s.empty?
|
|
275
|
+
|
|
276
|
+
case adapter
|
|
277
|
+
when "postgres", "postgresql"
|
|
278
|
+
return nil if has_url
|
|
279
|
+
|
|
280
|
+
%w[POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB].each do |key|
|
|
281
|
+
return "database.secrets.#{key} is required for postgres (or provide database.url)" unless db.dig("secrets", key)
|
|
282
|
+
end
|
|
283
|
+
when "mysql"
|
|
284
|
+
return nil if has_url
|
|
285
|
+
|
|
286
|
+
%w[MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE].each do |key|
|
|
287
|
+
return "database.secrets.#{key} is required for mysql (or provide database.url)" unless db.dig("secrets", key)
|
|
288
|
+
end
|
|
289
|
+
when "sqlite", "sqlite3"
|
|
290
|
+
# SQLite doesn't require secrets - path can be inferred from url, mount, or defaults
|
|
291
|
+
else
|
|
292
|
+
return "unsupported database adapter: #{adapter}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def default_template
|
|
299
|
+
# Generate SSH keypair for first-time setup
|
|
300
|
+
private_key, public_key = Utils::ConfigLoader.generate_keypair
|
|
301
|
+
|
|
302
|
+
<<~YAML
|
|
303
|
+
# NVOI Deployment Configuration
|
|
304
|
+
# This file is encrypted - never commit deploy.key!
|
|
305
|
+
|
|
306
|
+
application:
|
|
307
|
+
name: myapp
|
|
308
|
+
environment: production
|
|
309
|
+
|
|
310
|
+
domain_provider:
|
|
311
|
+
cloudflare:
|
|
312
|
+
api_token: YOUR_CLOUDFLARE_API_TOKEN
|
|
313
|
+
account_id: YOUR_CLOUDFLARE_ACCOUNT_ID
|
|
314
|
+
|
|
315
|
+
compute_provider:
|
|
316
|
+
hetzner:
|
|
317
|
+
api_token: YOUR_HETZNER_API_TOKEN
|
|
318
|
+
server_type: cx22
|
|
319
|
+
server_location: fsn1
|
|
320
|
+
|
|
321
|
+
servers:
|
|
322
|
+
master:
|
|
323
|
+
type: cx22
|
|
324
|
+
location: fsn1
|
|
325
|
+
|
|
326
|
+
keep_count: 2
|
|
327
|
+
|
|
328
|
+
app:
|
|
329
|
+
web:
|
|
330
|
+
servers: [master]
|
|
331
|
+
domain: example.com
|
|
332
|
+
subdomain: app
|
|
333
|
+
port: 3000
|
|
334
|
+
healthcheck:
|
|
335
|
+
type: http
|
|
336
|
+
path: /health
|
|
337
|
+
port: 3000
|
|
338
|
+
|
|
339
|
+
# database:
|
|
340
|
+
# servers: [master]
|
|
341
|
+
# adapter: postgres
|
|
342
|
+
# url: postgres://myapp:YOUR_DB_PASSWORD@localhost:5432/myapp_production
|
|
343
|
+
# image: postgres:16-alpine
|
|
344
|
+
#
|
|
345
|
+
# Or for SQLite (no container needed):
|
|
346
|
+
# database:
|
|
347
|
+
# servers: [master]
|
|
348
|
+
# adapter: sqlite3
|
|
349
|
+
# mount:
|
|
350
|
+
# db: /app/data
|
|
351
|
+
|
|
352
|
+
env:
|
|
353
|
+
# Add environment variables here
|
|
354
|
+
# RAILS_ENV: production
|
|
355
|
+
|
|
356
|
+
secrets:
|
|
357
|
+
# Add secrets here (will be injected as env vars)
|
|
358
|
+
# SECRET_KEY_BASE: YOUR_SECRET_KEY_BASE
|
|
359
|
+
|
|
360
|
+
# SSH keys (auto-generated, do not modify)
|
|
361
|
+
ssh_keys:
|
|
362
|
+
private_key: |
|
|
363
|
+
#{private_key.lines.map { |l| " #{l}" }.join}
|
|
364
|
+
public_key: #{public_key}
|
|
365
|
+
YAML
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def update_gitignore(working_dir)
|
|
369
|
+
gitignore_path = File.join(working_dir, ".gitignore")
|
|
370
|
+
existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
|
371
|
+
|
|
372
|
+
return if existing.include?(DEFAULT_KEY_FILE)
|
|
373
|
+
|
|
374
|
+
File.open(gitignore_path, "a") do |f|
|
|
375
|
+
f.puts "" unless existing.end_with?("\n") || existing.empty?
|
|
376
|
+
f.puts "# Nvoi master key - DO NOT COMMIT"
|
|
377
|
+
f.puts DEFAULT_KEY_FILE
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Credentials
|
|
6
|
+
module Show
|
|
7
|
+
# Command handles displaying decrypted credentials
|
|
8
|
+
class Command
|
|
9
|
+
def initialize(options)
|
|
10
|
+
@options = options
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
working_dir = resolve_working_dir
|
|
15
|
+
manager = Utils::CredentialStore.new(working_dir, @options[:credentials], @options[:master_key])
|
|
16
|
+
|
|
17
|
+
content = manager.read
|
|
18
|
+
puts content
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def resolve_working_dir
|
|
24
|
+
wd = @options[:dir]
|
|
25
|
+
if wd.nil? || wd.empty? || wd == "."
|
|
26
|
+
Dir.pwd
|
|
27
|
+
else
|
|
28
|
+
File.expand_path(wd)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|