|
#!/usr/bin/env ruby |
|
# frozen_string_literal: true |
|
|
|
# A dependency-free, Gist-friendly wrapper around `kamal deploy`. |
|
# It replaces Kamal's interactive output with a small centered animation while |
|
# preserving plain output for pipes and CI. The percentage is a phase estimate, |
|
# not a byte counter. Run with --help for configuration. |
|
|
|
require 'fileutils' |
|
require 'io/console' |
|
require 'shellwords' |
|
require 'tmpdir' |
|
|
|
class KamalDeployAnimation |
|
ANSI_PATTERN = /\e(?:\[[0-?]*[ -\/]*[@-~]|\][^\a]*(?:\a|\e\\))/.freeze |
|
FRAME_INTERVAL = 0.12 |
|
FAILURE_TAIL_LINES = 40 |
|
|
|
PHASES = [ |
|
[ 8, 'Building image', /Build and push app image|Pull app image/i ], |
|
[ 18, 'Building image', /docker buildx build|\[internal\].*(?:dockerfile|metadata|context)/i ], |
|
[ 32, 'Packaging image', /exporting (?:to image|layers|manifest|config)/i ], |
|
[ 42, 'Shipping image', /pushing (?:layers|manifest)|Pulling image/i ], |
|
[ 48, 'Locking deploy', /Acquiring the deploy lock/i ], |
|
[ 55, 'Starting proxy', /Ensure kamal-proxy is running/i ], |
|
[ 64, 'Checking containers', /Detect stale containers/i ], |
|
[ 71, 'Selecting image', /Get most recent version available as an image/i ], |
|
[ 78, 'Starting application', /Start container with version/i ], |
|
[ 86, 'Waiting for healthy application', /healthcheck|health check|kamal-proxy deploy/i ], |
|
[ 91, 'Tidying old images', /Prune old containers and images/i ], |
|
[ 97, 'Releasing deploy lock', /Releasing the deploy lock/i ], |
|
[ 99, 'Finishing deploy', /Finished all in/i ] |
|
].freeze |
|
|
|
FRAMES = [ |
|
[ ' /\\_/\\', ' ( o.o )', ' > ^ < ~' ], |
|
[ ' /\\_/\\', ' ( o.o )', ' > ^ < ~ ' ], |
|
[ ' /\\_/\\', ' ( -.- )', ' > ^ < ~ ' ], |
|
[ ' /\\_/\\', ' ( o.o )', ' > ^ < ~ ' ] |
|
].freeze |
|
|
|
def initialize(command, log_path) |
|
@command = command |
|
@log_path = log_path |
|
@percent = 2 |
|
@phase = 'Preparing deploy' |
|
@frame = 0 |
|
@scan_buffer = +'' |
|
@child_pid = nil |
|
end |
|
|
|
def run |
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) |
|
read_io, write_io = IO.pipe |
|
@child_pid = Process.spawn(*@command, in: $stdin, out: write_io, err: write_io, pgroup: true) |
|
write_io.close |
|
|
|
install_signal_forwarders |
|
enter_screen |
|
|
|
File.open(@log_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |log| |
|
consume_output(read_io, log) |
|
end |
|
|
|
_, status = Process.wait2(@child_pid) |
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at |
|
|
|
if status.success? |
|
@percent = 100 |
|
@phase = 'Deploy complete' |
|
draw |
|
sleep 0.25 |
|
leave_screen |
|
puts "Deployed successfully in #{format_duration(elapsed)}." |
|
puts "Log: #{@log_path}" |
|
0 |
|
else |
|
leave_screen |
|
report_failure(status) |
|
status.exitstatus || 128 + status.termsig |
|
end |
|
rescue Errno::ENOENT => error |
|
leave_screen |
|
warn "Could not start #{Shellwords.join(@command)}: #{error.message}" |
|
127 |
|
ensure |
|
read_io&.close unless read_io&.closed? |
|
write_io&.close unless write_io&.closed? |
|
restore_signal_handlers |
|
leave_screen |
|
end |
|
|
|
private |
|
|
|
def consume_output(read_io, log) |
|
next_frame_at = monotonic_time |
|
|
|
loop do |
|
timeout = [ next_frame_at - monotonic_time, 0 ].max |
|
|
|
if IO.select([ read_io ], nil, nil, timeout) |
|
chunk = read_io.read_nonblock(16_384, exception: false) |
|
break if chunk.nil? |
|
|
|
unless chunk == :wait_readable |
|
log.write(chunk) |
|
log.flush |
|
update_phase(chunk) |
|
end |
|
end |
|
|
|
if monotonic_time >= next_frame_at |
|
draw |
|
next_frame_at = monotonic_time + FRAME_INTERVAL |
|
end |
|
end |
|
end |
|
|
|
def update_phase(chunk) |
|
text = chunk.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) |
|
@scan_buffer << text.gsub(ANSI_PATTERN, '') |
|
records = @scan_buffer.split(/[\r\n]/, -1) |
|
remainder = records.pop.to_s |
|
@scan_buffer = remainder.length > 2_048 ? remainder.slice(-2_048, 2_048) : remainder |
|
|
|
records.each do |record| |
|
PHASES.each do |percent, label, pattern| |
|
next unless percent > @percent && record.match?(pattern) |
|
|
|
@percent = percent |
|
@phase = label |
|
end |
|
end |
|
end |
|
|
|
def draw |
|
rows, columns = terminal_size |
|
art = FRAMES[@frame % FRAMES.length] |
|
@frame += 1 |
|
|
|
lines = art + [ "Deploying #{@percent}%", @phase ] |
|
top_padding = [ (rows - lines.length) / 2, 0 ].max |
|
|
|
output = +"\e[H" |
|
output << "\n" * top_padding |
|
lines.each do |line| |
|
line = truncate(line, columns) |
|
left_padding = [ (columns - line.length) / 2, 0 ].max |
|
output << (' ' * left_padding) << line << "\e[K\n" |
|
end |
|
output << "\e[J" |
|
$stdout.write(output) |
|
$stdout.flush |
|
end |
|
|
|
def terminal_size |
|
console = IO.console |
|
return [ 24, 80 ] unless console |
|
|
|
rows, columns = console.winsize |
|
rows = 24 unless rows.positive? |
|
columns = 80 unless columns.positive? |
|
[ rows, columns ] |
|
rescue SystemCallError |
|
[ 24, 80 ] |
|
end |
|
|
|
def truncate(line, columns) |
|
return '' if columns <= 0 |
|
return line if line.length <= columns |
|
return line.slice(0, columns) if columns < 4 |
|
|
|
"#{line.slice(0, columns - 3)}..." |
|
end |
|
|
|
def enter_screen |
|
return if @screen_active |
|
|
|
@screen_active = true |
|
$stdout.write("\e[?1049h\e[?25l\e[2J\e[H") |
|
$stdout.flush |
|
end |
|
|
|
def leave_screen |
|
return unless @screen_active |
|
|
|
@screen_active = false |
|
$stdout.write("\e[?25h\e[?1049l") |
|
$stdout.flush |
|
end |
|
|
|
def install_signal_forwarders |
|
@previous_signal_handlers = {} |
|
|
|
%w[INT TERM HUP].each do |signal| |
|
@previous_signal_handlers[signal] = Signal.trap(signal) do |
|
begin |
|
Process.kill(signal, -@child_pid) if @child_pid |
|
rescue Errno::ESRCH |
|
nil |
|
end |
|
end |
|
end |
|
end |
|
|
|
def restore_signal_handlers |
|
@previous_signal_handlers&.each do |signal, handler| |
|
Signal.trap(signal, handler) |
|
end |
|
end |
|
|
|
def report_failure(status) |
|
code = status.exitstatus || "signal #{status.termsig}" |
|
warn "Deploy failed (#{code})." |
|
warn 'Last Kamal output:' |
|
warn failure_tail |
|
warn "Full log: #{@log_path}" |
|
end |
|
|
|
def failure_tail |
|
text = File.binread(@log_path) |
|
.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) |
|
.gsub(ANSI_PATTERN, '') |
|
.tr("\r", "\n") |
|
|
|
text.lines.last(FAILURE_TAIL_LINES).join.rstrip |
|
rescue Errno::ENOENT |
|
'(no output captured)' |
|
end |
|
|
|
def format_duration(seconds) |
|
total_seconds = seconds.round |
|
minutes, seconds = total_seconds.divmod(60) |
|
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s" |
|
end |
|
|
|
def monotonic_time |
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) |
|
end |
|
end |
|
|
|
def deploy_command |
|
configured = ENV['KAMAL_DEPLOY_COMMAND'] |
|
return Shellwords.split(configured) unless configured.nil? || configured.strip.empty? |
|
|
|
if File.executable?('bin/kamal') |
|
[ 'bin/kamal', 'deploy' ] |
|
elsif File.file?('Gemfile') |
|
[ 'bundle', 'exec', 'kamal', 'deploy' ] |
|
else |
|
[ 'kamal', 'deploy' ] |
|
end |
|
end |
|
|
|
def print_help |
|
puts <<~HELP |
|
Usage: kamal-deploy [KAMAL DEPLOY OPTIONS] |
|
|
|
Shows a centered deploy animation in an interactive terminal and passes every |
|
argument through to `kamal deploy`. Pipes, CI, TERM=dumb, and explicit plain |
|
mode receive Kamal's original output. The monotonic percentage is an estimate |
|
based on Kamal's deploy phases, not bytes or elapsed time. |
|
|
|
Environment: |
|
KAMAL_DEPLOY_COMMAND Full base command (default: auto-detected Kamal deploy) |
|
KAMAL_DEPLOY_LOG_DIR Animation log directory (default: system temp directory) |
|
KAMAL_DEPLOY_PLAIN=1 Disable the animation and use Kamal's original output |
|
|
|
Examples: |
|
bin/kamal-deploy |
|
bin/kamal-deploy --skip-push |
|
KAMAL_DEPLOY_COMMAND='kamal deploy' bin/kamal-deploy -d staging |
|
HELP |
|
end |
|
|
|
if ARGV == [ '--help' ] |
|
print_help |
|
exit 0 |
|
end |
|
|
|
command = deploy_command + ARGV |
|
plain = !$stdout.tty? || ENV['TERM'] == 'dumb' || ENV['KAMAL_DEPLOY_PLAIN'] == '1' |
|
|
|
if plain |
|
exec(*command) |
|
else |
|
log_dir = ENV.fetch('KAMAL_DEPLOY_LOG_DIR', File.join(Dir.tmpdir, 'kamal-deploy')) |
|
FileUtils.mkdir_p(log_dir, mode: 0o700) |
|
timestamp = Time.now.strftime('%Y%m%d-%H%M%S') |
|
log_path = File.join(log_dir, "deploy-#{timestamp}-#{Process.pid}.log") |
|
exit KamalDeployAnimation.new(command, log_path).run |
|
end |