nvoi 0.2.0 → 0.2.1

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.
@@ -0,0 +1,433 @@
1
+ # NVOI Gem - Rails Optimization TODO
2
+
3
+ Changes needed in the `nvoi` gem to support **optional** dashboard integration.
4
+
5
+ ---
6
+
7
+ ## Modes
8
+
9
+ ### Standalone Mode (default)
10
+
11
+ No `callback_url` in config. Gem works exactly as today:
12
+
13
+ ```
14
+ nvoi deploy
15
+
16
+ logs to stdout
17
+
18
+ done
19
+ ```
20
+
21
+ ### Dashboard Mode (optional)
22
+
23
+ When `callback_url` is set in config:
24
+
25
+ ```
26
+ nvoi deploy
27
+
28
+ logs to stdout AND POSTs to callback_url
29
+
30
+ Rails dashboard receives logs in real-time
31
+ ```
32
+
33
+ **The gem remains 100% standalone.** Dashboard integration is opt-in via config.
34
+
35
+ ---
36
+
37
+ ## Architecture (Dashboard Mode)
38
+
39
+ ```
40
+ CI (GitHub Actions, GitLab CI, etc.)
41
+
42
+ gem install nvoi && nvoi deploy
43
+
44
+ gem decrypts deploy.enc with DEPLOY_KEY
45
+
46
+ gem POSTs logs to callback_url (signed with DEPLOY_KEY)
47
+
48
+ Rails receives, stores, broadcasts via Turbo
49
+ ```
50
+
51
+ **One key, two purposes (when dashboard enabled):**
52
+
53
+ 1. Decrypt `deploy.enc`
54
+ 2. HMAC-sign API callbacks to Rails
55
+
56
+ ---
57
+
58
+ ## 1. Callback Configuration
59
+
60
+ Add optional callback URL to deploy config schema:
61
+
62
+ ```ruby
63
+ # lib/nvoi/configuration/deploy.rb
64
+ class Deploy
65
+ # Existing fields...
66
+
67
+ # New: callback URL for log streaming
68
+ def callback_url
69
+ @data["callback_url"]
70
+ end
71
+
72
+ def callback_enabled?
73
+ callback_url.present?
74
+ end
75
+ end
76
+ ```
77
+
78
+ Config example:
79
+
80
+ ```yaml
81
+ # In decrypted deploy config
82
+ callback_url: "https://myapp.com/api/deploys"
83
+ ```
84
+
85
+ ---
86
+
87
+ ## 2. HTTP Logger Adapter
88
+
89
+ New adapter that POSTs logs to Rails:
90
+
91
+ ```ruby
92
+ # lib/nvoi/adapters/logger/http.rb
93
+ module Nvoi
94
+ module Adapters
95
+ module Logger
96
+ class Http < Base
97
+ def initialize(url:, key:, deploy_id:, fallback: nil)
98
+ @url = url
99
+ @key = key
100
+ @deploy_id = deploy_id
101
+ @fallback = fallback || Stdout.new
102
+ @buffer = []
103
+ @flush_thread = start_flush_thread
104
+ end
105
+
106
+ def info(message, *args)
107
+ log(:info, format_message(message, args))
108
+ end
109
+
110
+ def success(message, *args)
111
+ log(:success, format_message(message, args))
112
+ end
113
+
114
+ def error(message, *args)
115
+ log(:error, format_message(message, args))
116
+ end
117
+
118
+ def warning(message, *args)
119
+ log(:warning, format_message(message, args))
120
+ end
121
+
122
+ def step(message, *args)
123
+ log(:step, format_message(message, args))
124
+ end
125
+
126
+ def ok(message, *args)
127
+ log(:ok, format_message(message, args))
128
+ end
129
+
130
+ def flush
131
+ return if @buffer.empty?
132
+
133
+ logs = @buffer.dup
134
+ @buffer.clear
135
+
136
+ send_logs(logs)
137
+ end
138
+
139
+ def close
140
+ @flush_thread&.kill
141
+ flush
142
+ send_status(:completed)
143
+ end
144
+
145
+ def fail!(error_message)
146
+ flush
147
+ send_status(:failed, error: error_message)
148
+ end
149
+
150
+ private
151
+
152
+ def log(level, message)
153
+ @fallback&.public_send(level, message)
154
+
155
+ @buffer << {
156
+ level: level,
157
+ message: message,
158
+ logged_at: Time.now.iso8601(3)
159
+ }
160
+ end
161
+
162
+ def start_flush_thread
163
+ Thread.new do
164
+ loop do
165
+ sleep 1
166
+ flush
167
+ end
168
+ end
169
+ end
170
+
171
+ def send_logs(logs)
172
+ payload = { logs: logs }
173
+ post("#{@url}/#{@deploy_id}/logs", payload)
174
+ rescue => e
175
+ @fallback&.warning("Callback failed: #{e.message}")
176
+ end
177
+
178
+ def send_status(status, error: nil)
179
+ payload = { status: status, error: error }
180
+ post("#{@url}/#{@deploy_id}/status", payload)
181
+ rescue => e
182
+ @fallback&.warning("Status callback failed: #{e.message}")
183
+ end
184
+
185
+ def post(url, payload)
186
+ body = payload.to_json
187
+ signature = sign(body)
188
+
189
+ uri = URI(url)
190
+ http = Net::HTTP.new(uri.host, uri.port)
191
+ http.use_ssl = uri.scheme == "https"
192
+
193
+ request = Net::HTTP::Post.new(uri.path)
194
+ request["Content-Type"] = "application/json"
195
+ request["X-Nvoi-Signature"] = signature
196
+ request["X-Nvoi-Deploy-Id"] = @deploy_id
197
+ request.body = body
198
+
199
+ response = http.request(request)
200
+
201
+ unless response.is_a?(Net::HTTPSuccess)
202
+ raise "HTTP #{response.code}: #{response.body}"
203
+ end
204
+ end
205
+
206
+ def sign(body)
207
+ "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", @key, body)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ ```
214
+
215
+ ---
216
+
217
+ ## 3. CLI Integration
218
+
219
+ Update deploy command to use HTTP logger when callback configured:
220
+
221
+ ```ruby
222
+ # lib/nvoi/cli/deploy/command.rb
223
+ def run
224
+ @log = build_logger
225
+
226
+ # ... existing deploy logic ...
227
+
228
+ @log.close # flush remaining logs + send completed status
229
+ rescue => e
230
+ @log.fail!(e.message)
231
+ raise
232
+ end
233
+
234
+ private
235
+
236
+ def build_logger
237
+ if @config.callback_enabled?
238
+ deploy_id = ENV["NVOI_DEPLOY_ID"] || ENV["GITHUB_RUN_ID"] || SecureRandom.uuid
239
+
240
+ Adapters::Logger::Http.new(
241
+ url: @config.callback_url,
242
+ key: load_deploy_key,
243
+ deploy_id: deploy_id,
244
+ fallback: Utils::Logger.new # still print to stdout
245
+ )
246
+ else
247
+ Utils::Logger.new
248
+ end
249
+ end
250
+
251
+ def load_deploy_key
252
+ key_path = resolve_key_path
253
+ File.read(key_path).strip
254
+ end
255
+ ```
256
+
257
+ ---
258
+
259
+ ## 4. Status Callbacks
260
+
261
+ Send deploy lifecycle events:
262
+
263
+ ```ruby
264
+ # Callback payloads:
265
+
266
+ # POST /api/deploys/:id/logs
267
+ {
268
+ "logs": [
269
+ { "level": "info", "message": "Starting deploy...", "logged_at": "2024-01-15T10:30:00.123Z" },
270
+ { "level": "success", "message": "Server provisioned", "logged_at": "2024-01-15T10:30:05.456Z" }
271
+ ]
272
+ }
273
+
274
+ # POST /api/deploys/:id/status
275
+ {
276
+ "status": "started",
277
+ "git_sha": "abc123",
278
+ "git_ref": "main",
279
+ "ci_provider": "github_actions",
280
+ "ci_run_url": "https://github.com/user/repo/actions/runs/12345"
281
+ }
282
+
283
+ # POST /api/deploys/:id/status (on complete)
284
+ {
285
+ "status": "completed",
286
+ "tunnels": [
287
+ { "service_name": "web", "hostname": "www.example.com" }
288
+ ],
289
+ "duration_seconds": 120
290
+ }
291
+
292
+ # POST /api/deploys/:id/status (on failure)
293
+ {
294
+ "status": "failed",
295
+ "error": "SSH connection failed"
296
+ }
297
+ ```
298
+
299
+ ---
300
+
301
+ ## 5. Delete Command Callbacks
302
+
303
+ Same pattern for delete:
304
+
305
+ ```ruby
306
+ # lib/nvoi/cli/delete/command.rb
307
+ def run
308
+ @log = build_logger
309
+ send_status(:started)
310
+
311
+ # ... existing delete logic ...
312
+
313
+ send_status(:completed)
314
+ @log.close
315
+ rescue => e
316
+ send_status(:failed, error: e.message)
317
+ @log.close
318
+ raise
319
+ end
320
+ ```
321
+
322
+ ---
323
+
324
+ ## 6. Environment Variables
325
+
326
+ CI provides context:
327
+
328
+ | Variable | Source | Purpose |
329
+ | ------------------- | --------------------------- | ------------------------ |
330
+ | `NVOI_DEPLOY_ID` | Set by CI or auto-generated | Unique deploy identifier |
331
+ | `GITHUB_RUN_ID` | GitHub Actions | Fallback deploy ID |
332
+ | `GITHUB_SHA` | GitHub Actions | Git commit SHA |
333
+ | `GITHUB_REF_NAME` | GitHub Actions | Branch name |
334
+ | `GITHUB_SERVER_URL` | GitHub Actions | Build CI run URL |
335
+ | `GITHUB_REPOSITORY` | GitHub Actions | repo owner/name |
336
+ | `GITHUB_RUN_ID` | GitHub Actions | Run ID for URL |
337
+
338
+ ---
339
+
340
+ ## 7. File Structure
341
+
342
+ ```
343
+ lib/nvoi/
344
+ adapters/
345
+ logger/
346
+ base.rb
347
+ http.rb # NEW
348
+ stdout.rb # renamed from utils/logger.rb
349
+ null.rb
350
+ cli/
351
+ deploy/
352
+ command.rb # MODIFIED - use callback logger
353
+ delete/
354
+ command.rb # MODIFIED - use callback logger
355
+ ```
356
+
357
+ ---
358
+
359
+ ## 8. Onboard Wizard Enhancement
360
+
361
+ Add callback URL step:
362
+
363
+ ```ruby
364
+ # lib/nvoi/cli/onboard/steps/callback_step.rb
365
+ class CallbackStep
366
+ def run(state)
367
+ use_callback = prompt.yes?("Stream deploy logs to a dashboard?")
368
+ return state unless use_callback
369
+
370
+ url = prompt.ask("Callback URL:", required: true)
371
+
372
+ state.merge(callback_url: url)
373
+ end
374
+ end
375
+ ```
376
+
377
+ ---
378
+
379
+ ## 9. Testing
380
+
381
+ ```ruby
382
+ # test/nvoi/adapters/logger/http_test.rb
383
+ class HttpLoggerTest < Minitest::Test
384
+ def setup
385
+ @url = "https://example.com/api/deploys"
386
+ @key = "test-key-123"
387
+ @deploy_id = "run-456"
388
+ end
389
+
390
+ def test_signs_requests_with_hmac
391
+ stub_request(:post, "#{@url}/#{@deploy_id}/logs")
392
+ .with { |req|
393
+ signature = req.headers["X-Nvoi-Signature"]
394
+ expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", @key, req.body)
395
+ signature == expected
396
+ }
397
+ .to_return(status: 200)
398
+
399
+ logger = Nvoi::Adapters::Logger::Http.new(
400
+ url: @url, key: @key, deploy_id: @deploy_id
401
+ )
402
+ logger.info("test")
403
+ logger.flush
404
+ end
405
+
406
+ def test_buffers_and_flushes
407
+ stub = stub_request(:post, "#{@url}/#{@deploy_id}/logs")
408
+ .to_return(status: 200)
409
+
410
+ logger = Nvoi::Adapters::Logger::Http.new(
411
+ url: @url, key: @key, deploy_id: @deploy_id
412
+ )
413
+
414
+ logger.info("one")
415
+ logger.info("two")
416
+ logger.flush
417
+
418
+ assert_requested(stub, times: 1)
419
+ end
420
+ end
421
+ ```
422
+
423
+ ---
424
+
425
+ ## 10. Migration Path
426
+
427
+ 1. Add HTTP logger adapter (non-breaking)
428
+ 2. Add callback_url config option (non-breaking)
429
+ 3. Update CLI to use callback logger when configured (non-breaking)
430
+ 4. Update onboard wizard (non-breaking)
431
+ 5. Release as 0.3.0
432
+
433
+ No breaking changes for existing CLI users.
@@ -127,6 +127,7 @@ components:
127
127
  description: Hetzner Cloud provider configuration
128
128
  required:
129
129
  - api_token
130
+ - architecture
130
131
  properties:
131
132
  api_token:
132
133
  type: string
@@ -141,6 +142,11 @@ components:
141
142
  description: Default datacenter location
142
143
  enum: [fsn1, nbg1, hel1, ash, hil]
143
144
  example: fsn1
145
+ architecture:
146
+ type: string
147
+ description: CPU architecture for Docker builds
148
+ enum: [x86, arm64]
149
+ example: x86
144
150
 
145
151
  AWSConfig:
146
152
  type: object
@@ -149,6 +155,7 @@ components:
149
155
  - access_key_id
150
156
  - secret_access_key
151
157
  - region
158
+ - architecture
152
159
  properties:
153
160
  access_key_id:
154
161
  type: string
@@ -164,6 +171,11 @@ components:
164
171
  type: string
165
172
  description: Default EC2 instance type
166
173
  example: t3.medium
174
+ architecture:
175
+ type: string
176
+ description: CPU architecture for Docker builds
177
+ enum: [x86, arm64]
178
+ example: x86
167
179
 
168
180
  ServerConfig:
169
181
  type: object
@@ -19,6 +19,7 @@ application:
19
19
  api_token: $HETZNER_API_TOKEN
20
20
  server_type: cx22
21
21
  server_location: fsn1
22
+ architecture: x86
22
23
 
23
24
  servers:
24
25
  master:
@@ -13,6 +13,7 @@ application:
13
13
  api_token: $HETZNER_API_TOKEN
14
14
  server_type: cx22 # Default for all servers
15
15
  server_location: nbg1
16
+ architecture: x86
16
17
 
17
18
  # Multi-instance server configuration: 3 servers total
18
19
  # 1 master (K3s control plane) + 2 workers (app + database)
@@ -13,6 +13,7 @@ application:
13
13
  api_token: $HETZNER_API_TOKEN
14
14
  server_type: cx22 # Default for all servers
15
15
  server_location: nbg1
16
+ architecture: x86
16
17
 
17
18
  # Multi-instance server configuration: 4 servers total
18
19
  servers:
@@ -13,6 +13,7 @@ application:
13
13
  api_token: $HETZNER_API_TOKEN
14
14
  server_type: cx22
15
15
  server_location: fsn1
16
+ architecture: x86
16
17
 
17
18
  # Server configuration (single server, master: true is implicit)
18
19
  servers:
@@ -12,6 +12,7 @@ application:
12
12
  api_token: $HETZNER_API_TOKEN
13
13
  server_type: cx22
14
14
  server_location: fsn1
15
+ architecture: x86
15
16
 
16
17
  servers:
17
18
  master:
@@ -206,6 +206,7 @@ module Nvoi
206
206
  return "application.compute_provider.hetzner.api_token is required" if h["api_token"].blank?
207
207
  return "application.compute_provider.hetzner.server_type is required" if h["server_type"].blank?
208
208
  return "application.compute_provider.hetzner.server_location is required" if h["server_location"].blank?
209
+ return "application.compute_provider.hetzner.architecture is required" if h["architecture"].blank?
209
210
  end
210
211
 
211
212
  if (a = compute_provider&.dig("aws"))
@@ -213,12 +214,14 @@ module Nvoi
213
214
  return "application.compute_provider.aws.secret_access_key is required" if a["secret_access_key"].blank?
214
215
  return "application.compute_provider.aws.region is required" if a["region"].blank?
215
216
  return "application.compute_provider.aws.instance_type is required" if a["instance_type"].blank?
217
+ return "application.compute_provider.aws.architecture is required" if a["architecture"].blank?
216
218
  end
217
219
 
218
220
  if (s = compute_provider&.dig("scaleway"))
219
221
  return "application.compute_provider.scaleway.secret_key is required" if s["secret_key"].blank?
220
222
  return "application.compute_provider.scaleway.project_id is required" if s["project_id"].blank?
221
223
  return "application.compute_provider.scaleway.server_type is required" if s["server_type"].blank?
224
+ return "application.compute_provider.scaleway.architecture is required" if s["architecture"].blank?
222
225
  end
223
226
 
224
227
  # Servers (if any services defined)
@@ -317,6 +320,7 @@ module Nvoi
317
320
  api_token: YOUR_HETZNER_API_TOKEN
318
321
  server_type: cx22
319
322
  server_location: fsn1
323
+ architecture: x86
320
324
 
321
325
  servers:
322
326
  master:
@@ -28,11 +28,12 @@ module Nvoi
28
28
  def build_image(working_dir, tag)
29
29
  cache_from = @config.namer.latest_image_tag
30
30
  cache_args = "--cache-from #{cache_from}"
31
+ platform = @config.docker_platform
31
32
 
32
33
  build_cmd = [
33
34
  "cd #{working_dir} &&",
34
35
  "DOCKER_BUILDKIT=1 docker build",
35
- "--platform linux/amd64",
36
+ "--platform #{platform}",
36
37
  cache_args,
37
38
  "--build-arg BUILDKIT_INLINE_CACHE=1",
38
39
  "-t #{tag} ."
@@ -345,18 +345,21 @@ module Nvoi
345
345
  end
346
346
 
347
347
  def check_public_url(url)
348
- curl_cmd = "curl -si -m 10 '#{url}' 2>/dev/null"
348
+ # -L follows redirects, -s silent, -i include headers, -m timeout
349
+ curl_cmd = "curl -Lsi -m 10 '#{url}' 2>/dev/null"
349
350
  output = @ssh.execute(curl_cmd).strip
350
351
 
351
- http_code = output.lines.first&.match(/HTTP\/[\d.]+ (\d+)/)&.captures&.first || "000"
352
+ # With -L, last HTTP line is the final response after redirects
353
+ http_lines = output.lines.select { |l| l.match?(/^HTTP\/[\d.]+\s+\d+/) }
354
+ http_code = http_lines.last&.match(/HTTP\/[\d.]+ (\d+)/)&.captures&.first || "000"
352
355
  has_error_header = output.lines.any? { |line| line.downcase.start_with?("x-nvoi-error:") }
353
356
 
354
- if http_code == "200" && !has_error_header
357
+ if http_code.start_with?("2") && !has_error_header
355
358
  { success: true, http_code:, message: "OK" }
356
359
  elsif has_error_header
357
360
  { success: false, http_code:, message: "Error backend responding (X-Nvoi-Error header present) - app is down" }
358
361
  else
359
- { success: false, http_code:, message: "HTTP #{http_code} (expected: 200)" }
362
+ { success: false, http_code:, message: "HTTP #{http_code} (expected: 2xx)" }
360
363
  end
361
364
  end
362
365