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,149 @@
|
|
|
1
|
+
module LuxDeploy
|
|
2
|
+
# Bag of resolved state for a single command invocation.
|
|
3
|
+
class Context
|
|
4
|
+
# A deployable unit. systemd.service is the web service (caddy-fronted);
|
|
5
|
+
# any other config/deploy/<name>.service becomes <prefix>-<app>-<name>.
|
|
6
|
+
Service ||= Struct.new(:name, :template, :artifact, :unit, :web)
|
|
7
|
+
|
|
8
|
+
attr_reader :host, :ssh, :branch, :app, :app_dir, :config_dir,
|
|
9
|
+
:env_template_name, :config, :templates_dir
|
|
10
|
+
attr_accessor :ports, :domain, :rendered
|
|
11
|
+
|
|
12
|
+
# Primary web port. Convenience for the manifest / done line; the full
|
|
13
|
+
# set (PORT, PORT_*) lives in #ports.
|
|
14
|
+
def port
|
|
15
|
+
ports && ports[:PORT]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ruby_path
|
|
19
|
+
@ruby_path ||= detect_ruby_path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# All template-substitution vars come from two places: git-derived and
|
|
23
|
+
# yaml. Engine-dynamic vars (PORT, DIR, RUBY, RUBY_DIR) layer on top in
|
|
24
|
+
# render_artifacts since they are only known after server probe. The
|
|
25
|
+
# rendered .env never feeds back into this namespace - .env is a
|
|
26
|
+
# runtime-only file the app reads at boot.
|
|
27
|
+
def base_vars
|
|
28
|
+
Template.git_vars.merge(config.template_vars).merge(
|
|
29
|
+
APP: @app,
|
|
30
|
+
APP_UNDERSCORE: @app.gsub(/[^A-Za-z0-9]+/, '_'),
|
|
31
|
+
HASH: domain_hash,
|
|
32
|
+
TAG: domain_tag
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def domain_hash
|
|
37
|
+
require 'digest'
|
|
38
|
+
"h#{Digest::SHA256.hexdigest(@domain.to_s)[0, 6]}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def domain_tag
|
|
42
|
+
require 'digest'
|
|
43
|
+
"s#{Digest::SHA256.hexdigest(@domain.to_s)[0, 5]}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.read_host(opts)
|
|
47
|
+
override = opts[:server]
|
|
48
|
+
return override.to_s.strip if override && !override.to_s.strip.empty?
|
|
49
|
+
Config.load.server.tap do |host|
|
|
50
|
+
raise Error.new("config/deploy/.yaml: 'server:' is empty") if host.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.build(opts, render: true)
|
|
55
|
+
ctx = new
|
|
56
|
+
ctx.send(:resolve!, opts)
|
|
57
|
+
ctx
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Raw template body, or nil when the file exists in neither the app's
|
|
61
|
+
# config/deploy nor the plugin templates_dir. Non-raising twin of
|
|
62
|
+
# read_template - used for cheap scans (port keys, {{RUBY}} presence).
|
|
63
|
+
def template_source(name)
|
|
64
|
+
local = File.join(@config_dir, name)
|
|
65
|
+
return File.read(local) if File.exist?(local)
|
|
66
|
+
if templates_dir
|
|
67
|
+
shipped = File.join(templates_dir, name)
|
|
68
|
+
return File.read(shipped) if File.exist?(shipped)
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Look up a template by name. Apps override individual files by dropping
|
|
74
|
+
# them in ./config/deploy/<name>; if missing, fall back to the host-
|
|
75
|
+
# supplied templates_dir (set via Hammer.register), if any.
|
|
76
|
+
def read_template(name)
|
|
77
|
+
template_source(name) or
|
|
78
|
+
raise Error.new("template not found: #{name} (looked in #{@config_dir}" \
|
|
79
|
+
"#{templates_dir ? " and #{templates_dir}" : ''})")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Services discovered from config/deploy/*.service. systemd.service is
|
|
83
|
+
# always present (web, caddy front); every other file is an extra unit.
|
|
84
|
+
# A leading "!" disables a file (e.g. !job.service) - lux-deploy ignores
|
|
85
|
+
# it everywhere (not a service, not rsync'd). Pure filesystem lookup -
|
|
86
|
+
# no ssh - so it works in render:false (destroy).
|
|
87
|
+
def services
|
|
88
|
+
@services ||= begin
|
|
89
|
+
prefix = config.service_prefix
|
|
90
|
+
list = [Service.new('web', 'systemd.service', 'systemd.service',
|
|
91
|
+
"#{prefix}-#{app}", true)]
|
|
92
|
+
Dir.children(@config_dir).select { |f| f.end_with?('.service') && !f.start_with?('!') }.sort.each do |f|
|
|
93
|
+
next if f == 'systemd.service'
|
|
94
|
+
name = f.sub(/\.service\z/, '')
|
|
95
|
+
list << Service.new(name, f, "systemd.#{name}.service",
|
|
96
|
+
"#{prefix}-#{app}-#{name}", false)
|
|
97
|
+
end
|
|
98
|
+
list
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# True when any rendered template references {{RUBY}}/{{RUBY_DIR}}. Gates
|
|
103
|
+
# the (ssh) ruby probe so a Go/Python app whose unit runs a built binary
|
|
104
|
+
# deploys without a ruby on the box.
|
|
105
|
+
def ruby_used?
|
|
106
|
+
return @ruby_used unless @ruby_used.nil?
|
|
107
|
+
names = (['caddy.conf', env_template_name] + services.map(&:template)).uniq
|
|
108
|
+
@ruby_used = names.any? { |n| (s = template_source(n)) && s.include?('{{RUBY') }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def resolve!(opts)
|
|
114
|
+
@config_dir = './config/deploy'
|
|
115
|
+
raise Error.new("missing #{@config_dir}/ directory") unless Dir.exist?(@config_dir)
|
|
116
|
+
|
|
117
|
+
@config = Config.load
|
|
118
|
+
@templates_dir = opts[:templates_dir]
|
|
119
|
+
|
|
120
|
+
@host = (opts[:server].to_s.strip.empty? ? @config.server : opts[:server]).to_s.strip
|
|
121
|
+
raise Error.new("no server set (.yaml 'server:' or --server)") if @host.empty?
|
|
122
|
+
|
|
123
|
+
@ssh = SSH.new(@host, service_user: @config.service_user, dry_run: opts[:dry_run] || false)
|
|
124
|
+
@branch = Template.git_vars[:GIT_BRANCH]
|
|
125
|
+
@env_template_name = LuxDeploy::MAIN_BRANCHES.include?(@branch) ? '.env' : '.env.staging'
|
|
126
|
+
|
|
127
|
+
# App slug comes from yaml `domain:` only. Multi-host strings like
|
|
128
|
+
# "foo.com, *.foo" are allowed; the slug is the first comma-split
|
|
129
|
+
# segment with any leading "*." stripped.
|
|
130
|
+
raw = @config.domain.to_s
|
|
131
|
+
raise Error.new("config/deploy/.yaml: 'domain:' is empty") if raw.strip.empty?
|
|
132
|
+
domain = raw.split(',').first.to_s.strip.sub(/^\*\./, '')
|
|
133
|
+
raise Error.new('domain resolved to empty') if domain.empty?
|
|
134
|
+
@app = domain
|
|
135
|
+
@domain = domain
|
|
136
|
+
@app_dir = File.join(@config.remote_base, domain)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def detect_ruby_path
|
|
140
|
+
return "/home/#{config.service_user}/.local/share/mise/installs/ruby/CURRENT/bin/ruby" if @ssh.dry_run
|
|
141
|
+
out = @ssh.run(<<~SH, as: :service, allow_fail: true)
|
|
142
|
+
ls -td ~/.local/share/mise/installs/ruby/*/bin/ruby 2>/dev/null | head -n1 || which ruby
|
|
143
|
+
SH
|
|
144
|
+
path = out.lines.find { |l| l.strip.start_with?('/') }&.strip
|
|
145
|
+
raise Error.new("no ruby found on remote (mise not installed for #{config.service_user}?)") if path.to_s.empty?
|
|
146
|
+
path
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
3
|
+
module LuxDeploy
|
|
4
|
+
# Named host checks: each entry is [label, check_cmd, fix_cmd_or_nil].
|
|
5
|
+
# `check_cmd` must exit 0 on PASS, non-zero on FAIL.
|
|
6
|
+
# `fix_cmd` is optional; if present and check fails, we run it (when --fix)
|
|
7
|
+
# and re-check. Anything that needs interactive judgement leaves fix_cmd nil.
|
|
8
|
+
module Doctor
|
|
9
|
+
GREEN ||= "\e[32m"
|
|
10
|
+
RED ||= "\e[31m"
|
|
11
|
+
DIM ||= "\e[2m"
|
|
12
|
+
RESET ||= "\e[0m"
|
|
13
|
+
|
|
14
|
+
# Placeholders the engine always provides at deploy time; templates
|
|
15
|
+
# may reference these without declaring them in .env or .yaml.
|
|
16
|
+
PROVIDED_VARS ||= %w[
|
|
17
|
+
GIT_BRANCH GIT_BRANCH_UNDERSCORE APP APP_UNDERSCORE HASH TAG
|
|
18
|
+
PORT DIR RUBY RUBY_DIR
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def run(ssh, config, fix: true)
|
|
24
|
+
puts 'Local config'
|
|
25
|
+
local_failed = local_checks(config)
|
|
26
|
+
puts
|
|
27
|
+
|
|
28
|
+
puts 'Remote host'
|
|
29
|
+
checks = build_checks(config)
|
|
30
|
+
failed = local_failed
|
|
31
|
+
|
|
32
|
+
checks.each do |label, check_cmd, fix_cmd|
|
|
33
|
+
if passes?(ssh, check_cmd)
|
|
34
|
+
puts " #{GREEN}PASS#{RESET} #{label}"
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if fix && fix_cmd
|
|
39
|
+
puts " #{DIM}FIX#{RESET} #{label}"
|
|
40
|
+
ssh.run(fix_cmd, allow_fail: true)
|
|
41
|
+
if passes?(ssh, check_cmd)
|
|
42
|
+
puts " #{GREEN}PASS#{RESET} #{label} (after fix)"
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
failed += 1
|
|
48
|
+
puts " #{RED}FAIL#{RESET} #{label}"
|
|
49
|
+
puts " #{DIM} check: #{check_cmd.lines.first.chomp}#{RESET}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
puts
|
|
53
|
+
if failed.zero?
|
|
54
|
+
puts "#{GREEN}all checks passed#{RESET}"
|
|
55
|
+
else
|
|
56
|
+
puts "#{RED}#{failed} check#{failed == 1 ? '' : 's'} failed#{RESET}"
|
|
57
|
+
raise Error.new("doctor reported #{failed} failure(s)")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Run check command, return true on exit 0.
|
|
62
|
+
def passes?(ssh, cmd)
|
|
63
|
+
out = ssh.run(cmd + ' && echo __OK__ || echo __FAIL__', allow_fail: true)
|
|
64
|
+
out.include?('__OK__')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Lifecycle hooks (run during `up` if present in config/deploy/).
|
|
68
|
+
# Optional - doctor reports their presence/absence, never creates.
|
|
69
|
+
# Scaffolds with explanatory header comments ship via `app:init`.
|
|
70
|
+
HOOK_FILES ||= %w[local_before.sh remote_before.sh remote_after.sh local_after.sh].freeze
|
|
71
|
+
|
|
72
|
+
# True when any local config/deploy template references {{RUBY}}. Gates
|
|
73
|
+
# the ruby/bundler host checks so a Go/Python app doesn't fail doctor.
|
|
74
|
+
def ruby_runtime?(dir = './config/deploy')
|
|
75
|
+
Dir.glob("#{dir}/*").any? { |f| File.file?(f) && File.read(f).include?('{{RUBY') }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def local_checks(_config)
|
|
79
|
+
dir = './config/deploy'
|
|
80
|
+
failed = 0
|
|
81
|
+
|
|
82
|
+
report = ->(ok, label, detail = nil) {
|
|
83
|
+
if ok
|
|
84
|
+
puts " #{GREEN}PASS#{RESET} #{label}"
|
|
85
|
+
else
|
|
86
|
+
failed += 1
|
|
87
|
+
puts " #{RED}FAIL#{RESET} #{label}"
|
|
88
|
+
puts " #{DIM} #{detail}#{RESET}" if detail
|
|
89
|
+
end
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
skip = ->(label) { puts " #{DIM}SKIP #{label} (does not exist)#{RESET}" }
|
|
93
|
+
|
|
94
|
+
unless Dir.exist?(dir)
|
|
95
|
+
report.call(false, "#{dir}/ directory present",
|
|
96
|
+
"missing; run: deploy app:init")
|
|
97
|
+
return failed
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Parse .yaml; bail early if absent or malformed since other
|
|
101
|
+
# checks depend on its keys.
|
|
102
|
+
yaml_path = "#{dir}/.yaml"
|
|
103
|
+
yaml_data = nil
|
|
104
|
+
if File.exist?(yaml_path)
|
|
105
|
+
report.call(true, "#{yaml_path} present")
|
|
106
|
+
begin
|
|
107
|
+
yaml_data = YAML.safe_load(File.read(yaml_path)) || {}
|
|
108
|
+
report.call(yaml_data.is_a?(Hash), "#{yaml_path} is a YAML mapping")
|
|
109
|
+
yaml_data = {} unless yaml_data.is_a?(Hash)
|
|
110
|
+
report.call(!yaml_data['server'].to_s.strip.empty?, ".yaml 'server:' set")
|
|
111
|
+
report.call(!yaml_data['domain'].to_s.strip.empty?, ".yaml 'domain:' set")
|
|
112
|
+
rescue Psych::SyntaxError => e
|
|
113
|
+
report.call(false, "#{yaml_path} parses", e.message)
|
|
114
|
+
yaml_data = {}
|
|
115
|
+
end
|
|
116
|
+
else
|
|
117
|
+
report.call(false, "#{yaml_path} present", "missing; run: deploy app:init")
|
|
118
|
+
yaml_data = {}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
report.call(File.exist?("#{dir}/.env"), "#{dir}/.env present")
|
|
122
|
+
report.call(File.exist?("#{dir}/caddy.conf"), "#{dir}/caddy.conf present")
|
|
123
|
+
report.call(File.exist?("#{dir}/systemd.service"), "#{dir}/systemd.service present")
|
|
124
|
+
|
|
125
|
+
# Lifecycle hooks - optional. Report each as PASS (present, will run)
|
|
126
|
+
# or SKIP (absent, hook step will be skipped during `up`).
|
|
127
|
+
HOOK_FILES.each do |name|
|
|
128
|
+
path = "#{dir}/#{name}"
|
|
129
|
+
if File.exist?(path)
|
|
130
|
+
report.call(true, "#{path} present (will run)")
|
|
131
|
+
else
|
|
132
|
+
skip.call("#{path} (lifecycle hook)")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
yaml_keys = yaml_data.keys.map { |k| k.to_s.upcase }
|
|
137
|
+
|
|
138
|
+
# Every template is rendered with the same vars: engine-provided +
|
|
139
|
+
# yaml. The rendered .env is uploaded verbatim; it never feeds back
|
|
140
|
+
# into the template namespace, so caddy.conf / *.service can only
|
|
141
|
+
# reference yaml keys + engine vars (not anything defined in .env).
|
|
142
|
+
# Every *.service file is a service; only systemd.service is required.
|
|
143
|
+
# PORT-prefixed placeholders are engine-provided (auto-allocated).
|
|
144
|
+
service_files = Dir.children(dir).select { |f| f.end_with?('.service') && !f.start_with?('!') }.sort
|
|
145
|
+
optional = %w[.env.staging] + (service_files - %w[systemd.service])
|
|
146
|
+
placeholder_targets = %w[.env .env.staging caddy.conf] + service_files
|
|
147
|
+
provided = PROVIDED_VARS + yaml_keys
|
|
148
|
+
placeholder_targets.uniq.each do |name|
|
|
149
|
+
path = "#{dir}/#{name}"
|
|
150
|
+
unless File.exist?(path)
|
|
151
|
+
skip.call(name) if optional.include?(name)
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
placeholders = File.read(path).scan(Template::PLACEHOLDER).flatten.uniq
|
|
156
|
+
missing = placeholders.reject { |v| provided.include?(v) || v.start_with?('PORT') }
|
|
157
|
+
ok = missing.empty?
|
|
158
|
+
report.call(ok, "#{name}: placeholders resolve",
|
|
159
|
+
ok ? nil : "missing: #{missing.map { |v| "{{#{v}}}" }.join(' ')}")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
failed
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def build_checks(config)
|
|
166
|
+
user = config.service_user
|
|
167
|
+
base = config.remote_base
|
|
168
|
+
[
|
|
169
|
+
[
|
|
170
|
+
"#{user} user exists",
|
|
171
|
+
"id #{user} >/dev/null 2>&1",
|
|
172
|
+
"useradd -m -s /bin/bash #{user}"
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
"/home/#{user} is traversable (0755)",
|
|
176
|
+
# caddy + systemd need to read symlinks into ~/<base>;
|
|
177
|
+
# useradd defaults home to 0700 on Debian, which blocks them.
|
|
178
|
+
"[ \"$(stat -c %a /home/#{user})\" = 755 ]",
|
|
179
|
+
"chmod 0755 /home/#{user}"
|
|
180
|
+
],
|
|
181
|
+
[
|
|
182
|
+
"#{user} in sudo group (passwordless)",
|
|
183
|
+
"grep -q '^#{user} ' /etc/sudoers.d/#{user} 2>/dev/null && grep -q NOPASSWD /etc/sudoers.d/#{user}",
|
|
184
|
+
"echo '#{user} ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/#{user} && chmod 0440 /etc/sudoers.d/#{user}"
|
|
185
|
+
],
|
|
186
|
+
[
|
|
187
|
+
"#{base} exists and owned by #{user}",
|
|
188
|
+
"[ -d #{base} ] && [ \"$(stat -c %U #{base})\" = #{user} ]",
|
|
189
|
+
"install -d -o #{user} -g #{user} -m 0755 #{base}"
|
|
190
|
+
],
|
|
191
|
+
[
|
|
192
|
+
'/etc/caddy/sites exists',
|
|
193
|
+
"[ -d #{CADDY_SITES} ]",
|
|
194
|
+
"install -d -m 0755 #{CADDY_SITES}"
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
'caddy running',
|
|
198
|
+
'systemctl is-active --quiet caddy',
|
|
199
|
+
nil
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
"caddy imports #{CADDY_SITES}/*.caddy",
|
|
203
|
+
"grep -Rq 'import #{CADDY_SITES}/\\*.caddy' /etc/caddy/ 2>/dev/null",
|
|
204
|
+
nil
|
|
205
|
+
],
|
|
206
|
+
[
|
|
207
|
+
"mise installed for #{user}",
|
|
208
|
+
"sudo -iu #{user} bash -lc 'command -v mise >/dev/null'",
|
|
209
|
+
nil
|
|
210
|
+
],
|
|
211
|
+
# Ruby/bundler only matter when a template references {{RUBY}};
|
|
212
|
+
# a Go/Python app builds in remote_before and needs neither.
|
|
213
|
+
*(ruby_runtime? ? [
|
|
214
|
+
[
|
|
215
|
+
"ruby on #{user} PATH",
|
|
216
|
+
"sudo -iu #{user} bash -lc 'command -v ruby >/dev/null && ruby -v'",
|
|
217
|
+
nil
|
|
218
|
+
],
|
|
219
|
+
[
|
|
220
|
+
"bundler on #{user} PATH",
|
|
221
|
+
"sudo -iu #{user} bash -lc 'command -v bundle >/dev/null'",
|
|
222
|
+
"sudo -iu #{user} bash -lc 'gem install bundler --no-document'"
|
|
223
|
+
]
|
|
224
|
+
] : []),
|
|
225
|
+
[
|
|
226
|
+
'xcaddy available (for plugin rebuilds)',
|
|
227
|
+
'command -v xcaddy >/dev/null',
|
|
228
|
+
nil
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
'ssh password auth disabled (warn-only)',
|
|
232
|
+
"sshd -T 2>/dev/null | grep -qx 'passwordauthentication no'",
|
|
233
|
+
nil
|
|
234
|
+
]
|
|
235
|
+
]
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module LuxDeploy
|
|
2
|
+
# Registers the lux-deploy Hammer tasks on a host CLI. Designed to be
|
|
3
|
+
# called from a Hammerfile (the gem's own, the lux-fw plugin's, or any
|
|
4
|
+
# user's) so all task definitions live in one place.
|
|
5
|
+
#
|
|
6
|
+
# Usage from a Hammerfile (block DSL):
|
|
7
|
+
#
|
|
8
|
+
# require 'lux_deploy'
|
|
9
|
+
# LuxDeploy::Hammer.register(self) # top-level
|
|
10
|
+
# LuxDeploy::Hammer.register(self, prefix: :deploy) # under deploy:
|
|
11
|
+
# LuxDeploy::Hammer.register(self,
|
|
12
|
+
# prefix: :deploy,
|
|
13
|
+
# templates_dir: '/abs/path/to/plugin/templates',
|
|
14
|
+
# defaults: {
|
|
15
|
+
# service_prefix: 'lux-web',
|
|
16
|
+
# remote_base: '/home/deployer/lux-apps',
|
|
17
|
+
# }
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# `defaults` is merged under user's .yaml (yaml wins). `templates_dir`
|
|
21
|
+
# is what `app:init` copies from and the deploy step falls back to
|
|
22
|
+
# when ./config/deploy/<name> is missing.
|
|
23
|
+
module Hammer
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def safe(opts)
|
|
27
|
+
yield
|
|
28
|
+
rescue LuxDeploy::Error => e
|
|
29
|
+
warn e.to_s
|
|
30
|
+
exit 1
|
|
31
|
+
rescue Interrupt
|
|
32
|
+
warn 'aborted'
|
|
33
|
+
exit 130
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def register(receiver, prefix: nil, templates_dir: nil, defaults: nil)
|
|
37
|
+
LuxDeploy.set_defaults(defaults) if defaults
|
|
38
|
+
tdir = templates_dir
|
|
39
|
+
if prefix
|
|
40
|
+
receiver.namespace(prefix) { LuxDeploy::Hammer.define_on(self, tdir) }
|
|
41
|
+
else
|
|
42
|
+
define_on(receiver, tdir)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `target` must respond to `task` and `namespace` (Builder or Hammer
|
|
47
|
+
# subclass). `tdir` is captured by task closures so `app:init` and
|
|
48
|
+
# deploy template fallback both see it.
|
|
49
|
+
def define_on(target, tdir)
|
|
50
|
+
target.task :up do
|
|
51
|
+
desc 'Deploy current branch (rsync, hooks, swap, restart)'
|
|
52
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
53
|
+
opt :dry_run, type: :boolean, default: false, desc: 'Print commands, do not execute'
|
|
54
|
+
|
|
55
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.up(opts.merge(templates_dir: tdir)) } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
target.task :redeploy do
|
|
59
|
+
desc 'Destroy then deploy (fresh PORT, blank release history)'
|
|
60
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
61
|
+
opt :yes, type: :boolean, default: false, desc: 'Skip destroy confirmation'
|
|
62
|
+
opt :dry_run, type: :boolean, default: false, desc: 'Print commands, do not execute'
|
|
63
|
+
|
|
64
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.redeploy(opts.merge(templates_dir: tdir)) } }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
target.task :destroy do
|
|
68
|
+
desc 'Stop service, unlink caddy/systemd, remove app dir'
|
|
69
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
70
|
+
opt :yes, type: :boolean, default: false, desc: 'Skip type-domain-to-confirm prompt'
|
|
71
|
+
opt :dry_run, type: :boolean, default: false, desc: 'Print commands, do not execute'
|
|
72
|
+
|
|
73
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.destroy(opts.merge(templates_dir: tdir)) } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
target.task :doctor do
|
|
77
|
+
desc 'Check & prepare host: service user, dirs, caddy, ruby, bundler'
|
|
78
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
79
|
+
opt :fix, type: :boolean, default: true, desc: 'Auto-fix safe items (default true; --no-fix to skip)'
|
|
80
|
+
|
|
81
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.doctor(opts) } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
target.task :log do
|
|
85
|
+
desc 'List server logs, or dump one with --log <name> (--lines 200)'
|
|
86
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
87
|
+
opt :log, desc: 'Log name to dump (e.g. errors, exceptions); omit to list all logs'
|
|
88
|
+
opt :lines, default: 200, desc: 'Lines to show when dumping a log (default 200)'
|
|
89
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.log(opts.merge(templates_dir: tdir)) } }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
target.namespace(:app) { LuxDeploy::Hammer.define_app_on(self, tdir) }
|
|
93
|
+
target.namespace(:server) { LuxDeploy::Hammer.define_server_on(self, tdir) }
|
|
94
|
+
target.namespace(:on) { LuxDeploy::Hammer.define_on_hooks_on(self, tdir) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def define_on_hooks_on(target, tdir)
|
|
98
|
+
target.namespace(:remote) do
|
|
99
|
+
LuxDeploy::Hammer.define_hook_task_on(self, :before, :remote, :before, tdir, 'Run config/deploy/remote_before.sh on new-release/')
|
|
100
|
+
LuxDeploy::Hammer.define_hook_task_on(self, :after, :remote, :after, tdir, 'Run config/deploy/remote_after.sh on release/')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
target.namespace(:local) do
|
|
104
|
+
LuxDeploy::Hammer.define_hook_task_on(self, :before, :local, :before, tdir, 'Run config/deploy/local_before.sh locally')
|
|
105
|
+
LuxDeploy::Hammer.define_hook_task_on(self, :after, :local, :after, tdir, 'Run config/deploy/local_after.sh locally')
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def define_hook_task_on(target, task_name, side, timing, tdir, description)
|
|
110
|
+
target.task task_name do
|
|
111
|
+
desc description
|
|
112
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
113
|
+
opt :dry_run, type: :boolean, default: false, desc: 'Print commands, do not execute'
|
|
114
|
+
|
|
115
|
+
proc { |opts|
|
|
116
|
+
LuxDeploy::Hammer.safe(opts) {
|
|
117
|
+
LuxDeploy::Commands.hook(opts.merge(templates_dir: tdir), side, timing)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def define_app_on(target, tdir)
|
|
124
|
+
target.task :init do
|
|
125
|
+
desc 'Copy shipped templates into ./config/deploy/ (skips existing files)'
|
|
126
|
+
opt :from, desc: 'Override templates directory (default: caller-provided or bundled)'
|
|
127
|
+
proc { |opts|
|
|
128
|
+
LuxDeploy::Hammer.safe(opts) {
|
|
129
|
+
LuxDeploy::Commands.init(opts.merge(templates_dir: opts[:from] || tdir))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def define_server_on(target, tdir)
|
|
136
|
+
target.task :ssh do
|
|
137
|
+
desc 'SSH into the release folder as the service user'
|
|
138
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
139
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.server_ssh(opts.merge(templates_dir: tdir)) } }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
target.task :log do
|
|
143
|
+
desc 'Tail systemd journal for the web service (-f, last 200)'
|
|
144
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
145
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.server_log(opts.merge(templates_dir: tdir)) } }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
target.task :errors do
|
|
149
|
+
desc 'Tail -f the app error log (release/log/error.log)'
|
|
150
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
151
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.server_errors(opts.merge(templates_dir: tdir)) } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
target.task :restart do
|
|
155
|
+
desc 'systemctl restart the web service'
|
|
156
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
157
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.server_restart(opts.merge(templates_dir: tdir)) } }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
target.task :status do
|
|
161
|
+
desc 'systemctl status the web service'
|
|
162
|
+
opt :server, desc: 'Override hostname from config/deploy/.yaml'
|
|
163
|
+
proc { |opts| LuxDeploy::Hammer.safe(opts) { LuxDeploy::Commands.server_status(opts.merge(templates_dir: tdir)) } }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
end
|
|
168
|
+
end
|