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,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.10
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-27 00:00:00.000000000 Z
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