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,761 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
require "tty-box"
|
|
6
|
+
require "tty-spinner"
|
|
7
|
+
require "tty-table"
|
|
8
|
+
require "nvoi/utils/credential_store"
|
|
9
|
+
|
|
10
|
+
module Nvoi
|
|
11
|
+
class Cli
|
|
12
|
+
module Onboard
|
|
13
|
+
# Interactive onboarding wizard for quick setup
|
|
14
|
+
class Command
|
|
15
|
+
MAX_RETRIES = 3
|
|
16
|
+
|
|
17
|
+
def initialize(prompt: nil)
|
|
18
|
+
@prompt = prompt || TTY::Prompt.new
|
|
19
|
+
@data = { "application" => {} }
|
|
20
|
+
@test_mode = prompt&.input.is_a?(StringIO)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
show_welcome
|
|
25
|
+
|
|
26
|
+
step_app_name
|
|
27
|
+
step_compute_provider
|
|
28
|
+
step_domain_provider
|
|
29
|
+
step_apps
|
|
30
|
+
step_database
|
|
31
|
+
step_env
|
|
32
|
+
|
|
33
|
+
summary_loop
|
|
34
|
+
|
|
35
|
+
show_next_steps
|
|
36
|
+
rescue TTY::Reader::InputInterrupt
|
|
37
|
+
puts "\n\nSetup cancelled."
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# ─────────────────────────────────────────────────────────────────
|
|
44
|
+
# Welcome
|
|
45
|
+
# ─────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def show_welcome
|
|
48
|
+
box = TTY::Box.frame(
|
|
49
|
+
"NVOI Quick Setup",
|
|
50
|
+
padding: [0, 2],
|
|
51
|
+
align: :center,
|
|
52
|
+
border: :light
|
|
53
|
+
)
|
|
54
|
+
puts box
|
|
55
|
+
puts
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────
|
|
59
|
+
# Step 1: App Name
|
|
60
|
+
# ─────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def step_app_name
|
|
63
|
+
name = @prompt.ask("Application name:") do |q|
|
|
64
|
+
q.required true
|
|
65
|
+
q.validate(/\A[a-z0-9_-]+\z/i, "Only letters, numbers, dashes, underscores")
|
|
66
|
+
end
|
|
67
|
+
@data["application"]["name"] = name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ─────────────────────────────────────────────────────────────────
|
|
71
|
+
# Step 2: Compute Provider
|
|
72
|
+
# ─────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def step_compute_provider
|
|
75
|
+
puts
|
|
76
|
+
puts section("Compute Provider")
|
|
77
|
+
|
|
78
|
+
provider = @prompt.select("Select provider:") do |menu|
|
|
79
|
+
menu.choice "Hetzner (recommended)", :hetzner
|
|
80
|
+
menu.choice "AWS", :aws
|
|
81
|
+
menu.choice "Scaleway", :scaleway
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
case provider
|
|
85
|
+
when :hetzner then setup_hetzner
|
|
86
|
+
when :aws then setup_aws
|
|
87
|
+
when :scaleway then setup_scaleway
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def setup_hetzner
|
|
92
|
+
token = prompt_with_retry("Hetzner API Token:", mask: true) do |t|
|
|
93
|
+
client = External::Cloud::Hetzner.new(t)
|
|
94
|
+
client.validate_credentials
|
|
95
|
+
@hetzner_client = client
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
types, locations = with_spinner("Fetching options...") do
|
|
99
|
+
[@hetzner_client.list_server_types, @hetzner_client.list_locations]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
type_choices = types.sort_by { |t| t[:name] }.map do |t|
|
|
103
|
+
price = t[:price] ? " - #{t[:price]}/mo" : ""
|
|
104
|
+
{ name: "#{t[:name]} (#{t[:cores]} vCPU, #{t[:memory] / 1024}GB#{price})", value: t[:name] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
location_choices = locations.map do |l|
|
|
108
|
+
{ name: "#{l[:name]} (#{l[:city]}, #{l[:country]})", value: l[:name] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
server_type = @prompt.select("Server type:", type_choices, per_page: 10)
|
|
112
|
+
location = @prompt.select("Location:", location_choices)
|
|
113
|
+
|
|
114
|
+
@data["application"]["compute_provider"] = {
|
|
115
|
+
"hetzner" => {
|
|
116
|
+
"api_token" => token,
|
|
117
|
+
"server_type" => server_type,
|
|
118
|
+
"server_location" => location
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def setup_aws
|
|
124
|
+
access_key = prompt_with_retry("AWS Access Key ID:") do |k|
|
|
125
|
+
raise Errors::ValidationError, "Invalid format" unless k.match?(/\AAKIA/)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
secret_key = @prompt.mask("AWS Secret Access Key:")
|
|
129
|
+
|
|
130
|
+
# Get regions first with temp client
|
|
131
|
+
temp_client = External::Cloud::Aws.new(access_key, secret_key, "us-east-1")
|
|
132
|
+
regions = with_spinner("Validating credentials...") do
|
|
133
|
+
temp_client.validate_credentials
|
|
134
|
+
temp_client.list_regions
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
region_choices = regions.map { |r| r[:name] }.sort
|
|
138
|
+
region = @prompt.select("Region:", region_choices, per_page: 10, filter: true)
|
|
139
|
+
|
|
140
|
+
# Now get instance types for selected region
|
|
141
|
+
client = External::Cloud::Aws.new(access_key, secret_key, region)
|
|
142
|
+
types = client.list_instance_types
|
|
143
|
+
|
|
144
|
+
type_choices = types.map do |t|
|
|
145
|
+
mem = t[:memory] ? " #{t[:memory] / 1024}GB" : ""
|
|
146
|
+
{ name: "#{t[:name]} (#{t[:vcpus]} vCPU#{mem})", value: t[:name] }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
instance_type = @prompt.select("Instance type:", type_choices)
|
|
150
|
+
|
|
151
|
+
@data["application"]["compute_provider"] = {
|
|
152
|
+
"aws" => {
|
|
153
|
+
"access_key_id" => access_key,
|
|
154
|
+
"secret_access_key" => secret_key,
|
|
155
|
+
"region" => region,
|
|
156
|
+
"instance_type" => instance_type
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def setup_scaleway
|
|
162
|
+
secret_key = prompt_with_retry("Scaleway Secret Key:", mask: true)
|
|
163
|
+
project_id = @prompt.ask("Scaleway Project ID:") { |q| q.required true }
|
|
164
|
+
|
|
165
|
+
# Get zones (static list)
|
|
166
|
+
temp_client = External::Cloud::Scaleway.new(secret_key, project_id)
|
|
167
|
+
zones = temp_client.list_zones
|
|
168
|
+
|
|
169
|
+
zone_choices = zones.map { |z| { name: "#{z[:name]} (#{z[:city]})", value: z[:name] } }
|
|
170
|
+
zone = @prompt.select("Zone:", zone_choices)
|
|
171
|
+
|
|
172
|
+
# Validate and get server types
|
|
173
|
+
client = External::Cloud::Scaleway.new(secret_key, project_id, zone:)
|
|
174
|
+
types = with_spinner("Validating credentials...") do
|
|
175
|
+
client.validate_credentials
|
|
176
|
+
client.list_server_types
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
type_choices = types.map do |t|
|
|
180
|
+
{ name: "#{t[:name]} (#{t[:cores]} cores)", value: t[:name] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
server_type = @prompt.select("Server type:", type_choices, per_page: 10, filter: true)
|
|
184
|
+
|
|
185
|
+
@data["application"]["compute_provider"] = {
|
|
186
|
+
"scaleway" => {
|
|
187
|
+
"secret_key" => secret_key,
|
|
188
|
+
"project_id" => project_id,
|
|
189
|
+
"zone" => zone,
|
|
190
|
+
"server_type" => server_type
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ─────────────────────────────────────────────────────────────────
|
|
196
|
+
# Step 3: Domain Provider
|
|
197
|
+
# ─────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def step_domain_provider
|
|
200
|
+
puts
|
|
201
|
+
puts section("Domain Provider")
|
|
202
|
+
|
|
203
|
+
setup = @prompt.yes?("Configure Cloudflare for domains/tunnels?")
|
|
204
|
+
return unless setup
|
|
205
|
+
|
|
206
|
+
token = prompt_with_retry("Cloudflare API Token:", mask: true)
|
|
207
|
+
account_id = @prompt.ask("Cloudflare Account ID:") { |q| q.required true }
|
|
208
|
+
|
|
209
|
+
@cloudflare_client = External::Dns::Cloudflare.new(token, account_id)
|
|
210
|
+
|
|
211
|
+
@cloudflare_zones = with_spinner("Fetching domains...") do
|
|
212
|
+
@cloudflare_client.validate_credentials
|
|
213
|
+
@cloudflare_client.list_zones.select { |z| z[:status] == "active" }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if @cloudflare_zones.empty?
|
|
217
|
+
warn "No active domains found in Cloudflare account"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
@data["application"]["domain_provider"] = {
|
|
221
|
+
"cloudflare" => {
|
|
222
|
+
"api_token" => token,
|
|
223
|
+
"account_id" => account_id
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# ─────────────────────────────────────────────────────────────────
|
|
229
|
+
# Step 4: Apps (loop)
|
|
230
|
+
# ─────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def step_apps
|
|
233
|
+
puts
|
|
234
|
+
puts section("Applications")
|
|
235
|
+
|
|
236
|
+
# Ensure we have a server
|
|
237
|
+
@data["application"]["servers"] ||= {}
|
|
238
|
+
@data["application"]["servers"]["main"] = { "master" => true, "count" => 1 }
|
|
239
|
+
|
|
240
|
+
@data["application"]["app"] ||= {}
|
|
241
|
+
|
|
242
|
+
loop do
|
|
243
|
+
name = @prompt.ask("App name:") { |q| q.required true }
|
|
244
|
+
command = @prompt.ask("Run command (optional, leave blank for Docker entrypoint):")
|
|
245
|
+
port = @prompt.ask("Port (optional, leave blank for background workers):")
|
|
246
|
+
port = port.to_i if port && !port.to_s.empty?
|
|
247
|
+
|
|
248
|
+
app_config = { "servers" => ["main"] }
|
|
249
|
+
app_config["command"] = command unless command.to_s.empty?
|
|
250
|
+
app_config["port"] = port if port && port > 0
|
|
251
|
+
|
|
252
|
+
# Domain selection only if port is set (web-facing) and Cloudflare configured
|
|
253
|
+
if port && port > 0 && @cloudflare_zones&.any?
|
|
254
|
+
domain, subdomain = prompt_domain_selection
|
|
255
|
+
if domain
|
|
256
|
+
app_config["domain"] = domain
|
|
257
|
+
app_config["subdomain"] = subdomain unless subdomain.to_s.empty?
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
pre_run = @prompt.ask("Pre-run command (e.g. migrations):")
|
|
262
|
+
app_config["pre_run_command"] = pre_run unless pre_run.to_s.empty?
|
|
263
|
+
|
|
264
|
+
@data["application"]["app"][name] = app_config
|
|
265
|
+
|
|
266
|
+
break unless @prompt.yes?("Add another app?")
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def prompt_domain_selection
|
|
271
|
+
domain_choices = @cloudflare_zones.map { |z| { name: z[:name], value: z } }
|
|
272
|
+
domain_choices << { name: "Skip (no domain)", value: nil }
|
|
273
|
+
|
|
274
|
+
selected = @prompt.select("Domain:", domain_choices)
|
|
275
|
+
return [nil, nil] unless selected
|
|
276
|
+
|
|
277
|
+
zone_id = selected[:id]
|
|
278
|
+
domain = selected[:name]
|
|
279
|
+
|
|
280
|
+
# Prompt for subdomain with validation
|
|
281
|
+
subdomain = prompt_subdomain(zone_id, domain)
|
|
282
|
+
|
|
283
|
+
[domain, subdomain]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def prompt_subdomain(zone_id, domain)
|
|
287
|
+
loop do
|
|
288
|
+
subdomain = @prompt.ask("Subdomain (leave blank for #{domain} + *.#{domain}):")
|
|
289
|
+
subdomain = subdomain.to_s.strip.downcase
|
|
290
|
+
|
|
291
|
+
# Validate subdomain format if provided
|
|
292
|
+
if !subdomain.empty? && !subdomain.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
|
|
293
|
+
error("Invalid subdomain format. Use lowercase letters, numbers, and hyphens.")
|
|
294
|
+
next
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Check availability for all hostnames that will be created
|
|
298
|
+
hostnames = Utils::Namer.build_hostnames(subdomain.empty? ? nil : subdomain, domain)
|
|
299
|
+
all_available = true
|
|
300
|
+
|
|
301
|
+
hostnames.each do |hostname|
|
|
302
|
+
available = with_spinner("Checking #{hostname}...") do
|
|
303
|
+
check_subdomain = hostname == domain ? "" : hostname.sub(".#{domain}", "")
|
|
304
|
+
@cloudflare_client.subdomain_available?(zone_id, check_subdomain, domain)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
unless available
|
|
308
|
+
error("#{hostname} already has a DNS record. Choose a different subdomain.")
|
|
309
|
+
all_available = false
|
|
310
|
+
break
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
return subdomain if all_available
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# ─────────────────────────────────────────────────────────────────
|
|
319
|
+
# Step 5: Database
|
|
320
|
+
# ─────────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
def step_database
|
|
323
|
+
puts
|
|
324
|
+
puts section("Database")
|
|
325
|
+
|
|
326
|
+
adapter = @prompt.select("Database:") do |menu|
|
|
327
|
+
menu.choice "PostgreSQL", "postgres"
|
|
328
|
+
menu.choice "MySQL", "mysql"
|
|
329
|
+
menu.choice "SQLite", "sqlite3"
|
|
330
|
+
menu.choice "None (skip)", nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
return unless adapter
|
|
334
|
+
|
|
335
|
+
db_config = {
|
|
336
|
+
"servers" => ["main"],
|
|
337
|
+
"adapter" => adapter
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case adapter
|
|
341
|
+
when "postgres"
|
|
342
|
+
db_name = @prompt.ask("Database name:", default: "#{@data["application"]["name"]}_production")
|
|
343
|
+
user = @prompt.ask("Database user:", default: @data["application"]["name"])
|
|
344
|
+
password = @prompt.mask("Database password:") { |q| q.required true }
|
|
345
|
+
|
|
346
|
+
db_config["secrets"] = {
|
|
347
|
+
"POSTGRES_DB" => db_name,
|
|
348
|
+
"POSTGRES_USER" => user,
|
|
349
|
+
"POSTGRES_PASSWORD" => password
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# Auto-add volume
|
|
353
|
+
@data["application"]["servers"]["main"]["volumes"] = {
|
|
354
|
+
"postgres_data" => { "size" => 10 }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
when "mysql"
|
|
358
|
+
db_name = @prompt.ask("Database name:", default: "#{@data["application"]["name"]}_production")
|
|
359
|
+
user = @prompt.ask("Database user:", default: @data["application"]["name"])
|
|
360
|
+
password = @prompt.mask("Database password:") { |q| q.required true }
|
|
361
|
+
|
|
362
|
+
db_config["secrets"] = {
|
|
363
|
+
"MYSQL_DATABASE" => db_name,
|
|
364
|
+
"MYSQL_USER" => user,
|
|
365
|
+
"MYSQL_PASSWORD" => password
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# Auto-add volume
|
|
369
|
+
@data["application"]["servers"]["main"]["volumes"] = {
|
|
370
|
+
"mysql_data" => { "size" => 10 }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
when "sqlite3"
|
|
374
|
+
path = @prompt.ask("Database path:", default: "/app/data/production.sqlite3")
|
|
375
|
+
db_config["path"] = path
|
|
376
|
+
db_config["mount"] = { "data" => "/app/data" }
|
|
377
|
+
|
|
378
|
+
# Auto-add volume
|
|
379
|
+
@data["application"]["servers"]["main"]["volumes"] = {
|
|
380
|
+
"sqlite_data" => { "size" => 10 }
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
@data["application"]["database"] = db_config
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# ─────────────────────────────────────────────────────────────────
|
|
388
|
+
# Step 6: Environment Variables
|
|
389
|
+
# ─────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
def step_env
|
|
392
|
+
puts
|
|
393
|
+
puts section("Environment Variables")
|
|
394
|
+
|
|
395
|
+
@data["application"]["env"] ||= {}
|
|
396
|
+
@data["application"]["secrets"] ||= {}
|
|
397
|
+
|
|
398
|
+
# Add default
|
|
399
|
+
@data["application"]["env"]["RAILS_ENV"] = "production"
|
|
400
|
+
|
|
401
|
+
loop do
|
|
402
|
+
show_env_table
|
|
403
|
+
|
|
404
|
+
choice = @prompt.select("Action:") do |menu|
|
|
405
|
+
menu.choice "Add variable", :add
|
|
406
|
+
menu.choice "Add secret (masked)", :secret
|
|
407
|
+
menu.choice "Done", :done
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
case choice
|
|
411
|
+
when :add
|
|
412
|
+
key = @prompt.ask("Variable name:") { |q| q.required true }
|
|
413
|
+
value = @prompt.ask("Value:") { |q| q.required true }
|
|
414
|
+
@data["application"]["env"][key] = value
|
|
415
|
+
|
|
416
|
+
when :secret
|
|
417
|
+
key = @prompt.ask("Secret name:") { |q| q.required true }
|
|
418
|
+
value = @prompt.mask("Value:") { |q| q.required true }
|
|
419
|
+
@data["application"]["secrets"][key] = value
|
|
420
|
+
|
|
421
|
+
when :done
|
|
422
|
+
break
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def show_env_table
|
|
428
|
+
return if @data["application"]["env"].empty? && @data["application"]["secrets"].empty?
|
|
429
|
+
|
|
430
|
+
rows = []
|
|
431
|
+
@data["application"]["env"].each { |k, v| rows << [k, v] }
|
|
432
|
+
@data["application"]["secrets"].each { |k, _| rows << [k, "********"] }
|
|
433
|
+
|
|
434
|
+
table = TTY::Table.new(header: %w[Key Value], rows:)
|
|
435
|
+
puts table.render(:unicode, padding: [0, 1])
|
|
436
|
+
puts
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# ─────────────────────────────────────────────────────────────────
|
|
440
|
+
# Summary & Save
|
|
441
|
+
# ─────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
def show_summary
|
|
444
|
+
puts
|
|
445
|
+
puts section("Summary")
|
|
446
|
+
|
|
447
|
+
provider_name = @data["application"]["compute_provider"]&.keys&.first || "none"
|
|
448
|
+
provider_info = case provider_name
|
|
449
|
+
when "hetzner"
|
|
450
|
+
cfg = @data["application"]["compute_provider"]["hetzner"]
|
|
451
|
+
"#{cfg["server_type"]} @ #{cfg["server_location"]}"
|
|
452
|
+
when "aws"
|
|
453
|
+
cfg = @data["application"]["compute_provider"]["aws"]
|
|
454
|
+
"#{cfg["instance_type"]} @ #{cfg["region"]}"
|
|
455
|
+
when "scaleway"
|
|
456
|
+
cfg = @data["application"]["compute_provider"]["scaleway"]
|
|
457
|
+
"#{cfg["server_type"]} @ #{cfg["zone"]}"
|
|
458
|
+
else
|
|
459
|
+
"not configured"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
domain_ok = @data["application"]["domain_provider"]&.any? ? "configured" : "not configured"
|
|
463
|
+
|
|
464
|
+
# Build app list with domains
|
|
465
|
+
app_list = @data["application"]["app"]&.map do |name, cfg|
|
|
466
|
+
if cfg["domain"]
|
|
467
|
+
fqdn = cfg["subdomain"] ? "#{cfg["subdomain"]}.#{cfg["domain"]}" : cfg["domain"]
|
|
468
|
+
"#{name} (#{fqdn})"
|
|
469
|
+
else
|
|
470
|
+
name
|
|
471
|
+
end
|
|
472
|
+
end&.join(", ") || "none"
|
|
473
|
+
db = @data["application"]["database"]&.dig("adapter") || "none"
|
|
474
|
+
env_count = (@data["application"]["env"]&.size || 0) + (@data["application"]["secrets"]&.size || 0)
|
|
475
|
+
|
|
476
|
+
rows = [
|
|
477
|
+
["Application", @data["application"]["name"]],
|
|
478
|
+
["Provider", "#{provider_name} (#{provider_info})"],
|
|
479
|
+
["Domain", "Cloudflare #{domain_ok}"],
|
|
480
|
+
["Apps", app_list],
|
|
481
|
+
["Database", db],
|
|
482
|
+
["Env/Secrets", "#{env_count} variables"]
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
table = TTY::Table.new(rows:)
|
|
486
|
+
puts table.render(:unicode, padding: [0, 1])
|
|
487
|
+
puts
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def summary_loop
|
|
491
|
+
loop do
|
|
492
|
+
show_summary
|
|
493
|
+
|
|
494
|
+
action = @prompt.select("What would you like to do?") do |menu|
|
|
495
|
+
menu.choice "Save configuration", :save
|
|
496
|
+
menu.choice "Edit application name", :app_name
|
|
497
|
+
menu.choice "Edit compute provider", :compute
|
|
498
|
+
menu.choice "Edit domain provider", :domain
|
|
499
|
+
menu.choice "Edit apps", :apps
|
|
500
|
+
menu.choice "Edit database", :database
|
|
501
|
+
menu.choice "Edit environment variables", :env
|
|
502
|
+
menu.choice "Start over", :restart
|
|
503
|
+
menu.choice "Cancel (discard)", :cancel
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
case action
|
|
507
|
+
when :save
|
|
508
|
+
save_config
|
|
509
|
+
return
|
|
510
|
+
when :cancel
|
|
511
|
+
return if @prompt.yes?("Discard all changes?")
|
|
512
|
+
when :app_name
|
|
513
|
+
step_app_name
|
|
514
|
+
when :compute
|
|
515
|
+
step_compute_provider
|
|
516
|
+
when :domain
|
|
517
|
+
step_domain_provider
|
|
518
|
+
when :apps
|
|
519
|
+
edit_apps
|
|
520
|
+
when :database
|
|
521
|
+
step_database
|
|
522
|
+
when :env
|
|
523
|
+
step_env
|
|
524
|
+
when :restart
|
|
525
|
+
if @prompt.yes?("This will clear all data. Continue?")
|
|
526
|
+
@data = { "application" => {} }
|
|
527
|
+
@cloudflare_client = nil
|
|
528
|
+
@cloudflare_zones = nil
|
|
529
|
+
step_app_name
|
|
530
|
+
step_compute_provider
|
|
531
|
+
step_domain_provider
|
|
532
|
+
step_apps
|
|
533
|
+
step_database
|
|
534
|
+
step_env
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def edit_apps
|
|
541
|
+
loop do
|
|
542
|
+
app_names = @data["application"]["app"]&.keys || []
|
|
543
|
+
|
|
544
|
+
choices = app_names.map { |name| { name:, value: name } }
|
|
545
|
+
choices << { name: "Add new app", value: :add }
|
|
546
|
+
choices << { name: "Done", value: :done }
|
|
547
|
+
|
|
548
|
+
selected = @prompt.select("Apps:", choices)
|
|
549
|
+
|
|
550
|
+
case selected
|
|
551
|
+
when :add
|
|
552
|
+
add_single_app
|
|
553
|
+
when :done
|
|
554
|
+
return
|
|
555
|
+
else
|
|
556
|
+
# Selected an app name - show actions
|
|
557
|
+
app_action = @prompt.select("#{selected}:") do |menu|
|
|
558
|
+
menu.choice "Edit", :edit
|
|
559
|
+
menu.choice "Delete", :delete
|
|
560
|
+
menu.choice "Back", :back
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
case app_action
|
|
564
|
+
when :edit
|
|
565
|
+
edit_single_app(selected)
|
|
566
|
+
when :delete
|
|
567
|
+
@data["application"]["app"].delete(selected) if @prompt.yes?("Delete #{selected}?")
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def edit_single_app(name)
|
|
574
|
+
app = @data["application"]["app"][name]
|
|
575
|
+
|
|
576
|
+
new_name = @prompt.ask("App name:", default: name) { |q| q.required true }
|
|
577
|
+
existing_cmd = app["command"]
|
|
578
|
+
command = if existing_cmd
|
|
579
|
+
@prompt.ask("Run command (optional):", default: existing_cmd)
|
|
580
|
+
else
|
|
581
|
+
@prompt.ask("Run command (optional, leave blank for Docker entrypoint):")
|
|
582
|
+
end
|
|
583
|
+
existing_port = app["port"]
|
|
584
|
+
port = if existing_port
|
|
585
|
+
@prompt.ask("Port (optional):", default: existing_port.to_s)
|
|
586
|
+
else
|
|
587
|
+
@prompt.ask("Port (optional, leave blank for background workers):")
|
|
588
|
+
end
|
|
589
|
+
port = port.to_i if port && !port.to_s.empty?
|
|
590
|
+
|
|
591
|
+
new_config = { "servers" => app["servers"] || ["main"] }
|
|
592
|
+
new_config["command"] = command unless command.to_s.empty?
|
|
593
|
+
new_config["port"] = port if port && port > 0
|
|
594
|
+
|
|
595
|
+
# Domain selection only if port is set (web-facing)
|
|
596
|
+
if port && port > 0 && @cloudflare_zones&.any?
|
|
597
|
+
domain, subdomain = prompt_domain_selection
|
|
598
|
+
if domain
|
|
599
|
+
new_config["domain"] = domain
|
|
600
|
+
new_config["subdomain"] = subdomain unless subdomain.to_s.empty?
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
existing_pre_run = app["pre_run_command"]
|
|
605
|
+
pre_run = if existing_pre_run
|
|
606
|
+
@prompt.ask("Pre-run command (optional):", default: existing_pre_run)
|
|
607
|
+
else
|
|
608
|
+
@prompt.ask("Pre-run command (optional):")
|
|
609
|
+
end
|
|
610
|
+
new_config["pre_run_command"] = pre_run unless pre_run.to_s.empty?
|
|
611
|
+
|
|
612
|
+
# Handle rename
|
|
613
|
+
@data["application"]["app"].delete(name) if new_name != name
|
|
614
|
+
@data["application"]["app"][new_name] = new_config
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def add_single_app
|
|
618
|
+
name = @prompt.ask("App name:") { |q| q.required true }
|
|
619
|
+
command = @prompt.ask("Run command (optional, leave blank for Docker entrypoint):")
|
|
620
|
+
port = @prompt.ask("Port (optional, leave blank for background workers):")
|
|
621
|
+
port = port.to_i if port && !port.to_s.empty?
|
|
622
|
+
|
|
623
|
+
app_config = { "servers" => ["main"] }
|
|
624
|
+
app_config["command"] = command unless command.to_s.empty?
|
|
625
|
+
app_config["port"] = port if port && port > 0
|
|
626
|
+
|
|
627
|
+
# Domain selection only if port is set (web-facing)
|
|
628
|
+
if port && port > 0 && @cloudflare_zones&.any?
|
|
629
|
+
domain, subdomain = prompt_domain_selection
|
|
630
|
+
if domain
|
|
631
|
+
app_config["domain"] = domain
|
|
632
|
+
app_config["subdomain"] = subdomain unless subdomain.to_s.empty?
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
pre_run = @prompt.ask("Pre-run command (e.g. migrations):")
|
|
637
|
+
app_config["pre_run_command"] = pre_run unless pre_run.to_s.empty?
|
|
638
|
+
|
|
639
|
+
@data["application"]["app"][name] = app_config
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def save_config
|
|
643
|
+
with_spinner("Generating SSH keys...") do
|
|
644
|
+
# Use ConfigApi.init to generate keys and encrypt
|
|
645
|
+
result = ConfigApi.init(
|
|
646
|
+
name: @data["application"]["name"],
|
|
647
|
+
environment: "production"
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if result.failure?
|
|
651
|
+
raise Errors::ConfigError, "Failed to initialize: #{result.error_message}"
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Now we need to apply all our config on top
|
|
655
|
+
# Decrypt the init result, merge our data, re-encrypt
|
|
656
|
+
yaml = Utils::Crypto.decrypt(result.config, result.master_key)
|
|
657
|
+
init_data = YAML.safe_load(yaml, permitted_classes: [Symbol])
|
|
658
|
+
|
|
659
|
+
# Merge our data into init_data (keep ssh_keys from init)
|
|
660
|
+
init_data["application"].merge!(@data["application"])
|
|
661
|
+
init_data["application"]["ssh_keys"] = YAML.safe_load(yaml)["application"]["ssh_keys"]
|
|
662
|
+
|
|
663
|
+
# Write files
|
|
664
|
+
config_path = File.join(".", Utils::DEFAULT_ENCRYPTED_FILE)
|
|
665
|
+
key_path = File.join(".", Utils::DEFAULT_KEY_FILE)
|
|
666
|
+
|
|
667
|
+
final_yaml = YAML.dump(init_data)
|
|
668
|
+
encrypted = Utils::Crypto.encrypt(final_yaml, result.master_key)
|
|
669
|
+
|
|
670
|
+
File.binwrite(config_path, encrypted)
|
|
671
|
+
File.write(key_path, "#{result.master_key}\n", perm: 0o600)
|
|
672
|
+
|
|
673
|
+
update_gitignore
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
puts
|
|
677
|
+
success("Created #{Utils::DEFAULT_ENCRYPTED_FILE}")
|
|
678
|
+
success("Created #{Utils::DEFAULT_KEY_FILE}")
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def show_next_steps
|
|
682
|
+
puts
|
|
683
|
+
puts "Next: #{pastel.cyan("nvoi deploy")}"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# ─────────────────────────────────────────────────────────────────
|
|
687
|
+
# Helpers
|
|
688
|
+
# ─────────────────────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
def prompt_with_retry(message, mask: false, &validation)
|
|
691
|
+
retries = 0
|
|
692
|
+
loop do
|
|
693
|
+
value = mask ? @prompt.mask(message) : @prompt.ask(message) { |q| q.required true }
|
|
694
|
+
|
|
695
|
+
begin
|
|
696
|
+
yield(value) if block_given?
|
|
697
|
+
return value
|
|
698
|
+
rescue Errors::ValidationError, Errors::AuthenticationError => e
|
|
699
|
+
retries += 1
|
|
700
|
+
if retries >= MAX_RETRIES
|
|
701
|
+
error("Failed after #{MAX_RETRIES} attempts: #{e.message}")
|
|
702
|
+
raise
|
|
703
|
+
end
|
|
704
|
+
warn("#{e.message}. Please try again. (#{retries}/#{MAX_RETRIES})")
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def section(title)
|
|
710
|
+
pastel.bold("─── #{title} ───")
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def with_spinner(message)
|
|
714
|
+
if @test_mode
|
|
715
|
+
result = yield
|
|
716
|
+
return result
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
|
|
720
|
+
spinner.auto_spin
|
|
721
|
+
begin
|
|
722
|
+
result = yield
|
|
723
|
+
spinner.success("done")
|
|
724
|
+
result
|
|
725
|
+
rescue StandardError => e
|
|
726
|
+
spinner.error("failed")
|
|
727
|
+
raise e
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def success(msg)
|
|
732
|
+
puts "#{pastel.green("✓")} #{msg}"
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def error(msg)
|
|
736
|
+
warn "#{pastel.red("✗")} #{msg}"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def pastel
|
|
740
|
+
@pastel ||= Pastel.new
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def update_gitignore
|
|
744
|
+
gitignore_path = ".gitignore"
|
|
745
|
+
entries = ["deploy.key", ".env", ".env.*", "!.env.example"]
|
|
746
|
+
|
|
747
|
+
existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
|
748
|
+
additions = entries.reject { |e| existing.include?(e) }
|
|
749
|
+
|
|
750
|
+
return if additions.empty?
|
|
751
|
+
|
|
752
|
+
File.open(gitignore_path, "a") do |f|
|
|
753
|
+
f.puts "" unless existing.end_with?("\n") || existing.empty?
|
|
754
|
+
f.puts "# NVOI"
|
|
755
|
+
additions.each { |e| f.puts e }
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
end
|