lux-hammer 0.3.10 → 0.3.13

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,555 @@
1
+ module LuxDeploy
2
+ module Commands
3
+ module_function
4
+
5
+ # -------- up -----------------------------------------------------------
6
+
7
+ def up(opts)
8
+ ctx = Context.build(opts)
9
+ step "deploy #{ctx.app} (branch #{ctx.branch}) -> #{ctx.host}"
10
+
11
+ check_renamed_hooks!
12
+ run_local_before_hook(ctx)
13
+
14
+ ensure_remote_dirs(ctx)
15
+ wipe_stale_new_release(ctx)
16
+ ctx.ports ||= allocate_ports(ctx)
17
+ render_artifacts(ctx)
18
+
19
+ step 'rsync code'
20
+ # "!*" drops any file whose name starts with "!" at any depth - the
21
+ # disable convention (e.g. !job.service, !scratch.rb never ship).
22
+ ctx.ssh.rsync('./', "#{ctx.app_dir}/new-release/",
23
+ excludes: %w[.git tmp log node_modules .DS_Store coverage !*])
24
+
25
+ step 'symlink shared dirs into new-release'
26
+ ctx.ssh.run(<<~SH, as: :service)
27
+ cd #{Shellwords.escape(ctx.app_dir)}/new-release && \
28
+ ln -sfn ../shared/tmp tmp && \
29
+ ln -sfn ../shared/log log && \
30
+ ln -sfn ../.env .env
31
+ SH
32
+
33
+ step 'write rendered .env / systemd.service / caddy.config'
34
+ upload_artifacts(ctx)
35
+
36
+ run_remote_before_hook(ctx)
37
+
38
+ step 'atomic release swap'
39
+ ctx.ssh.run(<<~SH, as: :service)
40
+ cd #{Shellwords.escape(ctx.app_dir)} && \
41
+ rm -rf old-release && \
42
+ ( [ -d release ] && mv release old-release || true ) && \
43
+ mv new-release release
44
+ SH
45
+
46
+ step 'install systemd + caddy symlinks'
47
+ install_system_symlinks(ctx)
48
+
49
+ step 'reload services'
50
+ reload_services(ctx)
51
+
52
+ run_remote_after_hook(ctx)
53
+
54
+ step "write #{Manifest::FILENAME}"
55
+ upload_manifest(ctx)
56
+
57
+ run_local_after_hook(ctx)
58
+
59
+ step "done. https://#{ctx.domain} (#{ctx.ports.map { |k, v| "#{k}=#{v}" }.join(' ')})"
60
+ end
61
+
62
+ # -------- lifecycle hook commands -------------------------------------
63
+
64
+ def hook(opts, side, timing)
65
+ ctx = Context.build(opts, render: false)
66
+ check_renamed_hooks!
67
+
68
+ case [side.to_sym, timing.to_sym]
69
+ when [:local, :before] then run_local_before_hook(ctx)
70
+ when [:remote, :before] then run_remote_before_hook(ctx)
71
+ when [:remote, :after] then run_remote_after_hook(ctx)
72
+ when [:local, :after] then run_local_after_hook(ctx, strict: true)
73
+ else raise Error.new("unknown lifecycle hook: #{side}:#{timing}")
74
+ end
75
+ end
76
+
77
+ # -------- destroy ------------------------------------------------------
78
+
79
+ def destroy(opts)
80
+ ctx = Context.build(opts, render: false)
81
+ step "destroy #{ctx.app} on #{ctx.host}"
82
+ confirm_destroy!(ctx) unless opts[:yes]
83
+
84
+ step 'stop + disable systemd units'
85
+ lines = ctx.services.flat_map do |s|
86
+ ["systemctl disable --now #{s.unit} 2>/dev/null || true",
87
+ "rm -f #{SYSTEMD_DIR}/#{s.unit}.service"]
88
+ end
89
+ lines << 'systemctl daemon-reload'
90
+ ctx.ssh.run(lines.join("\n"), allow_fail: true)
91
+
92
+ step 'unlink caddy site'
93
+ ctx.ssh.run(<<~SH, allow_fail: true)
94
+ rm -f #{CADDY_SITES}/#{ctx.app}.caddy
95
+ systemctl reload caddy || true
96
+ SH
97
+
98
+ step "rm -rf #{ctx.app_dir}"
99
+ ctx.ssh.run("rm -rf #{Shellwords.escape(ctx.app_dir)}", as: :service, allow_fail: true)
100
+
101
+ step 'done.'
102
+ end
103
+
104
+ # -------- redeploy ----------------------------------------------------
105
+
106
+ def redeploy(opts)
107
+ destroy(opts)
108
+ up(opts)
109
+ end
110
+
111
+ # -------- doctor ------------------------------------------------------
112
+
113
+ def doctor(opts)
114
+ host = Context.read_host(opts)
115
+ config = Config.load
116
+ ssh = SSH.new(host, service_user: config.service_user, dry_run: false)
117
+ Doctor.run(ssh, config, fix: opts.fetch(:fix, true))
118
+ end
119
+
120
+ # -------- app:init ----------------------------------------------------
121
+
122
+ # Copy every shipped template into ./config/deploy/. Existing files are
123
+ # left untouched so re-running this is safe. The files are raw - users
124
+ # edit them in place and the deploy step renders {{VAR}} placeholders.
125
+ #
126
+ # `templates_dir:` (passed via Hammer.register, or CLI --from) overrides
127
+ # the gem's bundled generic templates; the lux-fw plugin uses this to
128
+ # ship lux-flavored defaults without needing an adapter class.
129
+ def init(opts)
130
+ dest_dir = './config/deploy'
131
+ shipped_dir = opts[:templates_dir]&.to_s || LuxDeploy::ROOT.join('templates').to_s
132
+ raise Error.new("templates dir not found: #{shipped_dir}") unless Dir.exist?(shipped_dir)
133
+
134
+ FileUtils.mkdir_p(dest_dir)
135
+ step "init #{dest_dir}/ from #{shipped_dir}"
136
+
137
+ Dir.children(shipped_dir).sort.each do |name|
138
+ src = File.join(shipped_dir, name)
139
+ dst = File.join(dest_dir, name)
140
+ next unless File.file?(src)
141
+
142
+ if File.exist?(dst)
143
+ $stderr.puts " skip #{name} (exists)"
144
+ else
145
+ FileUtils.cp(src, dst)
146
+ $stderr.puts " write #{name}"
147
+ end
148
+ end
149
+
150
+ $stderr.puts "done. edit #{dest_dir}/.env (SECRET, DOMAIN) and #{dest_dir}/.yaml, then run 'deploy doctor' and 'deploy up'"
151
+ end
152
+
153
+ # -------- server:ssh --------------------------------------------------
154
+
155
+ def server_ssh(opts)
156
+ ctx = Context.build(opts, render: false)
157
+ step "ssh #{ctx.app_dir}/release (#{ctx.config.service_user})"
158
+ ctx.ssh.exec(
159
+ "cd #{Shellwords.escape(ctx.app_dir)}/release && exec bash -li",
160
+ as: :service
161
+ )
162
+ end
163
+
164
+ # -------- server:log --------------------------------------------------
165
+
166
+ def server_log(opts)
167
+ ctx = Context.build(opts, render: false)
168
+ unit = web_unit(ctx)
169
+ step "journalctl -fu #{unit}"
170
+ ctx.ssh.exec("journalctl -u #{unit} -n 200 -f")
171
+ end
172
+
173
+ # -------- log ---------------------------------------------------------
174
+
175
+ # `lux-deploy log` lists the shared release/log dir; `--log <name>` dumps the
176
+ # last `--lines` (200) lines of that file (the `.log` suffix is optional).
177
+ def log(opts)
178
+ ctx = Context.build(opts, render: false)
179
+ log_dir = "#{ctx.app_dir}/release/log"
180
+
181
+ if (name = opts[:log])
182
+ name = "#{name}.log" unless name.to_s.end_with?('.log')
183
+ lines = (opts[:lines] || 200).to_i
184
+ path = "#{log_dir}/#{name}"
185
+ step "tail -n #{lines} #{path}"
186
+ ctx.ssh.stream("tail -n #{lines} #{Shellwords.escape(path)}", as: :service, allow_fail: true)
187
+ else
188
+ step "logs in #{log_dir}"
189
+ ctx.ssh.stream("ls -lh #{Shellwords.escape(log_dir)}/", as: :service, allow_fail: true)
190
+ end
191
+ end
192
+
193
+ # -------- server:restart ----------------------------------------------
194
+
195
+ def server_restart(opts)
196
+ ctx = Context.build(opts, render: false)
197
+ unit = web_unit(ctx)
198
+ step "restart #{unit}"
199
+ ctx.ssh.run("systemctl restart #{unit}")
200
+ end
201
+
202
+ # -------- server:status -----------------------------------------------
203
+
204
+ def server_status(opts)
205
+ ctx = Context.build(opts, render: false)
206
+ unit = web_unit(ctx)
207
+ step "status #{unit}"
208
+ ctx.ssh.stream("systemctl status #{unit} --no-pager", allow_fail: true)
209
+ end
210
+
211
+ # -------- server:errors -----------------------------------------------
212
+
213
+ def server_errors(opts)
214
+ ctx = Context.build(opts, render: false)
215
+ path = "#{ctx.app_dir}/release/log/error.log"
216
+ step "tail -f #{path}"
217
+ ctx.ssh.exec("tail -f #{Shellwords.escape(path)}", as: :service)
218
+ end
219
+
220
+ # -------- helpers ------------------------------------------------------
221
+
222
+ def step(msg)
223
+ $stderr.puts "==> #{msg}"
224
+ end
225
+
226
+ def confirm_destroy!(ctx)
227
+ $stderr.print "type '#{ctx.domain}' to confirm destroy: "
228
+ typed = $stdin.gets.to_s.strip
229
+ raise Error.new('aborted; pass --yes to skip prompt') unless typed == ctx.domain
230
+ end
231
+
232
+ # Load a remote .env file into the current shell, exporting each
233
+ # KEY=VALUE line. Safer than `. file` because each line is passed as
234
+ # a single quoted argument to `export` - bash word-splitting and
235
+ # glob expansion never touch the value. Strips surrounding "..." or
236
+ # '...' from the value so `DOMAIN="a, *.b"` and `DOMAIN=a, *.b`
237
+ # both yield DOMAIN=`a, *.b` (matching dotenv conventions).
238
+ def env_source_sh(path)
239
+ body = <<~'SH'.chomp
240
+ while IFS= read -r __l || [ -n "$__l" ]; do
241
+ case "$__l" in
242
+ ''|\#*) continue ;;
243
+ *=*) ;;
244
+ *) continue ;;
245
+ esac
246
+ __v="${__l#*=}"
247
+ case "$__v" in
248
+ \"*\") __v="${__v#\"}"; __v="${__v%\"}" ;;
249
+ \'*\') __v="${__v#\'}"; __v="${__v%\'}" ;;
250
+ esac
251
+ export "${__l%%=*}=$__v"
252
+ done <
253
+ SH
254
+ "#{body} #{Shellwords.escape(path)}"
255
+ end
256
+
257
+ # Remove any new-release/ left behind by a prior failed deploy.
258
+ # Runs at the start of `up` so a fresh rsync doesn't merge with
259
+ # stale gem builds / half-installed assets.
260
+ def wipe_stale_new_release(ctx)
261
+ step 'wipe stale new-release (if any from prior failed deploy)'
262
+ ctx.ssh.run("rm -rf #{Shellwords.escape(ctx.app_dir)}/new-release", as: :service, allow_fail: true)
263
+ end
264
+
265
+ # Web unit name (bare, no .service suffix since systemctl accepts both).
266
+ # Used by server:restart/log/status which target the web service.
267
+ def web_unit(ctx) = "#{ctx.config.service_prefix}-#{ctx.app}"
268
+
269
+ # Enable + restart every discovered service, then reload caddy once.
270
+ # daemon-reload first so changed unit files are picked up. set -e aborts
271
+ # (and run raises) on the first failure.
272
+ def reload_services(ctx)
273
+ lines = ['set -e', 'systemctl daemon-reload']
274
+ ctx.services.each do |s|
275
+ lines << "systemctl enable --now #{s.unit}"
276
+ lines << "systemctl restart #{s.unit}"
277
+ end
278
+ lines << 'systemctl reload caddy'
279
+ ctx.ssh.run(lines.join("\n"))
280
+ end
281
+
282
+ def ensure_remote_dirs(ctx)
283
+ step 'ensure remote dirs'
284
+ ctx.ssh.run(<<~SH, as: :service)
285
+ mkdir -p #{Shellwords.escape(ctx.app_dir)}/shared/tmp
286
+ mkdir -p #{Shellwords.escape(ctx.app_dir)}/shared/log
287
+ SH
288
+ end
289
+
290
+ # Resolve every managed PORT* token to a concrete port. Tokens come from
291
+ # needed_port_keys (PORT* keys in .env + {{PORT*}} placeholders in any
292
+ # template). Each is reused from the remote .env when present, else a free
293
+ # port is allocated from PORT_RANGE. The ss scan only runs when at least
294
+ # one new port must be allocated - re-deploys that reuse everything never
295
+ # probe the host. Returns an ordered {PORT: 3010, PORT_FOO: 3020} hash.
296
+ def allocate_ports(ctx)
297
+ step 'allocate ports'
298
+ needed = needed_port_keys(ctx)
299
+ existing = read_existing_ports(ctx)
300
+ reused = needed.select { |k| existing[k] }
301
+
302
+ ports = {}
303
+ reused.each { |k| ports[k] = existing[k] }
304
+
305
+ to_assign = needed - reused
306
+ unless to_assign.empty?
307
+ in_use = scan_listening_ports(ctx) | existing.values.to_set
308
+ to_assign.each do |k|
309
+ free = PORT_RANGE.find { |p| !in_use.include?(p) }
310
+ raise Error.new("no free port in 3010..3990 (step 10) for #{k}") unless free
311
+ ports[k] = free
312
+ in_use << free
313
+ end
314
+ end
315
+
316
+ ordered = needed.each_with_object({}) { |k, h| h[k] = ports[k] }
317
+ ordered.each do |k, v|
318
+ $stderr.puts " #{reused.include?(k) ? 'reusing' : 'allocated'} #{k}=#{v}"
319
+ end
320
+ ordered
321
+ end
322
+
323
+ # PORT* tokens this deploy manages: union of PORT-prefixed keys declared in
324
+ # the .env template(s) and {{PORT*}} placeholders referenced by caddy /
325
+ # systemd units. A web app with no explicit PORT still gets one because
326
+ # caddy.conf references {{PORT}}.
327
+ def needed_port_keys(ctx)
328
+ keys = []
329
+ [ctx.env_template_name, '.env'].uniq.each do |n|
330
+ src = ctx.template_source(n) or next
331
+ keys.concat(src.scan(/^(PORT[A-Z0-9_]*)\s*=/).flatten)
332
+ end
333
+ (['caddy.conf'] + ctx.services.map(&:template)).uniq.each do |n|
334
+ src = ctx.template_source(n) or next
335
+ keys.concat(src.scan(/\{\{(PORT[A-Z0-9_]*)\}\}/).flatten)
336
+ end
337
+ keys.uniq.map(&:to_sym)
338
+ end
339
+
340
+ # PORT* => Integer parsed from the remote .env (re-deploys reuse these).
341
+ def read_existing_ports(ctx)
342
+ out = ctx.ssh.run(
343
+ "[ -f #{Shellwords.escape(ctx.app_dir)}/.env ] && " \
344
+ "grep -E '^PORT[A-Z0-9_]*=' #{Shellwords.escape(ctx.app_dir)}/.env || true",
345
+ as: :service, allow_fail: true
346
+ )
347
+ out.lines.each_with_object({}) do |l, h|
348
+ h[$1.to_sym] = $2.to_i if l.strip =~ /^(PORT[A-Z0-9_]*)=(\d+)/
349
+ end
350
+ end
351
+
352
+ def scan_listening_ports(ctx)
353
+ ctx.ssh.run("ss -tlnH | awk '{print $4}' | sed 's/.*://'", allow_fail: true)
354
+ .lines.map { |l| l.strip.to_i }.to_set
355
+ end
356
+
357
+ # One-pass render. Every template (.env, caddy.conf, and each
358
+ # *.service unit) is rendered with the same var set: git-derived + yaml +
359
+ # engine-dynamic (PORT*/DIR, plus RUBY/RUBY_DIR when referenced).
360
+ # The rendered .env never feeds back into the namespace - it is a
361
+ # runtime-only file the app reads at boot, opaque to the engine.
362
+ def render_artifacts(ctx)
363
+ step 'render templates'
364
+
365
+ vars = ctx.base_vars.merge(ctx.ports).merge(DIR: ctx.app_dir)
366
+ # RUBY/RUBY_DIR (and the ssh ruby probe) only when a template asks for
367
+ # them - a Go/Python unit running a built binary never triggers it.
368
+ if ctx.ruby_used?
369
+ vars = vars.merge(RUBY: ctx.ruby_path, RUBY_DIR: File.dirname(ctx.ruby_path))
370
+ end
371
+
372
+ # Persist every allocated PORT* into .env so allocate_ports reuses the
373
+ # same ports on the next deploy. Without this they only land in
374
+ # systemd/caddy, the reuse path never fires, and ports drift.
375
+ env_body = persist_ports(Template.render(ctx.read_template(ctx.env_template_name), vars), ctx.ports)
376
+
377
+ rendered = {
378
+ '.env' => env_body,
379
+ 'caddy.config' => Template.render(ctx.read_template('caddy.conf'), vars)
380
+ }
381
+ ctx.services.each do |s|
382
+ rendered[s.artifact] = Template.render(ctx.read_template(s.template), vars)
383
+ end
384
+ ctx.rendered = rendered
385
+ end
386
+
387
+ # Ensure every managed PORT* key appears with its value in the rendered
388
+ # .env body: replace an existing `KEY=` line or append one. The `=` anchor
389
+ # keeps PORT= from clobbering PORT_FOO=.
390
+ def persist_ports(env_body, ports)
391
+ ports.each do |key, val|
392
+ if env_body =~ /^#{key}=.*$/
393
+ env_body = env_body.sub(/^#{key}=.*$/, "#{key}=#{val}")
394
+ else
395
+ env_body += "\n" unless env_body.empty? || env_body.end_with?("\n")
396
+ env_body += "#{key}=#{val}\n"
397
+ end
398
+ end
399
+ env_body
400
+ end
401
+
402
+ # Upload rendered files atomically (write to .new, mv).
403
+ # .env is 0600 (secrets); other artifacts are 0644 so caddy/systemd
404
+ # (running as their own users) can read the symlinks into ctx.app_dir.
405
+ def upload_artifacts(ctx)
406
+ ctx.rendered.each do |name, body|
407
+ remote_path = "#{ctx.app_dir}/#{name}"
408
+ b64 = [body].pack('m0')
409
+ mode = name == '.env' ? '0600' : '0644'
410
+ ctx.ssh.run(<<~SH, as: :service)
411
+ install -d #{Shellwords.escape(File.dirname(remote_path))}
412
+ echo #{Shellwords.escape(b64)} | base64 -d > #{Shellwords.escape(remote_path)}.new
413
+ mv #{Shellwords.escape(remote_path)}.new #{Shellwords.escape(remote_path)}
414
+ chmod #{mode} #{Shellwords.escape(remote_path)}
415
+ SH
416
+ end
417
+ end
418
+
419
+ LOCAL_BEFORE_HOOK ||= 'config/deploy/local_before.sh'
420
+ REMOTE_BEFORE_HOOK ||= 'config/deploy/remote_before.sh'
421
+ REMOTE_AFTER_HOOK ||= 'config/deploy/remote_after.sh'
422
+ LOCAL_AFTER_HOOK ||= 'config/deploy/local_after.sh'
423
+
424
+ # Hooks were renamed to a symmetric local_/remote_ scheme. Old names no
425
+ # longer fire - abort with a rename hint rather than silently skipping a
426
+ # hook the user believes still runs.
427
+ RENAMED_HOOKS ||= {
428
+ 'config/deploy/before_local.sh' => LOCAL_BEFORE_HOOK,
429
+ 'config/deploy/before_server.sh' => REMOTE_BEFORE_HOOK,
430
+ 'config/deploy/after_server.sh' => REMOTE_AFTER_HOOK
431
+ }.freeze
432
+
433
+ def check_renamed_hooks!
434
+ stale = RENAMED_HOOKS.select { |old, _| File.exist?(old) }
435
+ return if stale.empty?
436
+ body = stale.map { |old, new| " mv #{old} #{new}" }.join("\n")
437
+ raise Error.new("lifecycle hooks were renamed; rename these files:\n#{body}")
438
+ end
439
+
440
+ # Pre-flight gate. Runs locally in the project root before any remote
441
+ # work. Optional - announced as "(not defined, skipping)" when the
442
+ # file is absent so a missing hook is visible in the output, not
443
+ # silent. Non-zero exit aborts the deploy.
444
+ def run_local_before_hook(ctx)
445
+ unless File.exist?(LOCAL_BEFORE_HOOK)
446
+ step "run #{LOCAL_BEFORE_HOOK} (not defined, skipping)"
447
+ return
448
+ end
449
+ step "run #{LOCAL_BEFORE_HOOK} (local)"
450
+ if ctx.ssh.dry_run
451
+ $stderr.puts " [dry] bash #{LOCAL_BEFORE_HOOK}"
452
+ return
453
+ end
454
+ system('bash', LOCAL_BEFORE_HOOK) or
455
+ raise Error.new("#{LOCAL_BEFORE_HOOK} failed; deploy aborted (no remote state changed)")
456
+ end
457
+
458
+ # Install/migrate hook. Runs on the server inside new-release/ as the
459
+ # service user, AFTER rsync + symlinks + .env upload, BEFORE the swap.
460
+ # The rendered .env is sourced into the shell before the script runs
461
+ # so DB_URL / SECRET / etc. are exported. This is where the user does
462
+ # `bundle install`, `npm ci`, `go build`, db migrations, asset compile,
463
+ # etc. - the engine itself is language-agnostic past this point.
464
+ #
465
+ # Optional - announced as "(not defined, skipping)" when absent so the
466
+ # absence is visible. Non-zero exit aborts: the new-release/ dir is
467
+ # kept for inspection, release/ is untouched.
468
+ def run_remote_before_hook(ctx)
469
+ unless File.exist?(REMOTE_BEFORE_HOOK)
470
+ step "run #{REMOTE_BEFORE_HOOK} (not defined, skipping)"
471
+ return
472
+ end
473
+ step "run #{REMOTE_BEFORE_HOOK} (server, in new-release, .env sourced)"
474
+ ok = ctx.ssh.stream(<<~SH, as: :service, allow_fail: true)
475
+ set -e
476
+ cd #{Shellwords.escape(ctx.app_dir)}/new-release
477
+ #{env_source_sh('.env')}
478
+ bash #{Shellwords.escape(REMOTE_BEFORE_HOOK)}
479
+ SH
480
+ return if ok
481
+ raise Error.new(
482
+ "#{REMOTE_BEFORE_HOOK} failed; deploy aborted.\n" \
483
+ " release/ untouched. new-release/ kept at #{ctx.app_dir}/new-release on #{ctx.host}.\n" \
484
+ " Inspect: deploy server:ssh Retry hook: deploy on:remote:before Full redeploy: deploy up"
485
+ )
486
+ end
487
+
488
+ # Post-deploy server hook. Runs on the server inside release/ after the
489
+ # swap and service reload, with the rendered .env sourced into the shell
490
+ # (same shape as the remote-before hook so both sides see the same
491
+ # exported vars). Optional - announced as "(not defined, skipping)"
492
+ # when absent so the absence is visible. Non-zero exit fails the command
493
+ # but does NOT roll back (deploy is already live).
494
+ def run_remote_after_hook(ctx)
495
+ unless File.exist?(REMOTE_AFTER_HOOK)
496
+ step "run #{REMOTE_AFTER_HOOK} (not defined, skipping)"
497
+ return
498
+ end
499
+ step "run #{REMOTE_AFTER_HOOK} (server, in release, .env sourced)"
500
+ ok = ctx.ssh.stream(<<~SH, as: :service, allow_fail: true)
501
+ set -e
502
+ cd #{Shellwords.escape(ctx.app_dir)}/release
503
+ #{env_source_sh('.env')}
504
+ bash #{Shellwords.escape(REMOTE_AFTER_HOOK)}
505
+ SH
506
+ return if ok
507
+ raise Error.new(
508
+ "#{REMOTE_AFTER_HOOK} failed.\n" \
509
+ " Active release remains live at #{ctx.app_dir}/release on #{ctx.host}; no automatic rollback was attempted."
510
+ )
511
+ end
512
+
513
+ # Post-deploy local hook. Runs in the project root after the remote deploy
514
+ # succeeded and the manifest is written - notifications, cleanup of local
515
+ # build artifacts produced by local_before. Optional - announced as "(not
516
+ # defined, skipping)" when absent. Non-zero exit warns (deploy is live).
517
+ def run_local_after_hook(ctx, strict: false)
518
+ unless File.exist?(LOCAL_AFTER_HOOK)
519
+ step "run #{LOCAL_AFTER_HOOK} (not defined, skipping)"
520
+ return
521
+ end
522
+ step "run #{LOCAL_AFTER_HOOK} (local)"
523
+ if ctx.ssh.dry_run
524
+ $stderr.puts " [dry] bash #{LOCAL_AFTER_HOOK}"
525
+ return
526
+ end
527
+ return if system('bash', LOCAL_AFTER_HOOK)
528
+ msg = "#{LOCAL_AFTER_HOOK} failed"
529
+ strict ? raise(Error.new(msg)) : warn("#{msg} but deploy is already live; continuing")
530
+ end
531
+
532
+ # Build + upload the post-deploy snapshot to <app_dir>/lux-deploy.yaml.
533
+ # Same atomic write pattern as upload_artifacts. 0644 so any user on
534
+ # the box can read it (it never contains secrets).
535
+ def upload_manifest(ctx)
536
+ body = Manifest.render(ctx)
537
+ remote = "#{ctx.app_dir}/#{Manifest::FILENAME}"
538
+ b64 = [body].pack('m0')
539
+ ctx.ssh.run(<<~SH, as: :service)
540
+ echo #{Shellwords.escape(b64)} | base64 -d > #{Shellwords.escape(remote)}.new
541
+ mv #{Shellwords.escape(remote)}.new #{Shellwords.escape(remote)}
542
+ chmod 0644 #{Shellwords.escape(remote)}
543
+ SH
544
+ end
545
+
546
+ def install_system_symlinks(ctx)
547
+ lines = ["install -d #{CADDY_SITES} #{SYSTEMD_DIR}"]
548
+ ctx.services.each do |s|
549
+ lines << "ln -sfn #{Shellwords.escape(ctx.app_dir)}/#{s.artifact} #{SYSTEMD_DIR}/#{s.unit}.service"
550
+ end
551
+ lines << "ln -sfn #{Shellwords.escape(ctx.app_dir)}/caddy.config #{CADDY_SITES}/#{ctx.app}.caddy"
552
+ ctx.ssh.run(lines.join("\n"))
553
+ end
554
+ end
555
+ end
@@ -0,0 +1,62 @@
1
+ require 'yaml'
2
+
3
+ module LuxDeploy
4
+ # Single source of behavior for a deploy. Reads ./config/deploy/.yaml and
5
+ # layers it on top of (a) engine defaults baked into this file and
6
+ # (b) host-supplied defaults from LuxDeploy.defaults (set by a wrapping
7
+ # plugin/Hammerfile, e.g. lux-fw injects 'lux-web' / 'lux-apps').
8
+ #
9
+ # Precedence (highest wins): user .yaml > LuxDeploy.defaults > ENGINE_DEFAULTS.
10
+ class Config
11
+ ENGINE_DEFAULTS ||= {
12
+ 'service_user' => 'deployer',
13
+ 'remote_base' => '/home/deployer/apps',
14
+ 'service_prefix' => 'web',
15
+ 'job_service_prefix' => nil
16
+ }.freeze
17
+
18
+ # Keys whose meaning is interpreted in Ruby - excluded from the
19
+ # `template_vars` map so they never become `{{SERVICE_PREFIX}}` etc.
20
+ # `server` is also behavioral (target host) but historically exposed
21
+ # as `{{SERVER}}` in caddy.conf, so it stays in template_vars.
22
+ BEHAVIORAL_KEYS ||= %w[service_user remote_base service_prefix
23
+ job_service_prefix flavor].freeze
24
+
25
+ attr_reader :raw
26
+
27
+ def self.load
28
+ file = './config/deploy/.yaml'
29
+ raise Error.new("missing #{file} (server: + domain: keys)") unless File.exist?(file)
30
+ data = YAML.safe_load(File.read(file)) || {}
31
+ raise Error.new("#{file} must be a YAML mapping") unless data.is_a?(Hash)
32
+ new(data)
33
+ end
34
+
35
+ def initialize(raw)
36
+ stringified = raw.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
37
+ @raw = ENGINE_DEFAULTS.merge(LuxDeploy.defaults).merge(stringified)
38
+ end
39
+
40
+ def server ; raw['server'].to_s.strip ; end
41
+ def domain ; raw['domain'].to_s.strip ; end
42
+ def service_user ; raw['service_user'].to_s ; end
43
+ def remote_base ; raw['remote_base'].to_s ; end
44
+ def service_prefix ; raw['service_prefix'].to_s ; end
45
+ # Deprecated since 0.2.0 - services are now discovered from *.service
46
+ # files (a `job.service` deploys as <service_prefix>-<app>-job). Kept so
47
+ # an old .yaml that still sets it parses without error; no longer wired.
48
+ def job_service_prefix ; v = raw['job_service_prefix']; v.to_s.empty? ? nil : v.to_s ; end
49
+
50
+ # Hash of UPPER_SYMBOL => string suitable for Template.render. Drops
51
+ # behavioral keys (service_prefix etc.) so they don't pollute the
52
+ # placeholder namespace. Nil values dropped so doctor's check treats
53
+ # them as missing rather than blank.
54
+ def template_vars
55
+ raw.each_with_object({}) do |(k, v), h|
56
+ next if v.nil?
57
+ next if BEHAVIORAL_KEYS.include?(k.to_s)
58
+ h[k.to_s.upcase.to_sym] = v.to_s
59
+ end
60
+ end
61
+ end
62
+ end