nvoi 0.1.6 → 0.1.8
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/12-cli-db-command.md +128 -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/todos/buckets.md +41 -0
- data/.claude/todos.md +550 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +35 -4
- data/Rakefile +1 -1
- data/ingest +0 -0
- data/lib/nvoi/cli/config/command.rb +219 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +12 -11
- data/lib/nvoi/cli/deploy/command.rb +27 -11
- data/lib/nvoi/cli/deploy/steps/build_image.rb +48 -6
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +15 -13
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +8 -15
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +10 -1
- 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 +257 -0
- data/lib/nvoi/config_api/actions/app.rb +30 -30
- data/lib/nvoi/config_api/actions/compute_provider.rb +31 -31
- data/lib/nvoi/config_api/actions/database.rb +42 -42
- data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
- data/lib/nvoi/config_api/actions/env.rb +12 -12
- data/lib/nvoi/config_api/actions/init.rb +67 -0
- data/lib/nvoi/config_api/actions/secret.rb +12 -12
- data/lib/nvoi/config_api/actions/server.rb +35 -35
- data/lib/nvoi/config_api/actions/service.rb +52 -0
- data/lib/nvoi/config_api/actions/volume.rb +18 -18
- data/lib/nvoi/config_api/base.rb +15 -21
- data/lib/nvoi/config_api/result.rb +5 -5
- data/lib/nvoi/config_api.rb +51 -28
- data/lib/nvoi/external/cloud/aws.rb +26 -1
- data/lib/nvoi/external/cloud/hetzner.rb +27 -1
- data/lib/nvoi/external/cloud/scaleway.rb +32 -6
- data/lib/nvoi/external/containerd.rb +1 -44
- data/lib/nvoi/external/dns/cloudflare.rb +34 -16
- data/lib/nvoi/external/ssh.rb +0 -12
- data/lib/nvoi/external/ssh_tunnel.rb +100 -0
- data/lib/nvoi/objects/configuration.rb +20 -0
- data/lib/nvoi/utils/namer.rb +9 -0
- data/lib/nvoi/utils/retry.rb +1 -1
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +16 -0
- data/templates/app-ingress.yaml.erb +3 -1
- metadata +27 -1
data/.claude/todos.md
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
# Onboard Wizard: Multi-Server & Volume Validation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Extend the onboard wizard to support multi-server setups and add validation for volume/server constraints.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Part 1: Volume/Server Validation
|
|
10
|
+
|
|
11
|
+
### File: `lib/nvoi/objects/configuration.rb`
|
|
12
|
+
|
|
13
|
+
Add validation in `validate_servers_and_references` method (after line ~108):
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Validate volume mount constraints
|
|
17
|
+
def validate_volume_mounts(app, servers)
|
|
18
|
+
app.app.each do |app_name, app_config|
|
|
19
|
+
next if app_config.mounts.nil? || app_config.mounts.empty?
|
|
20
|
+
|
|
21
|
+
# Can't mount volumes when running on multiple servers
|
|
22
|
+
if app_config.servers.size > 1
|
|
23
|
+
raise Errors::ConfigValidationError,
|
|
24
|
+
"app.#{app_name}: cannot mount volumes when running on multiple servers (#{app_config.servers.join(', ')})"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Can't mount volumes on multi-instance server (count > 1)
|
|
28
|
+
app_config.servers.each do |server_ref|
|
|
29
|
+
server = servers[server_ref]
|
|
30
|
+
next unless server
|
|
31
|
+
|
|
32
|
+
if server.count && server.count > 1
|
|
33
|
+
raise Errors::ConfigValidationError,
|
|
34
|
+
"app.#{app_name}: cannot mount volumes on multi-instance server '#{server_ref}' (count: #{server.count})"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Verify volume exists on server
|
|
38
|
+
app_config.mounts.each_key do |vol_name|
|
|
39
|
+
unless server.volumes&.key?(vol_name)
|
|
40
|
+
available = server.volumes&.keys&.join(", ") || "none"
|
|
41
|
+
raise Errors::ConfigValidationError,
|
|
42
|
+
"app.#{app_name}: mount '#{vol_name}' not found on server '#{server_ref}' (available: #{available})"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Database always needs volume, validate server constraints
|
|
49
|
+
return unless app.database
|
|
50
|
+
|
|
51
|
+
app.database.servers.each do |server_ref|
|
|
52
|
+
server = servers[server_ref]
|
|
53
|
+
next unless server
|
|
54
|
+
|
|
55
|
+
if server.count && server.count > 1
|
|
56
|
+
raise Errors::ConfigValidationError,
|
|
57
|
+
"database: cannot run on multi-instance server '#{server_ref}' (count: #{server.count}) - volumes can't multi-attach"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### File: `test/nvoi/objects/config_test.rb`
|
|
64
|
+
|
|
65
|
+
Add tests:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
def test_validates_no_volume_mount_on_multi_instance_server
|
|
69
|
+
config_data = {
|
|
70
|
+
"application" => {
|
|
71
|
+
"name" => "test",
|
|
72
|
+
"servers" => {
|
|
73
|
+
"workers" => { "count" => 3, "volumes" => { "data" => { "size" => 10 } } }
|
|
74
|
+
},
|
|
75
|
+
"app" => {
|
|
76
|
+
"web" => { "servers" => ["workers"], "mounts" => { "data" => "/app/data" } }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
error = assert_raises(Nvoi::Errors::ConfigValidationError) do
|
|
82
|
+
Nvoi::Objects::Configuration.new(config_data)
|
|
83
|
+
end
|
|
84
|
+
assert_match(/cannot mount volumes on multi-instance server/, error.message)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def test_validates_no_volume_mount_on_multiple_servers
|
|
88
|
+
config_data = {
|
|
89
|
+
"application" => {
|
|
90
|
+
"name" => "test",
|
|
91
|
+
"servers" => {
|
|
92
|
+
"server1" => { "master" => true, "volumes" => { "data" => { "size" => 10 } } },
|
|
93
|
+
"server2" => { "volumes" => { "data" => { "size" => 10 } } }
|
|
94
|
+
},
|
|
95
|
+
"app" => {
|
|
96
|
+
"web" => { "servers" => ["server1", "server2"], "mounts" => { "data" => "/app/data" } }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
error = assert_raises(Nvoi::Errors::ConfigValidationError) do
|
|
102
|
+
Nvoi::Objects::Configuration.new(config_data)
|
|
103
|
+
end
|
|
104
|
+
assert_match(/cannot mount volumes when running on multiple servers/, error.message)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_validates_database_not_on_multi_instance_server
|
|
108
|
+
config_data = {
|
|
109
|
+
"application" => {
|
|
110
|
+
"name" => "test",
|
|
111
|
+
"servers" => {
|
|
112
|
+
"workers" => { "master" => true, "count" => 2, "volumes" => { "pg_data" => { "size" => 10 } } }
|
|
113
|
+
},
|
|
114
|
+
"database" => {
|
|
115
|
+
"servers" => ["workers"],
|
|
116
|
+
"adapter" => "postgres",
|
|
117
|
+
"secrets" => { "POSTGRES_DB" => "x", "POSTGRES_USER" => "x", "POSTGRES_PASSWORD" => "x" }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
error = assert_raises(Nvoi::Errors::ConfigValidationError) do
|
|
123
|
+
Nvoi::Objects::Configuration.new(config_data)
|
|
124
|
+
end
|
|
125
|
+
assert_match(/database: cannot run on multi-instance server/, error.message)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_validates_mount_volume_exists_on_server
|
|
129
|
+
config_data = {
|
|
130
|
+
"application" => {
|
|
131
|
+
"name" => "test",
|
|
132
|
+
"servers" => {
|
|
133
|
+
"main" => { "master" => true } # no volumes defined
|
|
134
|
+
},
|
|
135
|
+
"app" => {
|
|
136
|
+
"web" => { "servers" => ["main"], "mounts" => { "data" => "/app/data" } }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
error = assert_raises(Nvoi::Errors::ConfigValidationError) do
|
|
142
|
+
Nvoi::Objects::Configuration.new(config_data)
|
|
143
|
+
end
|
|
144
|
+
assert_match(/mount 'data' not found on server 'main'/, error.message)
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Part 2: Onboard Wizard - Server Setup
|
|
151
|
+
|
|
152
|
+
### File: `lib/nvoi/cli/onboard/command.rb`
|
|
153
|
+
|
|
154
|
+
#### 2.1 Add `step_servers` after `step_compute_provider`
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
def step_servers
|
|
158
|
+
puts
|
|
159
|
+
puts section("Server Configuration")
|
|
160
|
+
|
|
161
|
+
mode = @prompt.select("Server setup:") do |menu|
|
|
162
|
+
menu.choice "Single server (recommended for small apps)", :single
|
|
163
|
+
menu.choice "Multi-server (master + workers)", :multi
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
case mode
|
|
167
|
+
when :single
|
|
168
|
+
setup_single_server
|
|
169
|
+
when :multi
|
|
170
|
+
setup_multi_server
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def setup_single_server
|
|
175
|
+
@data["application"]["servers"] = {
|
|
176
|
+
"main" => { "master" => true, "count" => 1 }
|
|
177
|
+
}
|
|
178
|
+
@server_mode = :single
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def setup_multi_server
|
|
182
|
+
@data["application"]["servers"] = {}
|
|
183
|
+
|
|
184
|
+
# Master server (control plane)
|
|
185
|
+
puts
|
|
186
|
+
puts pastel.dim("Master server runs k3s control plane")
|
|
187
|
+
master_type = prompt_server_type("Master server type:")
|
|
188
|
+
@data["application"]["servers"]["master"] = {
|
|
189
|
+
"master" => true,
|
|
190
|
+
"count" => 1,
|
|
191
|
+
"type" => master_type
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Worker servers
|
|
195
|
+
puts
|
|
196
|
+
puts pastel.dim("Worker servers run your apps")
|
|
197
|
+
worker_type = prompt_server_type("Worker server type:")
|
|
198
|
+
worker_count = @prompt.ask("Number of workers:", default: "2", convert: :int)
|
|
199
|
+
@data["application"]["servers"]["workers"] = {
|
|
200
|
+
"count" => worker_count,
|
|
201
|
+
"type" => worker_type
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Dedicated database server?
|
|
205
|
+
if @prompt.yes?("Dedicated database server? (recommended for production)")
|
|
206
|
+
db_type = prompt_server_type("Database server type:")
|
|
207
|
+
@data["application"]["servers"]["database"] = {
|
|
208
|
+
"count" => 1,
|
|
209
|
+
"type" => db_type
|
|
210
|
+
}
|
|
211
|
+
@dedicated_db_server = true
|
|
212
|
+
else
|
|
213
|
+
@dedicated_db_server = false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
@server_mode = :multi
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def prompt_server_type(message)
|
|
220
|
+
# Use cached server types from compute provider setup
|
|
221
|
+
return @prompt.ask(message) unless @server_types
|
|
222
|
+
|
|
223
|
+
choices = @server_types.map do |t|
|
|
224
|
+
price = t[:price] ? " - #{t[:price]}/mo" : ""
|
|
225
|
+
{ name: "#{t[:name]} (#{t[:cores]} vCPU, #{t[:memory] / 1024}GB#{price})", value: t[:name] }
|
|
226
|
+
end
|
|
227
|
+
@prompt.select(message, choices, per_page: 10)
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
#### 2.2 Update `step_apps` - Add replicas and server selection
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
def step_apps
|
|
235
|
+
puts
|
|
236
|
+
puts section("Applications")
|
|
237
|
+
|
|
238
|
+
@data["application"]["app"] ||= {}
|
|
239
|
+
|
|
240
|
+
loop do
|
|
241
|
+
name = @prompt.ask("App name:") { |q| q.required true }
|
|
242
|
+
command = @prompt.ask("Run command (optional, leave blank for Docker entrypoint):")
|
|
243
|
+
port = @prompt.ask("Port (optional, leave blank for background workers):")
|
|
244
|
+
port = port.to_i if port && !port.to_s.empty?
|
|
245
|
+
|
|
246
|
+
app_config = { "servers" => default_app_servers }
|
|
247
|
+
app_config["command"] = command unless command.to_s.empty?
|
|
248
|
+
app_config["port"] = port if port && port > 0
|
|
249
|
+
|
|
250
|
+
# Replicas (only for web-facing apps)
|
|
251
|
+
if port && port > 0
|
|
252
|
+
replicas = @prompt.ask("Replicas:", default: "2", convert: :int)
|
|
253
|
+
app_config["replicas"] = replicas if replicas && replicas > 0
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Server selection (only for multi-server)
|
|
257
|
+
if @server_mode == :multi
|
|
258
|
+
app_config["servers"] = prompt_server_selection(name, has_mounts: false)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Domain selection only if port is set (web-facing) and Cloudflare configured
|
|
262
|
+
if port && port > 0 && @cloudflare_zones&.any?
|
|
263
|
+
domain, subdomain = prompt_domain_selection
|
|
264
|
+
if domain
|
|
265
|
+
app_config["domain"] = domain
|
|
266
|
+
app_config["subdomain"] = subdomain unless subdomain.to_s.empty?
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
pre_run = @prompt.ask("Pre-run command (e.g. migrations):")
|
|
271
|
+
app_config["pre_run_command"] = pre_run unless pre_run.to_s.empty?
|
|
272
|
+
|
|
273
|
+
@data["application"]["app"][name] = app_config
|
|
274
|
+
|
|
275
|
+
break unless @prompt.yes?("Add another app?")
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def default_app_servers
|
|
280
|
+
case @server_mode
|
|
281
|
+
when :single then ["main"]
|
|
282
|
+
when :multi then ["workers"]
|
|
283
|
+
else ["main"]
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def prompt_server_selection(context, has_mounts: false)
|
|
288
|
+
servers = @data["application"]["servers"].keys
|
|
289
|
+
|
|
290
|
+
if has_mounts
|
|
291
|
+
# Filter to single-instance servers only
|
|
292
|
+
servers = servers.select do |name|
|
|
293
|
+
count = @data["application"]["servers"][name]["count"] || 1
|
|
294
|
+
count == 1
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if servers.empty?
|
|
298
|
+
error("No single-instance servers available for volume mounts")
|
|
299
|
+
error("Volume mounts require count: 1 server (volumes can't multi-attach)")
|
|
300
|
+
return ["main"]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Single select only
|
|
304
|
+
selected = @prompt.select("Server for #{context} (volumes require single server):", servers)
|
|
305
|
+
[selected]
|
|
306
|
+
else
|
|
307
|
+
# Multi-select allowed for stateless apps
|
|
308
|
+
@prompt.multi_select("Servers for #{context}:", servers, min: 1)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### 2.3 Update `step_database` - Server selection for multi-server
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
def step_database
|
|
317
|
+
puts
|
|
318
|
+
puts section("Database")
|
|
319
|
+
|
|
320
|
+
adapter = @prompt.select("Database:") do |menu|
|
|
321
|
+
menu.choice "PostgreSQL", "postgres"
|
|
322
|
+
menu.choice "MySQL", "mysql"
|
|
323
|
+
menu.choice "SQLite", "sqlite3"
|
|
324
|
+
menu.choice "None (skip)", nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
return unless adapter
|
|
328
|
+
|
|
329
|
+
# Database server selection for multi-server mode
|
|
330
|
+
db_servers = if @server_mode == :multi
|
|
331
|
+
if @dedicated_db_server
|
|
332
|
+
["database"] # Use dedicated db server
|
|
333
|
+
else
|
|
334
|
+
prompt_server_selection("database", has_mounts: true)
|
|
335
|
+
end
|
|
336
|
+
else
|
|
337
|
+
["main"]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
db_config = {
|
|
341
|
+
"servers" => db_servers,
|
|
342
|
+
"adapter" => adapter
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Add volume to the database server
|
|
346
|
+
db_server_name = db_servers.first
|
|
347
|
+
@data["application"]["servers"][db_server_name]["volumes"] ||= {}
|
|
348
|
+
|
|
349
|
+
case adapter
|
|
350
|
+
when "postgres"
|
|
351
|
+
db_name = @prompt.ask("Database name:", default: "#{@data["application"]["name"]}_production")
|
|
352
|
+
user = @prompt.ask("Database user:", default: @data["application"]["name"])
|
|
353
|
+
password = @prompt.mask("Database password:") { |q| q.required true }
|
|
354
|
+
|
|
355
|
+
db_config["secrets"] = {
|
|
356
|
+
"POSTGRES_DB" => db_name,
|
|
357
|
+
"POSTGRES_USER" => user,
|
|
358
|
+
"POSTGRES_PASSWORD" => password
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
@data["application"]["servers"][db_server_name]["volumes"]["postgres_data"] = { "size" => 10 }
|
|
362
|
+
|
|
363
|
+
when "mysql"
|
|
364
|
+
db_name = @prompt.ask("Database name:", default: "#{@data["application"]["name"]}_production")
|
|
365
|
+
user = @prompt.ask("Database user:", default: @data["application"]["name"])
|
|
366
|
+
password = @prompt.mask("Database password:") { |q| q.required true }
|
|
367
|
+
|
|
368
|
+
db_config["secrets"] = {
|
|
369
|
+
"MYSQL_DATABASE" => db_name,
|
|
370
|
+
"MYSQL_USER" => user,
|
|
371
|
+
"MYSQL_PASSWORD" => password
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
@data["application"]["servers"][db_server_name]["volumes"]["mysql_data"] = { "size" => 10 }
|
|
375
|
+
|
|
376
|
+
when "sqlite3"
|
|
377
|
+
path = @prompt.ask("Database path:", default: "/app/data/production.sqlite3")
|
|
378
|
+
db_config["path"] = path
|
|
379
|
+
db_config["mount"] = { "data" => "/app/data" }
|
|
380
|
+
|
|
381
|
+
@data["application"]["servers"][db_server_name]["volumes"]["sqlite_data"] = { "size" => 10 }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
@data["application"]["database"] = db_config
|
|
385
|
+
end
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
#### 2.4 Update `run` method
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
def run
|
|
392
|
+
show_welcome
|
|
393
|
+
|
|
394
|
+
step_app_name
|
|
395
|
+
step_compute_provider
|
|
396
|
+
step_servers # NEW - after compute provider
|
|
397
|
+
step_domain_provider
|
|
398
|
+
step_apps # Updated - replicas + server selection
|
|
399
|
+
step_database # Updated - server selection
|
|
400
|
+
step_env
|
|
401
|
+
|
|
402
|
+
summary_loop
|
|
403
|
+
|
|
404
|
+
show_next_steps
|
|
405
|
+
rescue TTY::Reader::InputInterrupt
|
|
406
|
+
puts "\n\nSetup cancelled."
|
|
407
|
+
exit 1
|
|
408
|
+
end
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
#### 2.5 Update `show_summary` for multi-server
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
def show_summary
|
|
415
|
+
# ... existing code ...
|
|
416
|
+
|
|
417
|
+
# Server info
|
|
418
|
+
server_info = @data["application"]["servers"].map do |name, cfg|
|
|
419
|
+
count = cfg["count"] || 1
|
|
420
|
+
type = cfg["type"] || "default"
|
|
421
|
+
master = cfg["master"] ? " (master)" : ""
|
|
422
|
+
"#{name}: #{count}x #{type}#{master}"
|
|
423
|
+
end.join(", ")
|
|
424
|
+
|
|
425
|
+
rows = [
|
|
426
|
+
["Application", @data["application"]["name"]],
|
|
427
|
+
["Provider", "#{provider_name} (#{provider_info})"],
|
|
428
|
+
["Servers", server_info], # NEW
|
|
429
|
+
["Domain", "Cloudflare #{domain_ok}"],
|
|
430
|
+
["Apps", app_list],
|
|
431
|
+
["Database", db],
|
|
432
|
+
["Env/Secrets", "#{env_count} variables"]
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
# ... rest of method ...
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## Part 3: Test Updates
|
|
442
|
+
|
|
443
|
+
### File: `test/cli/onboard/test_command.rb`
|
|
444
|
+
|
|
445
|
+
```ruby
|
|
446
|
+
def test_single_server_mode
|
|
447
|
+
prompt = TTY::Prompt::Test.new
|
|
448
|
+
|
|
449
|
+
prompt.input << "myapp\n"
|
|
450
|
+
prompt.input << "\r" # hetzner
|
|
451
|
+
prompt.input << "token\n"
|
|
452
|
+
prompt.input << "\r" # server type
|
|
453
|
+
prompt.input << "\r" # location
|
|
454
|
+
prompt.input << "\r" # Single server mode
|
|
455
|
+
prompt.input << "n\n" # no cloudflare
|
|
456
|
+
prompt.input << "web\n"
|
|
457
|
+
prompt.input << "\n" # no command
|
|
458
|
+
prompt.input << "3000\n" # port
|
|
459
|
+
prompt.input << "2\n" # replicas
|
|
460
|
+
prompt.input << "\n" # no pre-run
|
|
461
|
+
prompt.input << "n\n" # no more apps
|
|
462
|
+
prompt.input << "\e[B\e[B\e[B\r" # no database
|
|
463
|
+
prompt.input << "\e[B\e[B\r" # done with env
|
|
464
|
+
prompt.input << "\e[B\e[B\e[B\e[B\e[B\e[B\e[B\e[B\r" # Cancel
|
|
465
|
+
prompt.input << "y\n"
|
|
466
|
+
prompt.input.rewind
|
|
467
|
+
|
|
468
|
+
with_hetzner_mock do
|
|
469
|
+
cmd = Nvoi::Cli::Onboard::Command.new(prompt:)
|
|
470
|
+
cmd.run
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
output = prompt.output.string
|
|
474
|
+
assert_match(/Single server/, output)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def test_multi_server_mode
|
|
478
|
+
prompt = TTY::Prompt::Test.new
|
|
479
|
+
|
|
480
|
+
prompt.input << "myapp\n"
|
|
481
|
+
prompt.input << "\r" # hetzner
|
|
482
|
+
prompt.input << "token\n"
|
|
483
|
+
prompt.input << "\r" # server type
|
|
484
|
+
prompt.input << "\r" # location
|
|
485
|
+
prompt.input << "\e[B\r" # Multi-server mode
|
|
486
|
+
prompt.input << "\r" # master type
|
|
487
|
+
prompt.input << "\r" # worker type
|
|
488
|
+
prompt.input << "2\n" # worker count
|
|
489
|
+
prompt.input << "y\n" # dedicated db server
|
|
490
|
+
prompt.input << "\r" # db server type
|
|
491
|
+
prompt.input << "n\n" # no cloudflare
|
|
492
|
+
prompt.input << "web\n"
|
|
493
|
+
prompt.input << "\n"
|
|
494
|
+
prompt.input << "3000\n"
|
|
495
|
+
prompt.input << "2\n" # replicas
|
|
496
|
+
prompt.input << " \r" # select workers (space to select, enter to confirm)
|
|
497
|
+
prompt.input << "\n"
|
|
498
|
+
prompt.input << "n\n"
|
|
499
|
+
prompt.input << "\r" # postgres
|
|
500
|
+
prompt.input << "mydb\n"
|
|
501
|
+
prompt.input << "myuser\n"
|
|
502
|
+
prompt.input << "mypass\n"
|
|
503
|
+
prompt.input << "\e[B\e[B\r" # done with env
|
|
504
|
+
prompt.input << "\e[B\e[B\e[B\e[B\e[B\e[B\e[B\e[B\r"
|
|
505
|
+
prompt.input << "y\n"
|
|
506
|
+
prompt.input.rewind
|
|
507
|
+
|
|
508
|
+
with_hetzner_mock do
|
|
509
|
+
cmd = Nvoi::Cli::Onboard::Command.new(prompt:)
|
|
510
|
+
cmd.run
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
output = prompt.output.string
|
|
514
|
+
assert_match(/master/, output)
|
|
515
|
+
assert_match(/workers/, output)
|
|
516
|
+
assert_match(/database/, output)
|
|
517
|
+
end
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## Implementation Order
|
|
523
|
+
|
|
524
|
+
1. [ ] **Part 1: Validation** - Add volume/server validation to configuration.rb + tests
|
|
525
|
+
2. [ ] **Part 2.1: step_servers** - Add server setup step
|
|
526
|
+
3. [ ] **Part 2.2: step_apps update** - Add replicas + server selection
|
|
527
|
+
4. [ ] **Part 2.3: step_database update** - Add server selection
|
|
528
|
+
5. [ ] **Part 2.4: run method** - Wire up new step
|
|
529
|
+
6. [ ] **Part 2.5: show_summary** - Show server info
|
|
530
|
+
7. [ ] **Part 3: Tests** - Add tests for new flows
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## Notes
|
|
535
|
+
|
|
536
|
+
- Keep `@server_types` cached from compute provider setup for reuse
|
|
537
|
+
- `@server_mode` tracks :single vs :multi for conditional prompts
|
|
538
|
+
- `@dedicated_db_server` tracks if user chose dedicated db server
|
|
539
|
+
- Volume auto-creation happens in step_database based on selected server
|
|
540
|
+
- Multi-select uses `multi_select` with `min: 1`
|
|
541
|
+
- Single-server mode remains the default (recommended path)
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## Part 4: nvoi unlock command
|
|
546
|
+
|
|
547
|
+
✅ DONE - `lib/nvoi/cli/unlock/command.rb`
|
|
548
|
+
- SSHs into server and removes lock file
|
|
549
|
+
- Shows lock age before removing
|
|
550
|
+
- Uses `namer.deployment_lock_file_path`
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
nvoi (0.1.
|
|
4
|
+
nvoi (0.1.8)
|
|
5
5
|
aws-sdk-ec2 (~> 1.400)
|
|
6
6
|
faraday (~> 2.7)
|
|
7
7
|
net-scp (~> 4.0)
|
|
@@ -76,6 +76,8 @@ GEM
|
|
|
76
76
|
parser (3.3.10.0)
|
|
77
77
|
ast (~> 2.4.1)
|
|
78
78
|
racc
|
|
79
|
+
pastel (0.8.0)
|
|
80
|
+
tty-color (~> 0.5)
|
|
79
81
|
prism (1.6.0)
|
|
80
82
|
public_suffix (7.0.0)
|
|
81
83
|
racc (1.8.1)
|
|
@@ -120,17 +122,42 @@ GEM
|
|
|
120
122
|
simplecov_json_formatter (~> 0.1)
|
|
121
123
|
simplecov-html (0.13.2)
|
|
122
124
|
simplecov_json_formatter (0.1.4)
|
|
125
|
+
strings (0.2.1)
|
|
126
|
+
strings-ansi (~> 0.2)
|
|
127
|
+
unicode-display_width (>= 1.5, < 3.0)
|
|
128
|
+
unicode_utils (~> 1.4)
|
|
129
|
+
strings-ansi (0.2.0)
|
|
123
130
|
thor (1.4.0)
|
|
131
|
+
tty-box (0.7.0)
|
|
132
|
+
pastel (~> 0.8)
|
|
133
|
+
strings (~> 0.2.0)
|
|
134
|
+
tty-cursor (~> 0.7)
|
|
135
|
+
tty-color (0.6.0)
|
|
136
|
+
tty-cursor (0.7.1)
|
|
137
|
+
tty-prompt (0.23.1)
|
|
138
|
+
pastel (~> 0.8)
|
|
139
|
+
tty-reader (~> 0.8)
|
|
140
|
+
tty-reader (0.9.0)
|
|
141
|
+
tty-cursor (~> 0.7)
|
|
142
|
+
tty-screen (~> 0.8)
|
|
143
|
+
wisper (~> 2.0)
|
|
144
|
+
tty-screen (0.8.2)
|
|
145
|
+
tty-spinner (0.9.3)
|
|
146
|
+
tty-cursor (~> 0.7)
|
|
147
|
+
tty-table (0.12.0)
|
|
148
|
+
pastel (~> 0.8)
|
|
149
|
+
strings (~> 0.2.0)
|
|
150
|
+
tty-screen (~> 0.8)
|
|
124
151
|
tzinfo (2.0.6)
|
|
125
152
|
concurrent-ruby (~> 1.0)
|
|
126
|
-
unicode-display_width (
|
|
127
|
-
|
|
128
|
-
unicode-emoji (4.1.0)
|
|
153
|
+
unicode-display_width (2.6.0)
|
|
154
|
+
unicode_utils (1.4.0)
|
|
129
155
|
uri (1.1.1)
|
|
130
156
|
webmock (3.26.1)
|
|
131
157
|
addressable (>= 2.8.0)
|
|
132
158
|
crack (>= 0.3.2)
|
|
133
159
|
hashdiff (>= 0.4.0, < 2.0.0)
|
|
160
|
+
wisper (2.0.1)
|
|
134
161
|
zeitwerk (2.7.3)
|
|
135
162
|
|
|
136
163
|
PLATFORMS
|
|
@@ -155,6 +182,10 @@ DEPENDENCIES
|
|
|
155
182
|
rubocop (~> 1.57)
|
|
156
183
|
rubocop-rails-omakase
|
|
157
184
|
simplecov
|
|
185
|
+
tty-box (~> 0.7.0)
|
|
186
|
+
tty-prompt (~> 0.23.1)
|
|
187
|
+
tty-spinner (~> 0.9.3)
|
|
188
|
+
tty-table (~> 0.12.0)
|
|
158
189
|
webmock (~> 3.19)
|
|
159
190
|
|
|
160
191
|
BUNDLED WITH
|
data/Rakefile
CHANGED
data/ingest
ADDED
|
File without changes
|