diff --git a/lib/chef/knife/ssh.rb b/lib/chef/knife/ssh.rb index 8b7a3608..2f79ad2d 100644 --- a/lib/chef/knife/ssh.rb +++ b/lib/chef/knife/ssh.rb @@ -17,6 +17,7 @@ # require_relative "../knife" +require_relative "ssh_progress_bar" class Chef class Knife @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/lib/chef/knife/ssh_progress_bar.rb b/lib/chef/knife/ssh_progress_bar.rb new file mode 100644 index 00000000..bb15a60d --- /dev/null +++ b/lib/chef/knife/ssh_progress_bar.rb @@ -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 + end + + def terminal_cols + require "io/console" unless IO.respond_to?(:console) + IO.console&.winsize&.last || 80 + end + end + end +end diff --git a/spec/unit/knife/ssh_progress_bar_spec.rb b/spec/unit/knife/ssh_progress_bar_spec.rb new file mode 100644 index 00000000..2529dc91 --- /dev/null +++ b/spec/unit/knife/ssh_progress_bar_spec.rb @@ -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