Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ PATH
dotenv (~> 3.1)
ed25519 (~> 1.4)
net-ssh (~> 7.3)
pastel (~> 0.8)
sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3)
tty-spinner (~> 0.9)
zeitwerk (>= 2.6.18, < 3.0)

GEM
Expand Down Expand Up @@ -99,6 +101,8 @@ GEM
parser (3.3.10.0)
ast (~> 2.4.1)
racc
pastel (0.8.0)
tty-color (~> 0.5)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
Expand Down Expand Up @@ -181,6 +185,10 @@ GEM
stringio (3.2.0)
thor (1.4.0)
tsort (0.2.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
Expand Down
2 changes: 2 additions & 0 deletions kamal.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Gem::Specification.new do |spec|
spec.add_dependency "bcrypt_pbkdf", "~> 1.0"
spec.add_dependency "concurrent-ruby", "~> 1.2"
spec.add_dependency "base64", "~> 0.2"
spec.add_dependency "tty-spinner", "~> 0.9"
spec.add_dependency "pastel", "~> 0.8"

spec.add_development_dependency "debug"
spec.add_development_dependency "minitest", "< 6"
Expand Down
14 changes: 12 additions & 2 deletions lib/kamal/cli/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,19 @@ def modify(lock: false)
end
end

def say(message = "", *)
super unless options[:raw]
def say(message = "", color = nil, *)
# A console backend renders say output itself, but only while it's active
# (inside a modify block, i.e. KAMAL.logging). Outside of that — e.g.
# read-only commands — fall back to printing normally.
super unless options[:raw] || (KAMAL.console_output? && KAMAL.logging)
# Flag the origin and color so the console backend can tell phase markers
# (say ..., :magenta) from notices (errors/warnings) in the line stream.
Thread.current[:kamal_say] = true
Thread.current[:kamal_say_color] = color
KAMAL.log(message.to_s)
ensure
Thread.current[:kamal_say] = nil
Thread.current[:kamal_say_color] = nil
end

# Raw output is written straight to stdout for piping, so silence SSHKit's
Expand Down
22 changes: 20 additions & 2 deletions lib/kamal/commander.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ def reset
self.lock_wait = false
self.lock_wait_timeout = 900
self.lock_wait_interval = 15
@console_output = false
@null_output&.close
@null_output = nil
@modify_depth = 0
@specifics = @specific_roles = @specific_hosts = nil
@config = @config_kwargs = nil
Expand Down Expand Up @@ -170,6 +173,10 @@ def holding_lock?
self.holding_lock
end

def console_output?
@console_output
end

def connected?
self.connected
end
Expand Down Expand Up @@ -208,9 +215,20 @@ def configure_output_with(config)

config.output.loggers.each { |logger| output_logger.broadcast_to(logger) }

SSHKit.config.output = Kamal::Output::Formatter.new($stdout, output_logger)
# A console backend renders to the screen itself, so drop SSHKit's raw stream
# (still teed to other backends). -v/--verbose skips it to restore the firehose.
console = config.output.loggers.find { |logger| logger.is_a?(Kamal::Output::ConsoleLogger) }
@console_output = !console.nil? && verbosity != :debug
console&.disable! unless @console_output

if @console_output
@null_output = File.open(File::NULL, "w")
SSHKit.config.output = Kamal::Output::Formatter.new(@null_output, output_logger)
else
SSHKit.config.output = Kamal::Output::Formatter.new($stdout, output_logger)
end

at_exit { @output_logger&.close }
at_exit { @output_logger&.close; @null_output&.close }
rescue => e
$stderr.puts "Output logger setup failed: #{e.class}: #{e.message}"
$stderr.puts e.backtrace.join("\n") if ENV["VERBOSE"]
Expand Down
14 changes: 14 additions & 0 deletions lib/kamal/configuration/docs/output.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ output:
# One log file is created per deploy, named with the timestamp and command.
file:
path: /var/log/kamal/

# Console
#
# Replace the raw command firehose on the terminal with a condensed view: a
# header panel, one section per deploy phase, a live status line per host,
# and a summary panel. The raw output is suppressed on success and replayed
# for any host that fails. Run with -v/--verbose to restore the full firehose.
#
# Both options are optional:
# spinner - show animated per-host spinners on a TTY (default: true)
# color - force color on/off (default: auto-detect from the terminal)
console:
spinner: true
color: true
3 changes: 2 additions & 1 deletion lib/kamal/configuration/output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ class Kamal::Configuration::Output

LOGGER_TYPES = {
"otel" => "Kamal::Output::OtelLogger",
"file" => "Kamal::Output::FileLogger"
"file" => "Kamal::Output::FileLogger",
"console" => "Kamal::Output::ConsoleLogger"
}

attr_reader :output_config, :loggers
Expand Down
50 changes: 50 additions & 0 deletions lib/kamal/output/console/plain_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Line-based renderer for non-TTY output (CI, piped, redirected). No cursor
# movement or spinners: phases and per-host results are printed in order as
# they resolve, so the log stays clean and deterministic.
class Kamal::Output::Console::PlainRenderer < Kamal::Output::Console::Renderer
def header(command:, service:, version:, destination:, hosts:, roles:)
target = [ service, version ].compact.join("@")
scope = "#{hosts} #{"host".pluralize(hosts)}, #{roles} #{"role".pluralize(roles)}"
scope += " · #{destination}" if destination
panel(command, [ "#{target} → #{scope}" ])
end

def phase(name)
puts
puts pastel.decorate("#{ARROW} #{name}", :bright_magenta, :bold)
end

def end_phase(statuses)
statuses.each do |host, result|
if result[:status] == :failed
puts " #{pastel.red(FAIL)} #{host} #{pastel.red("failed")}"
else
puts " #{pastel.green(OK)} #{host} #{pastel.dim(format_duration(result[:duration]))}"
end
end
end

def notice(message, color)
puts color ? pastel.decorate(message, color) : message
end

def summary(ok:, failed:, needs_attention:, runtime:, exception:)
counts = [ pastel.green("#{OK} #{ok} ok") ]
counts << pastel.red("#{FAIL} #{failed} failed") if failed > 0
lines = [ "#{counts.join(" ")} #{pastel.dim(format_duration(runtime))}" ]
lines << pastel.red("needs attention: #{needs_attention.join(", ")}") if needs_attention.any?
panel("Summary", lines, color: failed > 0 ? :red : :green)
end

def replay(host, lines)
puts
puts pastel.dim("── retained output · #{host} ─────")
lines.each { |line| puts pastel.dim("#{BAR} ") + line }
end

private
def format_duration(seconds)
return "" unless seconds
"#{sprintf("%.1f", seconds)}s"
end
end
54 changes: 54 additions & 0 deletions lib/kamal/output/console/renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
require "pastel"

# Shared formatting for the console renderers: colors, icons, and the rounded
# panels used for the header and summary. Subclasses implement the event
# methods (+header+, +phase+, +host_active+, +end_phase+, +summary+, +replay+)
# that the ConsoleLogger drives.
class Kamal::Output::Console::Renderer
OK = "✔"
FAIL = "✖"
ARROW = "❯"
BAR = "┃"

def initialize(output:, settings: {})
@output = output
@settings = settings
@pastel = Pastel.new(enabled: color_enabled?)
end

def header(command:, service:, version:, destination:, hosts:, roles:); end
def phase(name); end
def host_active(host); end
def end_phase(statuses); end
def notice(message, color); end
def summary(ok:, failed:, needs_attention:, runtime:, exception:); end
def replay(host, lines); end
def host_error(host); end

private
attr_reader :output, :settings, :pastel

def color_enabled?
return settings["color"] if settings.key?("color")
output.respond_to?(:tty?) && output.tty?
end

def puts(line = "")
output.puts(line)
end

# A rounded panel with a highlighted title, sized to its widest line.
def panel(title, lines, color: :magenta)
width = ([ visible_width(title) + 4 ] + lines.map { |line| visible_width(line) }).max
puts
puts pastel.decorate("╭─ ", color) + pastel.decorate(title, color, :bold) + pastel.decorate(" #{"─" * (width - visible_width(title) - 1)}╮", color)
lines.each do |line|
puts pastel.decorate("│ ", color) + line + " " * (width - visible_width(line)) + pastel.decorate(" │", color)
end
puts pastel.decorate("╰#{"─" * (width + 2)}╯", color)
end

def visible_width(string)
pastel.strip(string.to_s).length
end
end
74 changes: 74 additions & 0 deletions lib/kamal/output/console/tty_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require "tty-spinner"

# Interactive renderer: each phase is a TTY::Spinner::Multi whose children are
# the participating hosts, so their spinners animate concurrently and then
# settle into ✔/✖ status lines when the phase resolves. The header, summary,
# and replay panels are inherited from the plain renderer unchanged.
class Kamal::Output::Console::TtyRenderer < Kamal::Output::Console::PlainRenderer
SPINNER = :dots

def phase(name)
finish_multi
puts
@multi = TTY::Spinner::Multi.new(
pastel.decorate("#{ARROW} #{name}", :bright_magenta, :bold),
output: output, hide_cursor: true, format: SPINNER
)
@spinners = {}
end

def host_active(host)
return unless @multi
spinner = @multi.register(
"[:spinner] #{host}",
format: SPINNER,
success_mark: pastel.green(OK),
error_mark: pastel.red(FAIL)
)
@spinners[host] = spinner
spinner.auto_spin
end

# tty-spinner has no safe way to print between its live spinners, so hold
# notices raised during a phase and flush them once the phase resolves.
def notice(message, color)
line = color ? pastel.decorate(message, color) : message
if @multi
(@pending_notices ||= []) << line
else
puts line
end
end

def end_phase(statuses)
return super unless @multi

statuses.each do |host, result|
spinner = @spinners[host]
next unless spinner

if result[:status] == :failed
spinner.error(pastel.red("failed"))
else
spinner.success(pastel.dim(format_duration(result[:duration])))
end
end

finish_multi
end

private
def finish_multi
return unless @multi
@spinners.each_value { |spinner| spinner.stop if spinner.spinning? }
@multi = nil
@spinners = {}
flush_notices
end

def flush_notices
return unless @pending_notices
@pending_notices.each { |line| puts line }
@pending_notices = nil
end
end
Loading