Skip to content
Open
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
23 changes: 22 additions & 1 deletion lib/chef/knife/ssh.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#

require_relative "../knife"
require_relative "ssh_progress_bar"

class Chef
class Knife
Expand Down Expand Up @@ -146,13 +147,20 @@ class Ssh < Knife
boolean: true,
default: false

option :progress,
long: "--[no-]progress",
description: "Show progress bar during execution. Enabled by default when stderr is a TTY.",
boolean: true,
default: nil

def session
ssh_error_handler = Proc.new do |server|
if config[:on_error]
# Net::SSH::Multi magic to force exception to be re-raised.
throw :go, :raise
else
@progress&.clear_bar
ui.warn "Failed to connect to #{server.host} -- #{$!.class.name}: #{$!.message}"
@progress&.render
$!.backtrace.each { |l| Chef::Log.debug(l) }
end
end
Expand Down Expand Up @@ -362,7 +370,9 @@ def print_data(host, data)
def print_line(host, data)
padding = @longest - host.length
str = ui.color(host, :cyan) + (" " * (padding + 1)) + data
@progress&.clear_bar
ui.msg(str)
@progress&.render
end

# @param command [String] the command to run
Expand All @@ -373,6 +383,8 @@ def ssh_command(command, session_list = session)
exit_status = 0
command = fixup_sudo(command)
command.force_encoding("binary") if command.respond_to?(:force_encoding)
total_nodes = session_list.servers_for.size
@progress = progress_bar(total_nodes)
session_list.open_channel do |chan|
if config[:on_error] && exit_status != 0
chan.close
Expand Down Expand Up @@ -402,11 +414,13 @@ def ssh_command(command, session_list = session)
end
ch.on_request "exit-status" do |ichannel, data|
exit_status = [exit_status, data.read_long].max
@progress&.increment
end
end
end
end
session.loop
@progress&.finish
exit_status
ensure
session_list.close
Expand Down Expand Up @@ -604,6 +618,13 @@ def configure_ssh_gateway_identity
config[:ssh_gateway_identity] = get_stripped_unfrozen_value(config[:ssh_gateway_identity])
end

def progress_bar(total)
show = config[:progress].nil? ? $stderr.tty? : config[:progress]
return nil unless show && total > 1

SshProgressBar.new(total)
end

def run
@longest = 0

Expand Down
152 changes: 152 additions & 0 deletions lib/chef/knife/ssh_progress_bar.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#
# Copyright:: Copyright (c) 2009-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

class Chef
class Knife
class SshProgressBar
BAR_CHARS = { filled: "\u2588", empty: "\u2591" }.freeze unless defined?(BAR_CHARS)

attr_reader :total, :completed, :failed

def initialize(total, output: $stderr)
@total = total
@completed = 0
@failed = 0
@output = output
@active = @output.tty?
@started_at = Time.now
@mutex = Mutex.new
@bar_visible = false
render if @active
end

def increment
@mutex.synchronize do
@completed += 1
do_render if @active
end
end

def increment_failed
@mutex.synchronize do
@failed += 1
@completed += 1
do_render if @active
end
end

def finish
return unless @active

@mutex.synchronize do
clear_bar
print_summary
@bar_visible = false
end
end

def active?
@active
end

def clear_bar
return unless @active && @bar_visible

@output.write("\e[s")
@output.write("\e[#{terminal_rows};1H")
@output.write("\e[2K")
@output.write("\e[u")
@output.flush
@bar_visible = false
end

def render
return unless @active

@mutex.synchronize { do_render }
end

private

def do_render
elapsed = Time.now - @started_at
pct = @total > 0 ? (@completed.to_f / @total * 100).round(1) : 0
bar = build_bar(pct)
status = format_status(elapsed)

@output.write("\e[s")
@output.write("\e[#{terminal_rows};1H")
@output.write("\e[2K")
@output.write(status_line(bar, status, pct))
@output.write("\e[u")
@output.flush
@bar_visible = true
end

def build_bar(pct)
width = [terminal_cols - 40, 10].max
filled_width = (pct / 100.0 * width).round
empty_width = width - filled_width

"\e[32m#{BAR_CHARS[:filled] * filled_width}\e[90m#{BAR_CHARS[:empty] * empty_width}\e[0m"
end

def format_status(elapsed)
if @completed > 0 && @completed < @total
rate = elapsed / @completed
eta = ((@total - @completed) * rate).round
" ETA #{format_duration(eta)}"
elsif @completed == @total && @total > 0
" done in #{format_duration(elapsed.round)}"
else
""
end
end

def status_line(bar, status, pct)
failed_str = @failed > 0 ? " \e[31m(#{@failed} failed)\e[0m" : ""
" #{bar} #{@completed}/#{@total} (#{pct}%)#{failed_str}#{status}"
end

def format_duration(seconds)
if seconds >= 3600
format("%dh%02dm%02ds", seconds / 3600, (seconds % 3600) / 60, seconds % 60)
elsif seconds >= 60
format("%dm%02ds", seconds / 60, seconds % 60)
else
"#{seconds}s"
end
end

def print_summary
elapsed = Time.now - @started_at
failed_str = @failed > 0 ? ", \e[31m#{@failed} failed\e[0m" : ""
@output.puts("\e[1m#{@completed}/#{@total} nodes completed#{failed_str} in #{format_duration(elapsed.round)}\e[0m")
end

def terminal_rows
require "io/console" unless IO.respond_to?(:console)
IO.console&.winsize&.first || 24

Check warning on line 143 in lib/chef/knife/ssh_progress_bar.rb

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (winsize)
end

def terminal_cols
require "io/console" unless IO.respond_to?(:console)
IO.console&.winsize&.last || 80

Check warning on line 148 in lib/chef/knife/ssh_progress_bar.rb

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (winsize)
end
end
end
end
139 changes: 139 additions & 0 deletions spec/unit/knife/ssh_progress_bar_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#
# Copyright:: Copyright (c) 2009-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "knife_spec_helper"
require "chef/knife/ssh_progress_bar"

describe Chef::Knife::SshProgressBar do
let(:output) { StringIO.new }

before do
allow(output).to receive(:tty?).and_return(true)
allow(IO).to receive(:console).and_return(double(winsize: [24, 80]))
end

describe "#initialize" do
it "sets total and starts at zero completed" do
bar = described_class.new(10, output: output)
expect(bar.total).to eq(10)
expect(bar.completed).to eq(0)
expect(bar.failed).to eq(0)
end

it "renders the initial bar when output is a TTY" do
described_class.new(5, output: output)
expect(output.string).to include("0/5")
end

it "does not render when output is not a TTY" do
allow(output).to receive(:tty?).and_return(false)
described_class.new(5, output: output)
expect(output.string).to be_empty
end
end

describe "#increment" do
it "increments the completed count" do
bar = described_class.new(10, output: output)
bar.increment
expect(bar.completed).to eq(1)
end

it "updates the rendered output" do
bar = described_class.new(10, output: output)
bar.increment
expect(output.string).to include("1/10")
end

it "can increment to total" do
bar = described_class.new(3, output: output)
3.times { bar.increment }
expect(bar.completed).to eq(3)
expect(output.string).to include("3/3")
expect(output.string).to include("100.0%")
end
end

describe "#increment_failed" do
it "increments both failed and completed counts" do
bar = described_class.new(10, output: output)
bar.increment_failed
expect(bar.completed).to eq(1)
expect(bar.failed).to eq(1)
end

it "shows failed count in output" do
bar = described_class.new(10, output: output)
bar.increment_failed
expect(output.string).to include("1 failed")
end
end

describe "#finish" do
it "prints a completion summary" do
bar = described_class.new(3, output: output)
3.times { bar.increment }
bar.finish
expect(output.string).to include("3/3 nodes completed")
end

it "includes failed count in summary when there are failures" do
bar = described_class.new(3, output: output)
2.times { bar.increment }
bar.increment_failed
bar.finish
expect(output.string).to include("1 failed")
end

it "does nothing when output is not a TTY" do
allow(output).to receive(:tty?).and_return(false)
bar = described_class.new(3, output: output)
bar.finish
expect(output.string).to be_empty
end
end

describe "#clear_bar" do
it "writes ANSI clear sequence when bar is visible" do
bar = described_class.new(3, output: output)
output.truncate(0)
output.rewind
bar.clear_bar
expect(output.string).to include("\e[2K")
end

it "does nothing when output is not a TTY" do
allow(output).to receive(:tty?).and_return(false)
bar = described_class.new(3, output: output)
bar.clear_bar
expect(output.string).to be_empty
end
end

describe "#active?" do
it "returns true when output is a TTY" do
bar = described_class.new(3, output: output)
expect(bar.active?).to be true
end

it "returns false when output is not a TTY" do
allow(output).to receive(:tty?).and_return(false)
bar = described_class.new(3, output: output)
expect(bar.active?).to be false
end
end
end
Loading