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,169 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'socket'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module LuxDeploy
|
|
6
|
+
# Snapshot of what is wired up on the host for one app after a successful
|
|
7
|
+
# deploy. Written to <app_dir>/lux-deploy.yaml mode 0644. Safe to read by
|
|
8
|
+
# humans, LLMs, monitoring scripts - never contains secrets.
|
|
9
|
+
module Manifest
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
FILENAME ||= 'lux-deploy.yaml'
|
|
13
|
+
|
|
14
|
+
# Keys whose values are stripped from the env section. Matched on the
|
|
15
|
+
# uppercased key name, substring (so SECRET, JWT_SECRET, DB_MAIN, DB_URL,
|
|
16
|
+
# DATABASE_URL, MY_API_TOKEN, AWS_ACCESS_KEY_ID all redact). As a backstop,
|
|
17
|
+
# any value carrying URL-embedded credentials (user:pass@) is redacted
|
|
18
|
+
# regardless of key name - see sensitive_value?.
|
|
19
|
+
SENSITIVE_KEY_PATTERNS ||= %w[SECRET PASSWORD TOKEN KEY DB_MAIN DB_URL DATABASE_URL CREDENTIAL].freeze
|
|
20
|
+
|
|
21
|
+
# Value carrying URL-embedded credentials, e.g. postgres://user:pass@host/db
|
|
22
|
+
# or password-only forms like redis://:pass@host (empty username).
|
|
23
|
+
CREDENTIAL_URL ||= %r{://[^/\s:@]*:[^/\s@]+@}
|
|
24
|
+
|
|
25
|
+
# Returns the YAML body ready to upload (with a header comment).
|
|
26
|
+
def render(ctx)
|
|
27
|
+
header = <<~TXT
|
|
28
|
+
# Generated by lux-deploy #{LuxDeploy::VERSION} on every successful deploy.
|
|
29
|
+
# Snapshot of what is currently wired up on this host for this app.
|
|
30
|
+
# Safe to read; do not edit (next deploy overwrites).
|
|
31
|
+
TXT
|
|
32
|
+
header + YAML.dump(stringify(build(ctx)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build(ctx)
|
|
36
|
+
# Domains come from yaml `domain:` (the sole source of truth post
|
|
37
|
+
# .env/.yaml split). env_snapshot is a redacted snapshot of the
|
|
38
|
+
# rendered .env contents - informational only, never used for
|
|
39
|
+
# template substitution.
|
|
40
|
+
domains = ctx.config.domain.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
41
|
+
env_snapshot = Template.parse_env(ctx.rendered['.env'])
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
deployed_at: Time.now.utc.iso8601,
|
|
45
|
+
app: ctx.app,
|
|
46
|
+
domain: ctx.domain,
|
|
47
|
+
domains: domains,
|
|
48
|
+
url: "https://#{ctx.domain}",
|
|
49
|
+
git: git_info(ctx.branch),
|
|
50
|
+
deploy: {
|
|
51
|
+
triggered_by: triggered_by,
|
|
52
|
+
host: ctx.host,
|
|
53
|
+
service_user: ctx.config.service_user,
|
|
54
|
+
app_dir: ctx.app_dir,
|
|
55
|
+
port: ctx.port,
|
|
56
|
+
ports: ctx.ports,
|
|
57
|
+
ruby: (ctx.ruby_used? ? ctx.ruby_path : nil)
|
|
58
|
+
},
|
|
59
|
+
paths: {
|
|
60
|
+
release: "#{ctx.app_dir}/release",
|
|
61
|
+
old_release: "#{ctx.app_dir}/old-release",
|
|
62
|
+
shared: "#{ctx.app_dir}/shared",
|
|
63
|
+
tmp: "#{ctx.app_dir}/shared/tmp",
|
|
64
|
+
log: "#{ctx.app_dir}/shared/log",
|
|
65
|
+
env_file: "#{ctx.app_dir}/.env",
|
|
66
|
+
manifest: "#{ctx.app_dir}/#{FILENAME}"
|
|
67
|
+
},
|
|
68
|
+
services: services_section(ctx),
|
|
69
|
+
caddy: {
|
|
70
|
+
site_link: "#{CADDY_SITES}/#{ctx.app}.caddy",
|
|
71
|
+
source: "#{ctx.app_dir}/caddy.config",
|
|
72
|
+
reload: 'systemctl reload caddy'
|
|
73
|
+
},
|
|
74
|
+
hooks: hooks_section,
|
|
75
|
+
env: env_section(env_snapshot),
|
|
76
|
+
artifacts: ctx.rendered.keys.sort,
|
|
77
|
+
engine: {
|
|
78
|
+
lux_deploy_version: LuxDeploy::VERSION,
|
|
79
|
+
service_prefix: ctx.config.service_prefix,
|
|
80
|
+
remote_base: ctx.config.remote_base
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# One block per discovered service (web + every extra *.service), keyed
|
|
86
|
+
# by service name. ExecStart is pulled from each rendered unit.
|
|
87
|
+
def services_section(ctx)
|
|
88
|
+
ctx.services.each_with_object({}) do |s, out|
|
|
89
|
+
out[s.name] = {
|
|
90
|
+
unit: "#{s.unit}.service",
|
|
91
|
+
systemd_link: "#{SYSTEMD_DIR}/#{s.unit}.service",
|
|
92
|
+
source: "#{ctx.app_dir}/#{s.artifact}",
|
|
93
|
+
exec_start: extract_directive(ctx.rendered[s.artifact], 'ExecStart'),
|
|
94
|
+
restart: "systemctl restart #{s.unit}",
|
|
95
|
+
logs: "journalctl -u #{s.unit} -n 200 -f"
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Lifecycle hook presence. Reflects what was on the local repo at
|
|
101
|
+
# deploy time - local_* hooks run on your machine, remote_* ride along
|
|
102
|
+
# with the rsync and run on the server.
|
|
103
|
+
def hooks_section
|
|
104
|
+
{
|
|
105
|
+
local_before: File.exist?(Commands::LOCAL_BEFORE_HOOK) ? Commands::LOCAL_BEFORE_HOOK : nil,
|
|
106
|
+
remote_before: File.exist?(Commands::REMOTE_BEFORE_HOOK) ? Commands::REMOTE_BEFORE_HOOK : nil,
|
|
107
|
+
remote_after: File.exist?(Commands::REMOTE_AFTER_HOOK) ? Commands::REMOTE_AFTER_HOOK : nil,
|
|
108
|
+
local_after: File.exist?(Commands::LOCAL_AFTER_HOOK) ? Commands::LOCAL_AFTER_HOOK : nil
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Non-secret env values; secret keys (see SENSITIVE_KEY_PATTERNS) keep
|
|
113
|
+
# the key but the value is replaced with '<redacted>' so the LLM still
|
|
114
|
+
# sees the variable exists.
|
|
115
|
+
def env_section(env)
|
|
116
|
+
env.each_with_object({}) do |(k, v), h|
|
|
117
|
+
h[k] = (sensitive?(k) || sensitive_value?(v)) ? '<redacted>' : v
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def sensitive?(key)
|
|
122
|
+
up = key.to_s.upcase
|
|
123
|
+
SENSITIVE_KEY_PATTERNS.any? { |pat| up.include?(pat) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def sensitive_value?(value)
|
|
127
|
+
value.to_s.match?(CREDENTIAL_URL)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def git_info(branch)
|
|
131
|
+
{
|
|
132
|
+
branch: branch,
|
|
133
|
+
commit: capture('git rev-parse HEAD'),
|
|
134
|
+
commit_short: capture('git rev-parse --short HEAD'),
|
|
135
|
+
commit_subject: capture('git log -1 --pretty=%s'),
|
|
136
|
+
author: capture("git log -1 --pretty='%an <%ae>'"),
|
|
137
|
+
committed_at: capture('git log -1 --pretty=%cI')
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Pull a single `Key=value` line out of a rendered systemd unit. Returns
|
|
142
|
+
# nil when the directive isn't present, so the LLM can tell skip vs blank.
|
|
143
|
+
def extract_directive(unit_text, key)
|
|
144
|
+
return nil unless unit_text
|
|
145
|
+
line = unit_text.lines.find { |l| l =~ /\A#{Regexp.escape(key)}=/ }
|
|
146
|
+
line&.chomp&.sub(/\A#{Regexp.escape(key)}=/, '')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def triggered_by
|
|
150
|
+
user = ENV['USER'] || ENV['LOGNAME'] || 'unknown'
|
|
151
|
+
host = (Socket.gethostname rescue 'unknown')
|
|
152
|
+
"#{user}@#{host}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def capture(cmd)
|
|
156
|
+
out = `#{cmd} 2>/dev/null`.strip
|
|
157
|
+
out.empty? ? nil : out
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# YAML.dump prefers string keys for portability + readability.
|
|
161
|
+
def stringify(obj)
|
|
162
|
+
case obj
|
|
163
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
|
|
164
|
+
when Array then obj.map { |v| stringify(v) }
|
|
165
|
+
else obj
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
|
|
4
|
+
module LuxDeploy
|
|
5
|
+
# Always connects as root. To run as the service user pass `as: :service`
|
|
6
|
+
# which wraps the command in `sudo -iu <service_user> bash -lc <quoted>`
|
|
7
|
+
# so the login shell activates mise (PATH for ruby/bundler).
|
|
8
|
+
class SSH
|
|
9
|
+
attr_reader :host, :dry_run, :service_user
|
|
10
|
+
|
|
11
|
+
def initialize(host, service_user: 'deployer', dry_run: false)
|
|
12
|
+
raise Error.new('config/deploy/.yaml server: is empty') if host.to_s.strip.empty?
|
|
13
|
+
@host = host.to_s.strip.sub(/^.*@/, '')
|
|
14
|
+
@service_user = service_user
|
|
15
|
+
@dry_run = dry_run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Run a command. Returns stdout (always captured).
|
|
19
|
+
# On non-zero exit raises unless allow_fail: true (then returns whatever was captured).
|
|
20
|
+
def run(cmd, as: :root, allow_fail: false)
|
|
21
|
+
remote = wrap(cmd, as)
|
|
22
|
+
argv = ssh_argv + [remote]
|
|
23
|
+
log argv, cmd
|
|
24
|
+
return '' if dry_run
|
|
25
|
+
out, status = Open3.capture2e(*argv)
|
|
26
|
+
if !status.success? && !allow_fail
|
|
27
|
+
raise Error.new("ssh failed (exit #{status.exitstatus})\n--- remote stderr+stdout ---\n#{out}")
|
|
28
|
+
end
|
|
29
|
+
out
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Streamed run (stdout/stderr pass through). Use for long-running steps
|
|
33
|
+
# the user wants to watch (bundle install, verification hooks).
|
|
34
|
+
def stream(cmd, as: :root, allow_fail: false)
|
|
35
|
+
remote = wrap(cmd, as)
|
|
36
|
+
argv = ssh_argv + [remote]
|
|
37
|
+
log argv, cmd
|
|
38
|
+
return true if dry_run
|
|
39
|
+
ok = system(*argv)
|
|
40
|
+
raise Error.new("ssh failed: #{cmd}") if !ok && !allow_fail
|
|
41
|
+
ok
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# rsync local dir to remote path; runs receiver as service user via sudo.
|
|
45
|
+
def rsync(src, dest_path, excludes: [])
|
|
46
|
+
argv = [
|
|
47
|
+
'rsync', '-az', '--delete',
|
|
48
|
+
*excludes.flat_map { |e| ['--exclude', e] },
|
|
49
|
+
"--rsync-path=sudo -u #{service_user} rsync",
|
|
50
|
+
src, "root@#{host}:#{dest_path}"
|
|
51
|
+
]
|
|
52
|
+
log argv, "rsync #{src} -> #{dest_path}"
|
|
53
|
+
return if dry_run
|
|
54
|
+
system(*argv) or raise Error.new('rsync failed')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Interactive ssh that allocates a TTY and replaces the current process
|
|
58
|
+
# (via Process.exec). Use for shells, REPLs, psql - anything that needs
|
|
59
|
+
# job control. Does not return on success.
|
|
60
|
+
def exec(cmd, as: :root)
|
|
61
|
+
remote = wrap(cmd, as, interactive: true)
|
|
62
|
+
argv = ssh_argv(interactive: true) + [remote]
|
|
63
|
+
log argv, cmd
|
|
64
|
+
return if dry_run
|
|
65
|
+
Process.exec(*argv)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# scp a file from the remote (as root) to a local path.
|
|
69
|
+
def scp_from(remote_path, local_path)
|
|
70
|
+
argv = ['scp', '-o', 'StrictHostKeyChecking=accept-new',
|
|
71
|
+
"root@#{host}:#{remote_path}", local_path]
|
|
72
|
+
log argv, "scp #{remote_path} -> #{local_path}"
|
|
73
|
+
return if dry_run
|
|
74
|
+
system(*argv) or raise Error.new("scp failed: #{remote_path}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# scp a local file up to the remote (as root). Default umask leaves
|
|
78
|
+
# the file 0644 so the service user can read it from /tmp.
|
|
79
|
+
def scp_to(local_path, remote_path)
|
|
80
|
+
argv = ['scp', '-o', 'StrictHostKeyChecking=accept-new',
|
|
81
|
+
local_path, "root@#{host}:#{remote_path}"]
|
|
82
|
+
log argv, "scp #{local_path} -> #{remote_path}"
|
|
83
|
+
return if dry_run
|
|
84
|
+
system(*argv) or raise Error.new("scp failed: #{local_path}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def ssh_argv(interactive: false)
|
|
90
|
+
[
|
|
91
|
+
'ssh',
|
|
92
|
+
*(interactive ? ['-tt'] : ['-o', 'BatchMode=yes']),
|
|
93
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
94
|
+
'-o', 'ConnectTimeout=10',
|
|
95
|
+
"root@#{host}"
|
|
96
|
+
]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def wrap(cmd, as, interactive: false)
|
|
100
|
+
case as
|
|
101
|
+
when :root then cmd
|
|
102
|
+
when :service, :deployer
|
|
103
|
+
if interactive
|
|
104
|
+
# Interactive shells need a TTY on stdin, so we can't use the
|
|
105
|
+
# base64-pipe transport (it leaves bash reading from a closed pipe
|
|
106
|
+
# and the inner `exec bash -li` exits immediately). Single-line
|
|
107
|
+
# commands don't need the b64 dance anyway - just shell-escape.
|
|
108
|
+
"sudo -iu #{service_user} -- bash -lc #{Shellwords.escape(cmd)}"
|
|
109
|
+
else
|
|
110
|
+
# sudo -i backslash-escapes every shell metachar including newlines, so
|
|
111
|
+
# multi-line scripts get collapsed by the target shell (\<nl> = line
|
|
112
|
+
# continuation). Transport the script base64-encoded so no metachars
|
|
113
|
+
# survive into the service user's shell re-parse.
|
|
114
|
+
b64 = [cmd].pack('m0')
|
|
115
|
+
inner = "echo #{b64} | base64 -d | bash -l"
|
|
116
|
+
"sudo -iu #{service_user} bash -lc #{Shellwords.escape(inner)}"
|
|
117
|
+
end
|
|
118
|
+
else raise "unknown ssh user: #{as}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def log(_argv, summary)
|
|
123
|
+
prefix = dry_run ? ' [dry] ' : ' $ '
|
|
124
|
+
head = summary.lines.first.to_s.chomp
|
|
125
|
+
head += ' …' if summary.lines.count > 1
|
|
126
|
+
$stderr.puts "\e[2m#{prefix}#{head}\e[0m"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module LuxDeploy
|
|
2
|
+
# Stupid {{VAR}} substitution. No conditionals, no loops, no escaping.
|
|
3
|
+
# Missing variables raise so we never silently ship a template with
|
|
4
|
+
# an un-rendered placeholder.
|
|
5
|
+
module Template
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/
|
|
9
|
+
|
|
10
|
+
def render(str, vars)
|
|
11
|
+
norm = vars.transform_keys(&:to_s)
|
|
12
|
+
str.gsub(PLACEHOLDER) do
|
|
13
|
+
key = ::Regexp.last_match(1)
|
|
14
|
+
norm.key?(key) ? norm[key].to_s : raise(Error.new("missing var {{#{key}}}"))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Parse a rendered .env file into a symbol-keyed hash. Comments and
|
|
19
|
+
# blank lines are ignored; surrounding single/double quotes are stripped.
|
|
20
|
+
def parse_env(rendered)
|
|
21
|
+
rendered.lines.each_with_object({}) do |line, h|
|
|
22
|
+
next if line.match?(/\A\s*(#|$)/)
|
|
23
|
+
k, v = line.strip.split('=', 2)
|
|
24
|
+
next unless k && v
|
|
25
|
+
h[k.to_sym] = v.gsub(/\A["']|["']\z/, '')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Git-derived placeholders, computed locally before any rendering.
|
|
30
|
+
def git_vars
|
|
31
|
+
branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
|
32
|
+
raise Error.new('not in a git repo (no current branch)') if branch.empty?
|
|
33
|
+
{
|
|
34
|
+
GIT_BRANCH: branch,
|
|
35
|
+
GIT_BRANCH_UNDERSCORE: branch.gsub(/[^A-Za-z0-9]+/, '_')
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lux-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05
|
|
11
|
+
date: 2026-06-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -47,7 +47,17 @@ files:
|
|
|
47
47
|
- "./lib/hammer/recipe.rb"
|
|
48
48
|
- "./lib/hammer/shell.rb"
|
|
49
49
|
- "./lib/lux-hammer.rb"
|
|
50
|
+
- "./recipes/deploy.rb"
|
|
50
51
|
- "./recipes/git-helper.rb"
|
|
52
|
+
- "./recipes/lib/deploy/boot.rb"
|
|
53
|
+
- "./recipes/lib/deploy/commands.rb"
|
|
54
|
+
- "./recipes/lib/deploy/config.rb"
|
|
55
|
+
- "./recipes/lib/deploy/context.rb"
|
|
56
|
+
- "./recipes/lib/deploy/doctor.rb"
|
|
57
|
+
- "./recipes/lib/deploy/hammer.rb"
|
|
58
|
+
- "./recipes/lib/deploy/manifest.rb"
|
|
59
|
+
- "./recipes/lib/deploy/ssh.rb"
|
|
60
|
+
- "./recipes/lib/deploy/template.rb"
|
|
51
61
|
- "./recipes/llm.rb"
|
|
52
62
|
- "./recipes/srt.rb"
|
|
53
63
|
- bin/hammer
|