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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/_TODO-rails-example.md +816 -0
- data/_TODO-rails-optimization.md +433 -0
- data/doc/config-schema.yaml +12 -0
- data/examples/apex-wildcard/deploy.yml +1 -0
- data/examples/golang-postgres-multi/deploy.yml +1 -0
- data/examples/postgres-multi/deploy.yml +1 -0
- data/examples/postgres-single/deploy.yml +1 -0
- data/examples/rails-single/deploy.yml +1 -0
- data/lib/nvoi/cli/credentials/edit/command.rb +4 -0
- data/lib/nvoi/cli/deploy/steps/build_image.rb +2 -1
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +7 -4
- data/lib/nvoi/cli/onboard/steps/compute.rb +61 -14
- data/lib/nvoi/cli.rb +2 -1
- data/lib/nvoi/configuration/builder.rb +6 -3
- data/lib/nvoi/configuration/providers.rb +6 -3
- data/lib/nvoi/configuration/root.rb +18 -0
- data/lib/nvoi/configuration/service.rb +0 -11
- data/lib/nvoi/configuration/ssh_key.rb +16 -0
- data/lib/nvoi/external/cloud/aws.rb +14 -4
- data/lib/nvoi/external/cloud/hetzner.rb +33 -18
- data/lib/nvoi/external/cloud/scaleway.rb +3 -1
- data/lib/nvoi/external/dns/cloudflare.rb +5 -5
- data/lib/nvoi/utils/namer.rb +6 -1
- data/lib/nvoi/version.rb +1 -1
- metadata +5 -3
- data/Makefile +0 -26
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
# NVOI Rails Dashboard
|
|
2
|
+
|
|
3
|
+
A Rails app that serves as a dashboard for nvoi deploys running in CI.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
11
|
+
│ User's Repo │
|
|
12
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
|
13
|
+
│ │ deploy.enc │ │ deploy.key │ │ .github/workflows/ │ │
|
|
14
|
+
│ │ (committed) │ │ (GH secret) │ │ deploy.yml │ │
|
|
15
|
+
│ └──────────────┘ └──────────────┘ └────────────────────────┘ │
|
|
16
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
17
|
+
│
|
|
18
|
+
│ git push
|
|
19
|
+
▼
|
|
20
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
21
|
+
│ CI (GitHub Actions, etc.) │
|
|
22
|
+
│ │
|
|
23
|
+
│ gem install nvoi │
|
|
24
|
+
│ echo "$DEPLOY_KEY" > deploy.key │
|
|
25
|
+
│ nvoi deploy [--branch $BRANCH] │
|
|
26
|
+
│ │
|
|
27
|
+
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
28
|
+
│ │ nvoi gem: │ │
|
|
29
|
+
│ │ 1. Decrypts deploy.enc with deploy.key │ │
|
|
30
|
+
│ │ 2. Provisions infra / deploys app │ │
|
|
31
|
+
│ │ 3. POSTs logs to callback_url (signed with deploy.key) │ │
|
|
32
|
+
│ └────────────────────────────────────────────────────────────┘ │
|
|
33
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
34
|
+
│
|
|
35
|
+
│ HTTPS callbacks
|
|
36
|
+
▼
|
|
37
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
38
|
+
│ Rails Dashboard │
|
|
39
|
+
│ │
|
|
40
|
+
│ • Receives deploy logs via API │
|
|
41
|
+
│ • Stores deploy history │
|
|
42
|
+
│ • Real-time log streaming via Turbo │
|
|
43
|
+
│ • Config wizard (generates deploy.enc + CI files) │
|
|
44
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Key insight:** CI runs the deploy, Rails just watches.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 1. App Setup
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
rails new nvoi-dashboard \
|
|
55
|
+
--database=postgresql \
|
|
56
|
+
--css=tailwind \
|
|
57
|
+
--skip-jbuilder \
|
|
58
|
+
--skip-action-mailbox \
|
|
59
|
+
--skip-action-text
|
|
60
|
+
|
|
61
|
+
cd nvoi-dashboard
|
|
62
|
+
bundle add nvoi
|
|
63
|
+
bundle add omniauth-github
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 2. Database Schema
|
|
69
|
+
|
|
70
|
+
Simplified - no infra state tracking (CI handles that).
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# db/migrate/001_create_users.rb
|
|
74
|
+
class CreateUsers < ActiveRecord::Migration[8.0]
|
|
75
|
+
def change
|
|
76
|
+
create_table :users do |t|
|
|
77
|
+
t.bigint :github_id, null: false, index: { unique: true }
|
|
78
|
+
t.string :github_username, null: false
|
|
79
|
+
t.text :github_access_token # encrypted
|
|
80
|
+
t.text :avatar_url
|
|
81
|
+
t.timestamps
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# db/migrate/002_create_projects.rb
|
|
87
|
+
class CreateProjects < ActiveRecord::Migration[8.0]
|
|
88
|
+
def change
|
|
89
|
+
create_table :projects do |t|
|
|
90
|
+
t.references :user, null: false, foreign_key: { on_delete: :cascade }
|
|
91
|
+
t.string :name, null: false
|
|
92
|
+
t.string :github_repo # optional: user/repo
|
|
93
|
+
t.binary :encrypted_config
|
|
94
|
+
t.binary :encrypted_key
|
|
95
|
+
t.string :callback_token, null: false, index: { unique: true }
|
|
96
|
+
t.timestamps
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# db/migrate/003_create_deploys.rb
|
|
102
|
+
class CreateDeploys < ActiveRecord::Migration[8.0]
|
|
103
|
+
def change
|
|
104
|
+
create_table :deploys do |t|
|
|
105
|
+
t.references :project, null: false, foreign_key: { on_delete: :cascade }
|
|
106
|
+
t.string :external_id, null: false # CI run ID
|
|
107
|
+
t.string :branch
|
|
108
|
+
t.string :git_sha, limit: 40
|
|
109
|
+
t.string :git_ref
|
|
110
|
+
t.string :status, default: "running"
|
|
111
|
+
t.string :ci_provider
|
|
112
|
+
t.text :ci_run_url
|
|
113
|
+
t.text :error_message
|
|
114
|
+
t.jsonb :result_data # tunnels, duration, etc.
|
|
115
|
+
t.timestamp :started_at
|
|
116
|
+
t.timestamp :finished_at
|
|
117
|
+
t.timestamps
|
|
118
|
+
t.index [:project_id, :external_id], unique: true
|
|
119
|
+
t.index [:project_id, :created_at]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# db/migrate/004_create_deploy_logs.rb
|
|
125
|
+
class CreateDeployLogs < ActiveRecord::Migration[8.0]
|
|
126
|
+
def change
|
|
127
|
+
create_table :deploy_logs do |t|
|
|
128
|
+
t.references :deploy, null: false, foreign_key: { on_delete: :cascade }
|
|
129
|
+
t.string :level, null: false
|
|
130
|
+
t.text :message, null: false
|
|
131
|
+
t.timestamp :logged_at, null: false
|
|
132
|
+
t.index [:deploy_id, :logged_at]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 3. Models
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# app/models/user.rb
|
|
144
|
+
class User < ApplicationRecord
|
|
145
|
+
encrypts :github_access_token
|
|
146
|
+
|
|
147
|
+
has_many :projects, dependent: :destroy
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# app/models/project.rb
|
|
151
|
+
class Project < ApplicationRecord
|
|
152
|
+
belongs_to :user
|
|
153
|
+
has_many :deploys, dependent: :destroy
|
|
154
|
+
|
|
155
|
+
encrypts :encrypted_config
|
|
156
|
+
encrypts :encrypted_key
|
|
157
|
+
|
|
158
|
+
before_create :generate_callback_token
|
|
159
|
+
|
|
160
|
+
def callback_url
|
|
161
|
+
Rails.application.routes.url_helpers.api_project_deploys_url(
|
|
162
|
+
callback_token,
|
|
163
|
+
host: Rails.application.config.app_host
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def deploy_key
|
|
168
|
+
encrypted_key
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def decrypted_config
|
|
172
|
+
return {} unless encrypted_config && encrypted_key
|
|
173
|
+
YAML.safe_load(
|
|
174
|
+
Nvoi::Utils::Crypto.decrypt(encrypted_config, encrypted_key)
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def save_config(data)
|
|
179
|
+
key = encrypted_key || SecureRandom.hex(32)
|
|
180
|
+
self.encrypted_key = key
|
|
181
|
+
self.encrypted_config = Nvoi::Utils::Crypto.encrypt(data.to_yaml, key)
|
|
182
|
+
save!
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def generate_callback_token
|
|
188
|
+
self.callback_token ||= SecureRandom.urlsafe_base64(32)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# app/models/deploy.rb
|
|
193
|
+
class Deploy < ApplicationRecord
|
|
194
|
+
belongs_to :project
|
|
195
|
+
has_many :logs, class_name: "DeployLog", dependent: :delete_all
|
|
196
|
+
|
|
197
|
+
enum :status, {
|
|
198
|
+
running: "running",
|
|
199
|
+
completed: "completed",
|
|
200
|
+
failed: "failed"
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
scope :recent, -> { order(created_at: :desc).limit(50) }
|
|
204
|
+
|
|
205
|
+
def duration
|
|
206
|
+
return nil unless started_at && finished_at
|
|
207
|
+
finished_at - started_at
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def tunnels
|
|
211
|
+
result_data&.dig("tunnels") || []
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# app/models/deploy_log.rb
|
|
216
|
+
class DeployLog < ApplicationRecord
|
|
217
|
+
belongs_to :deploy
|
|
218
|
+
|
|
219
|
+
after_create_commit :broadcast
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def broadcast
|
|
224
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
225
|
+
deploy,
|
|
226
|
+
target: "deploy_logs",
|
|
227
|
+
partial: "deploys/log_line",
|
|
228
|
+
locals: { log: self }
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 4. API Controller (receives callbacks from gem)
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
# app/controllers/api/deploys_controller.rb
|
|
240
|
+
module Api
|
|
241
|
+
class DeploysController < ApplicationController
|
|
242
|
+
skip_before_action :verify_authenticity_token
|
|
243
|
+
skip_before_action :authenticate_user!
|
|
244
|
+
before_action :set_project
|
|
245
|
+
before_action :verify_signature
|
|
246
|
+
|
|
247
|
+
# POST /api/projects/:callback_token/deploys/:external_id/logs
|
|
248
|
+
def logs
|
|
249
|
+
deploy = find_or_create_deploy
|
|
250
|
+
|
|
251
|
+
params[:logs].each do |log_data|
|
|
252
|
+
deploy.logs.create!(
|
|
253
|
+
level: log_data[:level],
|
|
254
|
+
message: log_data[:message],
|
|
255
|
+
logged_at: log_data[:logged_at]
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
head :ok
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# POST /api/projects/:callback_token/deploys/:external_id/status
|
|
263
|
+
def status
|
|
264
|
+
deploy = find_or_create_deploy
|
|
265
|
+
|
|
266
|
+
case params[:status]
|
|
267
|
+
when "started"
|
|
268
|
+
deploy.update!(
|
|
269
|
+
status: :running,
|
|
270
|
+
started_at: Time.current,
|
|
271
|
+
git_sha: params[:git_sha],
|
|
272
|
+
git_ref: params[:git_ref],
|
|
273
|
+
branch: params[:git_ref],
|
|
274
|
+
ci_provider: params[:ci_provider],
|
|
275
|
+
ci_run_url: params[:ci_run_url]
|
|
276
|
+
)
|
|
277
|
+
when "completed"
|
|
278
|
+
deploy.update!(
|
|
279
|
+
status: :completed,
|
|
280
|
+
finished_at: Time.current,
|
|
281
|
+
result_data: {
|
|
282
|
+
tunnels: params[:tunnels],
|
|
283
|
+
duration_seconds: params[:duration_seconds]
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
broadcast_status(deploy)
|
|
287
|
+
when "failed"
|
|
288
|
+
deploy.update!(
|
|
289
|
+
status: :failed,
|
|
290
|
+
finished_at: Time.current,
|
|
291
|
+
error_message: params[:error]
|
|
292
|
+
)
|
|
293
|
+
broadcast_status(deploy)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
head :ok
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
private
|
|
300
|
+
|
|
301
|
+
def set_project
|
|
302
|
+
@project = Project.find_by!(callback_token: params[:callback_token])
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def verify_signature
|
|
306
|
+
signature = request.headers["X-Nvoi-Signature"]
|
|
307
|
+
expected = "sha256=" + OpenSSL::HMAC.hexdigest(
|
|
308
|
+
"SHA256",
|
|
309
|
+
@project.deploy_key,
|
|
310
|
+
request.raw_post
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
head :unauthorized unless Rack::Utils.secure_compare(signature, expected)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def find_or_create_deploy
|
|
317
|
+
@project.deploys.find_or_create_by!(external_id: params[:external_id])
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def broadcast_status(deploy)
|
|
321
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
322
|
+
deploy,
|
|
323
|
+
target: "deploy_status",
|
|
324
|
+
partial: "deploys/status",
|
|
325
|
+
locals: { deploy: deploy }
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## 5. Dashboard Controllers
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
# app/controllers/application_controller.rb
|
|
338
|
+
class ApplicationController < ActionController::Base
|
|
339
|
+
before_action :authenticate_user!
|
|
340
|
+
|
|
341
|
+
helper_method :current_user
|
|
342
|
+
|
|
343
|
+
private
|
|
344
|
+
|
|
345
|
+
def current_user
|
|
346
|
+
@current_user ||= User.find_by(id: session[:user_id])
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def authenticate_user!
|
|
350
|
+
redirect_to login_path unless current_user
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# app/controllers/projects_controller.rb
|
|
355
|
+
class ProjectsController < ApplicationController
|
|
356
|
+
before_action :set_project, only: [:show, :edit, :update, :destroy, :export]
|
|
357
|
+
|
|
358
|
+
def index
|
|
359
|
+
@projects = current_user.projects.order(updated_at: :desc)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def show
|
|
363
|
+
@deploys = @project.deploys.recent
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def new
|
|
367
|
+
@project = current_user.projects.build
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def create
|
|
371
|
+
@project = current_user.projects.build(project_params)
|
|
372
|
+
|
|
373
|
+
if @project.save
|
|
374
|
+
redirect_to project_path(@project), notice: "Project created"
|
|
375
|
+
else
|
|
376
|
+
render :new, status: :unprocessable_entity
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def edit
|
|
381
|
+
@config = @project.decrypted_config
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def update
|
|
385
|
+
@project.save_config(config_params.to_h)
|
|
386
|
+
redirect_to project_path(@project), notice: "Config saved"
|
|
387
|
+
rescue => e
|
|
388
|
+
flash.now[:alert] = e.message
|
|
389
|
+
render :edit, status: :unprocessable_entity
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Export deploy.enc, deploy.key, and CI workflow files
|
|
393
|
+
def export
|
|
394
|
+
send_data generate_export_zip,
|
|
395
|
+
filename: "#{@project.name}-nvoi-config.zip",
|
|
396
|
+
type: "application/zip"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private
|
|
400
|
+
|
|
401
|
+
def set_project
|
|
402
|
+
@project = current_user.projects.find(params[:id])
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def project_params
|
|
406
|
+
params.require(:project).permit(:name, :github_repo)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def config_params
|
|
410
|
+
params.require(:config).permit!
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def generate_export_zip
|
|
414
|
+
require "zip"
|
|
415
|
+
|
|
416
|
+
Zip::OutputStream.write_buffer do |zip|
|
|
417
|
+
# deploy.enc
|
|
418
|
+
zip.put_next_entry("deploy.enc")
|
|
419
|
+
zip.write(@project.encrypted_config)
|
|
420
|
+
|
|
421
|
+
# deploy.key (user adds to CI secrets)
|
|
422
|
+
zip.put_next_entry("deploy.key.txt")
|
|
423
|
+
zip.write("Add this as DEPLOY_KEY secret in your CI:\n\n")
|
|
424
|
+
zip.write(@project.deploy_key)
|
|
425
|
+
|
|
426
|
+
# GitHub Actions workflows
|
|
427
|
+
zip.put_next_entry(".github/workflows/deploy-prod.yml")
|
|
428
|
+
zip.write(generate_prod_workflow)
|
|
429
|
+
|
|
430
|
+
zip.put_next_entry(".github/workflows/deploy-branch.yml")
|
|
431
|
+
zip.write(generate_branch_workflow)
|
|
432
|
+
|
|
433
|
+
# README
|
|
434
|
+
zip.put_next_entry("NVOI_SETUP.md")
|
|
435
|
+
zip.write(generate_readme)
|
|
436
|
+
end.string
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def generate_prod_workflow
|
|
440
|
+
<<~YAML
|
|
441
|
+
name: Deploy Production
|
|
442
|
+
|
|
443
|
+
on:
|
|
444
|
+
push:
|
|
445
|
+
branches: [main]
|
|
446
|
+
|
|
447
|
+
jobs:
|
|
448
|
+
deploy:
|
|
449
|
+
runs-on: ubuntu-latest
|
|
450
|
+
steps:
|
|
451
|
+
- uses: actions/checkout@v4
|
|
452
|
+
|
|
453
|
+
- uses: ruby/setup-ruby@v1
|
|
454
|
+
with:
|
|
455
|
+
ruby-version: '3.3'
|
|
456
|
+
|
|
457
|
+
- run: gem install nvoi
|
|
458
|
+
|
|
459
|
+
- run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
|
|
460
|
+
|
|
461
|
+
- run: nvoi deploy
|
|
462
|
+
YAML
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def generate_branch_workflow
|
|
466
|
+
<<~YAML
|
|
467
|
+
name: Deploy Branch
|
|
468
|
+
|
|
469
|
+
on:
|
|
470
|
+
push:
|
|
471
|
+
branches-ignore: [main]
|
|
472
|
+
|
|
473
|
+
jobs:
|
|
474
|
+
deploy:
|
|
475
|
+
runs-on: ubuntu-latest
|
|
476
|
+
steps:
|
|
477
|
+
- uses: actions/checkout@v4
|
|
478
|
+
|
|
479
|
+
- uses: ruby/setup-ruby@v1
|
|
480
|
+
with:
|
|
481
|
+
ruby-version: '3.3'
|
|
482
|
+
|
|
483
|
+
- run: gem install nvoi
|
|
484
|
+
|
|
485
|
+
- run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
|
|
486
|
+
|
|
487
|
+
- run: nvoi deploy --branch ${{ github.ref_name }}
|
|
488
|
+
YAML
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def generate_readme
|
|
492
|
+
<<~MD
|
|
493
|
+
# NVOI Setup
|
|
494
|
+
|
|
495
|
+
## Files in this export
|
|
496
|
+
|
|
497
|
+
- `deploy.enc` - Encrypted deploy configuration (commit this)
|
|
498
|
+
- `deploy.key.txt` - Encryption key (add as CI secret, DO NOT commit)
|
|
499
|
+
- `.github/workflows/deploy-prod.yml` - Deploy on push to main
|
|
500
|
+
- `.github/workflows/deploy-branch.yml` - Deploy branches with prefix
|
|
501
|
+
|
|
502
|
+
## Setup Steps
|
|
503
|
+
|
|
504
|
+
1. Copy `deploy.enc` to your repo root
|
|
505
|
+
2. Copy `.github/workflows/*.yml` to your repo
|
|
506
|
+
3. Add `DEPLOY_KEY` secret in GitHub repo settings:
|
|
507
|
+
- Go to Settings → Secrets and variables → Actions
|
|
508
|
+
- Click "New repository secret"
|
|
509
|
+
- Name: `DEPLOY_KEY`
|
|
510
|
+
- Value: contents of `deploy.key.txt`
|
|
511
|
+
4. Push to main to trigger first deploy
|
|
512
|
+
|
|
513
|
+
## How it works
|
|
514
|
+
|
|
515
|
+
- Push to `main` → deploys to production
|
|
516
|
+
- Push to any other branch → deploys with branch prefix (isolated infra)
|
|
517
|
+
- Deploy logs stream to: #{@project.callback_url}
|
|
518
|
+
MD
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# app/controllers/deploys_controller.rb
|
|
523
|
+
class DeploysController < ApplicationController
|
|
524
|
+
before_action :set_project
|
|
525
|
+
before_action :set_deploy, only: [:show]
|
|
526
|
+
|
|
527
|
+
def index
|
|
528
|
+
@deploys = @project.deploys.recent
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def show
|
|
532
|
+
@logs = @deploy.logs.order(:logged_at)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
private
|
|
536
|
+
|
|
537
|
+
def set_project
|
|
538
|
+
@project = current_user.projects.find(params[:project_id])
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def set_deploy
|
|
542
|
+
@deploy = @project.deploys.find(params[:id])
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## 6. Routes
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
# config/routes.rb
|
|
553
|
+
Rails.application.routes.draw do
|
|
554
|
+
# Auth
|
|
555
|
+
get "/auth/github/callback", to: "sessions#create"
|
|
556
|
+
get "/login", to: "sessions#new"
|
|
557
|
+
delete "/logout", to: "sessions#destroy"
|
|
558
|
+
|
|
559
|
+
# API - receives callbacks from nvoi gem running in CI
|
|
560
|
+
namespace :api do
|
|
561
|
+
scope "/projects/:callback_token/deploys/:external_id" do
|
|
562
|
+
post "logs", to: "deploys#logs"
|
|
563
|
+
post "status", to: "deploys#status"
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Dashboard
|
|
568
|
+
resources :projects do
|
|
569
|
+
member do
|
|
570
|
+
get :export
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
resources :deploys, only: [:index, :show]
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
root "projects#index"
|
|
577
|
+
end
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## 7. Views
|
|
583
|
+
|
|
584
|
+
```erb
|
|
585
|
+
<%# app/views/projects/show.html.erb %>
|
|
586
|
+
<div class="project">
|
|
587
|
+
<header class="flex justify-between items-center">
|
|
588
|
+
<h1><%= @project.name %></h1>
|
|
589
|
+
<div class="actions">
|
|
590
|
+
<%= link_to "Edit Config", edit_project_path(@project), class: "btn" %>
|
|
591
|
+
<%= link_to "Export", export_project_path(@project), class: "btn btn-primary" %>
|
|
592
|
+
</div>
|
|
593
|
+
</header>
|
|
594
|
+
|
|
595
|
+
<section class="callback-info">
|
|
596
|
+
<h3>Callback URL</h3>
|
|
597
|
+
<code><%= @project.callback_url %></code>
|
|
598
|
+
<p class="text-sm text-gray-500">
|
|
599
|
+
This is automatically included in your deploy.enc
|
|
600
|
+
</p>
|
|
601
|
+
</section>
|
|
602
|
+
|
|
603
|
+
<section class="deploys">
|
|
604
|
+
<h2>Recent Deploys</h2>
|
|
605
|
+
|
|
606
|
+
<% if @deploys.any? %>
|
|
607
|
+
<table>
|
|
608
|
+
<thead>
|
|
609
|
+
<tr>
|
|
610
|
+
<th>Branch</th>
|
|
611
|
+
<th>Commit</th>
|
|
612
|
+
<th>Status</th>
|
|
613
|
+
<th>Duration</th>
|
|
614
|
+
<th>Started</th>
|
|
615
|
+
</tr>
|
|
616
|
+
</thead>
|
|
617
|
+
<tbody>
|
|
618
|
+
<% @deploys.each do |deploy| %>
|
|
619
|
+
<tr>
|
|
620
|
+
<td><%= deploy.branch %></td>
|
|
621
|
+
<td>
|
|
622
|
+
<code><%= deploy.git_sha&.first(7) %></code>
|
|
623
|
+
</td>
|
|
624
|
+
<td>
|
|
625
|
+
<%= render "deploys/status_badge", deploy: deploy %>
|
|
626
|
+
</td>
|
|
627
|
+
<td>
|
|
628
|
+
<% if deploy.duration %>
|
|
629
|
+
<%= pluralize(deploy.duration.round, "second") %>
|
|
630
|
+
<% end %>
|
|
631
|
+
</td>
|
|
632
|
+
<td>
|
|
633
|
+
<%= link_to time_ago_in_words(deploy.created_at) + " ago",
|
|
634
|
+
project_deploy_path(@project, deploy) %>
|
|
635
|
+
</td>
|
|
636
|
+
</tr>
|
|
637
|
+
<% end %>
|
|
638
|
+
</tbody>
|
|
639
|
+
</table>
|
|
640
|
+
<% else %>
|
|
641
|
+
<p class="empty">No deploys yet. Push to your repo to trigger a deploy.</p>
|
|
642
|
+
<% end %>
|
|
643
|
+
</section>
|
|
644
|
+
</div>
|
|
645
|
+
|
|
646
|
+
<%# app/views/deploys/show.html.erb %>
|
|
647
|
+
<div class="deploy">
|
|
648
|
+
<header>
|
|
649
|
+
<h1>Deploy <%= @deploy.git_sha&.first(7) %></h1>
|
|
650
|
+
<div id="deploy_status">
|
|
651
|
+
<%= render "deploys/status", deploy: @deploy %>
|
|
652
|
+
</div>
|
|
653
|
+
</header>
|
|
654
|
+
|
|
655
|
+
<dl class="deploy-meta">
|
|
656
|
+
<dt>Branch</dt>
|
|
657
|
+
<dd><%= @deploy.branch %></dd>
|
|
658
|
+
|
|
659
|
+
<dt>Commit</dt>
|
|
660
|
+
<dd><code><%= @deploy.git_sha %></code></dd>
|
|
661
|
+
|
|
662
|
+
<% if @deploy.ci_run_url %>
|
|
663
|
+
<dt>CI Run</dt>
|
|
664
|
+
<dd><%= link_to "View in #{@deploy.ci_provider}", @deploy.ci_run_url, target: "_blank" %></dd>
|
|
665
|
+
<% end %>
|
|
666
|
+
|
|
667
|
+
<% if @deploy.duration %>
|
|
668
|
+
<dt>Duration</dt>
|
|
669
|
+
<dd><%= pluralize(@deploy.duration.round, "second") %></dd>
|
|
670
|
+
<% end %>
|
|
671
|
+
|
|
672
|
+
<% @deploy.tunnels.each do |tunnel| %>
|
|
673
|
+
<dt><%= tunnel["service_name"] %></dt>
|
|
674
|
+
<dd><%= link_to tunnel["hostname"], "https://#{tunnel['hostname']}", target: "_blank" %></dd>
|
|
675
|
+
<% end %>
|
|
676
|
+
</dl>
|
|
677
|
+
|
|
678
|
+
<%= turbo_stream_from @deploy %>
|
|
679
|
+
|
|
680
|
+
<div id="deploy_logs" class="logs">
|
|
681
|
+
<% @logs.each do |log| %>
|
|
682
|
+
<%= render "deploys/log_line", log: log %>
|
|
683
|
+
<% end %>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
|
|
687
|
+
<%# app/views/deploys/_log_line.html.erb %>
|
|
688
|
+
<div class="log-line log-<%= log.level %>">
|
|
689
|
+
<span class="timestamp"><%= log.logged_at.strftime("%H:%M:%S.%L") %></span>
|
|
690
|
+
<span class="level"><%= log.level %></span>
|
|
691
|
+
<span class="message"><%= log.message %></span>
|
|
692
|
+
</div>
|
|
693
|
+
|
|
694
|
+
<%# app/views/deploys/_status.html.erb %>
|
|
695
|
+
<% case deploy.status.to_sym %>
|
|
696
|
+
<% when :running %>
|
|
697
|
+
<span class="badge badge-blue animate-pulse">Deploying...</span>
|
|
698
|
+
<% when :completed %>
|
|
699
|
+
<span class="badge badge-green">Completed</span>
|
|
700
|
+
<% when :failed %>
|
|
701
|
+
<span class="badge badge-red">Failed</span>
|
|
702
|
+
<% if deploy.error_message %>
|
|
703
|
+
<p class="error-message"><%= deploy.error_message %></p>
|
|
704
|
+
<% end %>
|
|
705
|
+
<% end %>
|
|
706
|
+
|
|
707
|
+
<%# app/views/deploys/_status_badge.html.erb %>
|
|
708
|
+
<% case deploy.status.to_sym %>
|
|
709
|
+
<% when :running %>
|
|
710
|
+
<span class="badge badge-blue">●</span>
|
|
711
|
+
<% when :completed %>
|
|
712
|
+
<span class="badge badge-green">✓</span>
|
|
713
|
+
<% when :failed %>
|
|
714
|
+
<span class="badge badge-red">✗</span>
|
|
715
|
+
<% end %>
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
---
|
|
719
|
+
|
|
720
|
+
## 8. Config Editor (Optional Wizard)
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
# app/controllers/configs_controller.rb
|
|
724
|
+
class ConfigsController < ApplicationController
|
|
725
|
+
before_action :set_project
|
|
726
|
+
|
|
727
|
+
def edit
|
|
728
|
+
@config = @project.decrypted_config
|
|
729
|
+
@step = params[:step]&.to_i || 1
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def update
|
|
733
|
+
# Merge step data into config
|
|
734
|
+
current_config = @project.decrypted_config
|
|
735
|
+
updated_config = current_config.deep_merge(config_params.to_h)
|
|
736
|
+
|
|
737
|
+
@project.save_config(updated_config)
|
|
738
|
+
|
|
739
|
+
if params[:next_step]
|
|
740
|
+
redirect_to edit_project_config_path(@project, step: params[:next_step])
|
|
741
|
+
else
|
|
742
|
+
redirect_to project_path(@project), notice: "Config saved"
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
private
|
|
747
|
+
|
|
748
|
+
def set_project
|
|
749
|
+
@project = current_user.projects.find(params[:project_id])
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def config_params
|
|
753
|
+
params.require(:config).permit!
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
## Summary
|
|
761
|
+
|
|
762
|
+
This Rails app:
|
|
763
|
+
|
|
764
|
+
1. **Does NOT run deploys** - CI does that
|
|
765
|
+
2. **Receives callbacks** from nvoi gem running in CI
|
|
766
|
+
3. **Stores deploy history** and logs
|
|
767
|
+
4. **Streams logs in real-time** via Turbo
|
|
768
|
+
5. **Config wizard** generates deploy.enc
|
|
769
|
+
6. **Exports** deploy.enc + deploy.key + CI workflow files
|
|
770
|
+
7. **One secret** (DEPLOY_KEY) encrypts config AND signs callbacks
|
|
771
|
+
|
|
772
|
+
### User Flow
|
|
773
|
+
|
|
774
|
+
1. Create project in dashboard
|
|
775
|
+
2. Configure via wizard (or paste YAML)
|
|
776
|
+
3. Click "Export" → download zip
|
|
777
|
+
4. Commit `deploy.enc` to repo
|
|
778
|
+
5. Add `DEPLOY_KEY` to CI secrets
|
|
779
|
+
6. Push to trigger deploy
|
|
780
|
+
7. Watch logs stream in dashboard
|
|
781
|
+
|
|
782
|
+
### CI Files Generated
|
|
783
|
+
|
|
784
|
+
```yaml
|
|
785
|
+
# .github/workflows/deploy-prod.yml
|
|
786
|
+
on:
|
|
787
|
+
push:
|
|
788
|
+
branches: [main]
|
|
789
|
+
jobs:
|
|
790
|
+
deploy:
|
|
791
|
+
runs-on: ubuntu-latest
|
|
792
|
+
steps:
|
|
793
|
+
- uses: actions/checkout@v4
|
|
794
|
+
- uses: ruby/setup-ruby@v1
|
|
795
|
+
with: { ruby-version: '3.3' }
|
|
796
|
+
- run: gem install nvoi
|
|
797
|
+
- run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
|
|
798
|
+
- run: nvoi deploy
|
|
799
|
+
|
|
800
|
+
# .github/workflows/deploy-branch.yml
|
|
801
|
+
on:
|
|
802
|
+
push:
|
|
803
|
+
branches-ignore: [main]
|
|
804
|
+
jobs:
|
|
805
|
+
deploy:
|
|
806
|
+
runs-on: ubuntu-latest
|
|
807
|
+
steps:
|
|
808
|
+
- uses: actions/checkout@v4
|
|
809
|
+
- uses: ruby/setup-ruby@v1
|
|
810
|
+
with: { ruby-version: '3.3' }
|
|
811
|
+
- run: gem install nvoi
|
|
812
|
+
- run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
|
|
813
|
+
- run: nvoi deploy --branch ${{ github.ref_name }}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Works with any CI that can run Ruby and has secrets support.
|