Skip to content

Commit 745c7f0

Browse files
committed
refactor: extract SSH Channel wrapper from Connection class
- Create new TorrustDeploy::Infrastructure::SSH::Channel wrapper class - Refactor Connection class to use Channel wrapper for command execution - Remove ~97 lines of duplicate channel I/O logic from Connection class - Simplify Connection class by delegating channel operations to wrapper - Maintain same public API for Connection class - Add comprehensive unit tests for Channel wrapper - All existing tests continue to pass The Channel wrapper provides: - Clean interface for SSH channel operations - Timeout-based I/O with configurable timeouts - Health check functionality using echo command - Structured result format with output and exit codes - Better testability through dependency injection This refactoring improves maintainability by: - Separating channel operations from connection management - Reducing Connection class complexity from 680 to 583 lines - Creating reusable Channel component for future SSH needs - Following single responsibility principle
1 parent 648d052 commit 745c7f0

File tree

4 files changed

+443
-121
lines changed

4 files changed

+443
-121
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package TorrustDeploy::Infrastructure::SSH::Channel;
2+
3+
use v5.38;
4+
use Moo;
5+
use Carp qw(croak);
6+
use namespace::clean;
7+
8+
has 'channel' => (
9+
is => 'ro',
10+
required => 1,
11+
);
12+
13+
has 'timeout' => (
14+
is => 'ro',
15+
default => sub { 30 },
16+
);
17+
18+
sub execute_command {
19+
my ($self, $command) = @_;
20+
21+
croak "Failed to execute command '$command': " . ($self->channel->error || 'Unknown error')
22+
unless $self->channel->exec($command);
23+
24+
my $output = $self->read_output();
25+
my $exit_code = $self->get_exit_code();
26+
27+
return {
28+
output => $output,
29+
success => $exit_code == 0,
30+
exit_code => $exit_code,
31+
};
32+
}
33+
34+
sub health_check {
35+
my ($self) = @_;
36+
37+
# Use a simple echo command as health check
38+
my $test_command = 'echo "health_check"';
39+
return 0 unless $self->channel->exec($test_command);
40+
41+
my $output = $self->_read_health_check_output();
42+
$self->close();
43+
44+
return $output =~ /health_check/;
45+
}
46+
47+
sub read_output {
48+
my ($self) = @_;
49+
50+
$self->_setup_non_blocking_read();
51+
my $output = $self->_read_with_timeout();
52+
$output .= $self->_read_remaining_data();
53+
54+
return $output;
55+
}
56+
57+
sub get_exit_code {
58+
my ($self) = @_;
59+
60+
$self->channel->wait_closed();
61+
my $exit_code = $self->channel->exit_status();
62+
return defined $exit_code ? $exit_code : 0;
63+
}
64+
65+
sub close {
66+
my ($self) = @_;
67+
68+
$self->channel->close();
69+
}
70+
71+
#==============================================================================
72+
# PRIVATE METHODS - Channel I/O Operations
73+
#==============================================================================
74+
75+
sub _setup_non_blocking_read {
76+
my ($self) = @_;
77+
78+
$self->channel->blocking(0);
79+
}
80+
81+
sub _read_with_timeout {
82+
my ($self) = @_;
83+
84+
my $output = '';
85+
my $start_time = time();
86+
87+
while (time() - $start_time < $self->timeout) {
88+
my $buffer = $self->_try_read_chunk();
89+
90+
if (defined $buffer && length($buffer) > 0) {
91+
$output .= $buffer;
92+
next;
93+
}
94+
95+
last if $self->channel->eof();
96+
$self->_small_delay_to_prevent_busy_waiting();
97+
}
98+
99+
return $output;
100+
}
101+
102+
sub _try_read_chunk {
103+
my ($self) = @_;
104+
105+
my $buffer;
106+
my $bytes_read = $self->channel->read($buffer, 4096);
107+
108+
return (defined $bytes_read && $bytes_read > 0) ? $buffer : undef;
109+
}
110+
111+
sub _small_delay_to_prevent_busy_waiting {
112+
my ($self) = @_;
113+
114+
select(undef, undef, undef, 0.1);
115+
}
116+
117+
sub _read_remaining_data {
118+
my ($self) = @_;
119+
120+
my $remaining_output = '';
121+
122+
# Final blocking read to get any remaining data
123+
$self->channel->blocking(1);
124+
while (my $bytes_read = $self->channel->read(my $buffer, 4096)) {
125+
last unless defined $bytes_read && $bytes_read > 0;
126+
$remaining_output .= $buffer;
127+
}
128+
129+
return $remaining_output;
130+
}
131+
132+
sub _read_health_check_output {
133+
my ($self) = @_;
134+
135+
my $output = '';
136+
my $timeout = time() + 5; # 5 second timeout for health check
137+
138+
while (time() < $timeout) {
139+
my $buffer;
140+
my $bytes = $self->channel->read($buffer, 1024);
141+
last if $bytes <= 0;
142+
$output .= $buffer;
143+
last if $output =~ /health_check/;
144+
}
145+
146+
return $output;
147+
}
148+
149+
1;
150+
151+
__END__
152+
153+
=head1 NAME
154+
155+
TorrustDeploy::Infrastructure::SSH::Channel - SSH channel wrapper for command execution
156+
157+
=head1 DESCRIPTION
158+
159+
Wraps a Net::SSH2::Channel instance to provide a clean interface for command
160+
execution with timeout support and proper I/O handling. Encapsulates complex
161+
channel operations in a testable, reusable component.
162+
163+
=head1 SYNOPSIS
164+
165+
use TorrustDeploy::Infrastructure::SSH::Channel;
166+
167+
# Create channel from SSH2 connection
168+
my $raw_channel = $ssh2->channel();
169+
my $channel = TorrustDeploy::Infrastructure::SSH::Channel->new(
170+
channel => $raw_channel,
171+
timeout => 30,
172+
);
173+
174+
# Execute command
175+
my $result = $channel->execute_command('echo "Hello World"');
176+
# $result = {
177+
# output => "Hello World\n",
178+
# success => 1,
179+
# exit_code => 0
180+
# }
181+
182+
# Health check
183+
my $is_healthy = $channel->health_check();
184+
185+
=head1 ATTRIBUTES
186+
187+
=head2 channel
188+
189+
Required. The Net::SSH2::Channel instance to wrap.
190+
191+
=head2 timeout
192+
193+
Command execution timeout in seconds. Defaults to 30.
194+
195+
=head1 METHODS
196+
197+
=head2 execute_command($command)
198+
199+
Executes a command on the channel and returns a result hashref with:
200+
- output: Command output string
201+
- success: Boolean indicating if command succeeded (exit code 0)
202+
- exit_code: Command exit code
203+
204+
=head2 health_check()
205+
206+
Performs a lightweight health check by executing an echo command.
207+
Returns true if the channel is working, false otherwise.
208+
209+
=head2 read_output()
210+
211+
Reads all output from the channel with timeout protection.
212+
Returns the complete output as a string.
213+
214+
=head2 get_exit_code()
215+
216+
Waits for the channel to close and returns the command exit code.
217+
218+
=head2 close()
219+
220+
Closes the SSH channel.
221+
222+
=cut

0 commit comments

Comments
 (0)