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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/AGENTS.md +57 -41
- data/README.md +47 -44
- data/lib/hammer/builtins.rb +40 -33
- data/lib/hammer/dotenv.rb +3 -2
- data/lib/hammer/loader.rb +13 -7
- data/lib/hammer/option.rb +2 -0
- data/lib/hammer/parser.rb +40 -10
- data/lib/hammer/recipe.rb +2 -2
- data/lib/hammer/shell.rb +5 -2
- data/lib/lux-hammer.rb +113 -58
- data/recipes/deploy.rb +32 -0
- data/recipes/lib/deploy/boot.rb +52 -0
- data/recipes/lib/deploy/commands.rb +555 -0
- data/recipes/lib/deploy/config.rb +62 -0
- data/recipes/lib/deploy/context.rb +149 -0
- data/recipes/lib/deploy/doctor.rb +238 -0
- data/recipes/lib/deploy/hammer.rb +168 -0
- data/recipes/lib/deploy/manifest.rb +169 -0
- data/recipes/lib/deploy/ssh.rb +129 -0
- data/recipes/lib/deploy/template.rb +39 -0
- metadata +12 -2
|
@@ -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
|