diff --git a/lib/chef/knife/ssh.rb b/lib/chef/knife/ssh.rb index 8b7a3608..abd3240c 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&.increment_failed $!.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,7 @@ def ssh_command(command, session_list = session) exit_status = 0 command = fixup_sudo(command) command.force_encoding("binary") if command.respond_to?(:force_encoding) + @progress = progress_bar(session_list) session_list.open_channel do |chan| if config[:on_error] && exit_status != 0 chan.close @@ -401,14 +412,23 @@ def ssh_command(command, session_list = session) stderr += data end ch.on_request "exit-status" do |ichannel, data| - exit_status = [exit_status, data.read_long].max + channel_exit_status = data.read_long + exit_status = [exit_status, channel_exit_status].max + if channel_exit_status == 0 + @progress&.increment + else + @progress&.increment_failed + end end end end end session.loop + @progress&.finish exit_status ensure + @progress&.clear_bar + @progress = nil session_list.close end @@ -604,6 +624,17 @@ def configure_ssh_gateway_identity config[:ssh_gateway_identity] = get_stripped_unfrozen_value(config[:ssh_gateway_identity]) end + def progress_bar(session_list) + show = config[:progress].nil? ? $stderr.tty? : config[:progress] + return nil unless show + + servers = session_list.respond_to?(:servers_for) ? session_list.servers_for : session_list.servers + total = servers.size + return nil unless 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 diff --git a/spec/unit/knife/ssh_spec.rb b/spec/unit/knife/ssh_spec.rb index f5c0cf4d..3939e1ef 100644 --- a/spec/unit/knife/ssh_spec.rb +++ b/spec/unit/knife/ssh_spec.rb @@ -290,10 +290,13 @@ def self.should_return_specified_attributes let(:session_channel2) { double(:session_channel, request_pty: nil) } let(:session) { double(:session, loop: nil, close: nil) } + let(:progress_bar) { nil } let(:command) { "false" } before do + allow(@knife).to receive(:progress_bar).with(session).and_return(progress_bar) + expect(execution_channel) .to receive(:on_request) .and_yield(nil, double(:data_stream, read_long: exit_status)) @@ -344,6 +347,81 @@ def self.should_return_specified_attributes expect(@knife.ssh_command(command, session)).to eq(2) end end + + context "with progress enabled" do + let(:progress_bar) { double(:progress_bar, increment: nil, increment_failed: nil, finish: nil, clear_bar: nil) } + + context "both connections return 0" do + let(:exit_status) { 0 } + let(:exit_status2) { 0 } + + it "marks both connections completed" do + expect(progress_bar).to receive(:increment).twice + expect(progress_bar).not_to receive(:increment_failed) + expect(progress_bar).to receive(:finish) + + @knife.ssh_command(command, session) + end + end + + context "one connection returns non-zero" do + let(:exit_status) { 1 } + let(:exit_status2) { 0 } + + it "marks the failed connection" do + expect(progress_bar).to receive(:increment_failed).once + expect(progress_bar).to receive(:increment).once + expect(progress_bar).to receive(:finish) + + @knife.ssh_command(command, session) + end + end + end + end + + describe "#progress_bar" do + let(:session_list) { double(:session_list) } + + it "does not inspect the session list when progress is disabled" do + @knife.config[:progress] = false + + expect(session_list).not_to receive(:servers_for) + expect(@knife.progress_bar(session_list)).to be_nil + end + + it "does not inspect the session list when stderr is not a TTY" do + @knife.config[:progress] = nil + allow($stderr).to receive(:tty?).and_return(false) + + expect(session_list).not_to receive(:servers_for) + expect(@knife.progress_bar(session_list)).to be_nil + end + + it "does not create a progress bar for a single server" do + @knife.config[:progress] = true + allow(session_list).to receive(:servers_for).and_return([double(:server)]) + + expect(Chef::Knife::SshProgressBar).not_to receive(:new) + expect(@knife.progress_bar(session_list)).to be_nil + end + + it "creates a progress bar for multiple servers when progress is enabled" do + bar = double(:progress_bar) + @knife.config[:progress] = true + allow(session_list).to receive(:servers_for).and_return([double(:server), double(:server)]) + + expect(Chef::Knife::SshProgressBar).to receive(:new).with(2).and_return(bar) + expect(@knife.progress_bar(session_list)).to eq(bar) + end + + it "creates a progress bar for a subsession" do + bar = double(:progress_bar) + subsession = Struct.new(:servers).new([double(:server), double(:server)]) + @knife.config[:progress] = true + + expect(Chef::Knife::SshProgressBar).to receive(:new).with(2).and_return(bar) + expect(@knife.progress_bar(subsession)).to eq(bar) + end end describe "#tmux" do