Bug 39190: Re-implement the connection testing

We now use background jobs triggered by Transport->store to update the
connection status.

Status is now a json blob with more details, so we update the api
definition to reflect that.

Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Martin Renvoize 2025-01-22 12:38:59 +00:00 committed by Lucas Gass
parent 2d88bcaf1f
commit 3fef445515
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
11 changed files with 1007 additions and 955 deletions

View File

@ -456,6 +456,7 @@ sub core_types_to_classes {
marc_import_revert_batch => 'Koha::BackgroundJob::MARCImportRevertBatch',
pseudonymize_statistic => 'Koha::BackgroundJob::PseudonymizeStatistic',
import_from_kbart_file => 'Koha::BackgroundJob::ImportKBARTFile',
file_transport_test => 'Koha::BackgroundJob::TestTransport',
};
}

View File

@ -0,0 +1,91 @@
package Koha::BackgroundJob::TestTransport;
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use JSON qw( decode_json encode_json );
use Try::Tiny;
use Koha::File::Transports;
use base 'Koha::BackgroundJob';
=head1 NAME
Koha::BackgroundJob::TestTransport - Background job derived class to test File::Transports
=head1 API
=head2 Class methods
=head3 job_type
Define the job type of this job: file_transport_test
=cut
sub job_type {
return 'file_transport_test';
}
=head3 process
Process the transport test.
=cut
sub process {
my ( $self, $args ) = @_;
$self->start;
my $transport = Koha::File::Transports->find( $args->{transport_id} );
$transport->test_connection;
my $status = { status => 'ok' };
my $messages = $transport->object_messages;
for my $message (@$messages) {
$status->{status} = 'errors' if $message->{type} eq 'error';
push @{ $status->{operations} },
{ code => $message->{message}, status => $message->{type}, detail => $message->{payload} };
}
$transport->set( { status => encode_json($status) } )->store();
my $data = $status;
$self->finish($data);
}
=head3 enqueue
Enqueue the new job
=cut
sub enqueue {
my ( $self, $args ) = @_;
my $transport = $args->{transport_id};
Koha::Exceptions::MissingParameter->throw("Missing transport_id parameter is mandatory")
unless $transport;
$self->SUPER::enqueue(
{
job_size => 1,
job_args => {%$args},
job_queue => 'default',
}
);
}
1;

View File

@ -16,12 +16,16 @@ package Koha::File::Transport;
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use constant {
DEFAULT_TIMEOUT => 10,
TEST_FILE_NAME => '.koha_test_file',
TEST_FILE_CONTENT => "Hello, world!\n",
};
use JSON qw( decode_json encode_json );
use List::MoreUtils qw( any );
use Koha::BackgroundJob::TestTransport;
use Koha::Database;
use Koha::Exceptions::Object;
use Koha::Encryption;
@ -63,11 +67,24 @@ sub store {
$self->$dir_field( $dir . '/' ) unless substr( $dir, -1 ) eq '/';
}
my @config_fields = (qw(host port user_name password key_file upload_directory download_directory));
my $changed_config = ( !$self->in_storage || any { $self->_result->is_column_changed($_) } @config_fields ) ? 1 : 0;
# Store
$self->SUPER::store;
# Subclass triggers
my $subclass = $self->transport eq 'sftp' ? 'Koha::File::Transport::SFTP' : 'Koha::File::Transport::FTP';
$self = $subclass->_new_from_dbic( $self->_result );
$self->_post_store_trigger;
# Enqueue a connection test
if ($changed_config) {
Koha::BackgroundJob::TestTransport->new->enqueue( { transport_id => $self->id } );
}
# Return the updated object including the encrypt_sensitive_data
return $self->discard_changes;
return $self;
}
=head3 plain_text_password
@ -111,7 +128,8 @@ sub to_api {
my ( $self, $params ) = @_;
my $json = $self->SUPER::to_api($params) or return;
delete @{$json}{qw(password key_file)}; # Remove sensitive data
delete @{$json}{qw(password key_file)}; # Remove sensitive data
$json->{status} = $self->status ? decode_json( $self->status ) : undef; # Decode json status
return $json;
}
@ -141,14 +159,22 @@ sub test_connection {
$self->connect or return;
for my $dir_type (qw(download upload)) {
my $dir = $self->{"${dir_type}_directory"};
next if $dir eq '';
my $field = "${dir_type}_directory";
my $dir = $self->$field;
$dir ||= undef;
$self->change_directory($dir) or return;
$self->list_files() or return;
$self->change_directory(undef) or next;
$self->change_directory($dir) or next;
$self->list_files() or next;
}
return 1;
my $return = 1;
my $messages = $self->object_messages;
for my $message ( @{$messages} ) {
$return = 0 if $message->type eq 'error';
}
return $return;
}
=head2 Subclass methods
@ -220,6 +246,21 @@ sub list_files {
die "Subclass must implement list_files";
}
=head3 _post_store_trigger
$server->_post_store_trigger;
Method triggered by parent store to allow local additions to the store call
=cut
sub _post_store_trigger {
my ($self) = @_;
#Subclass may implement a _post_store_trigger as required
return $self;
}
=head2 Internal methods
=head3 _encrypt_sensitive_data
@ -232,14 +273,16 @@ sub _encrypt_sensitive_data {
my ($self) = @_;
my $encryption = Koha::Encryption->new;
# Only encrypt if the value has changed (is_changed from Koha::Object)
if ( ( !$self->in_storage || $self->is_changed('password') ) && $self->password ) {
# Only encrypt if the value has changed ($self->_result->is_column_changed from Koha::Object)
if ( ( !$self->in_storage || $self->_result->is_column_changed('password') ) && $self->password ) {
$self->password( $encryption->encrypt_hex( $self->password ) );
}
if ( ( !$self->in_storage || $self->is_changed('key_file') ) && $self->key_file ) {
if ( ( !$self->in_storage || $self->_result->is_column_changed('key_file') ) && $self->key_file ) {
$self->key_file( $encryption->encrypt_hex( _dos2unix( $self->key_file ) ) );
}
return;
}
=head3 _dos2unix

View File

@ -32,24 +32,6 @@ Koha::File::Transport::SFTP - SFTP implementation of file transport
=head2 Class methods
=head3 store
$server->store;
Overloaded store method that ensures key_file also gets written to the filesystem.
=cut
sub store {
my ($self) = @_;
$self->SUPER::store;
$self->discard_changes;
$self->_write_key_file;
return $self;
}
=head3 connect
my $success = $self->connect;
@ -60,25 +42,34 @@ Start the SFTP transport connect, returns true on success or undefined on failur
sub connect {
my ($self) = @_;
my $operation = "connection";
# String to capture STDERR output
$self->{stderr_capture} = '';
open my $stderr_fh, '>', \$self->{stderr_capture} or die "Can't open scalar as filehandle: $!";
$self->{connection} = Net::SFTP::Foreign->new(
host => $self->host,
port => $self->port,
user => $self->user_name,
password => $self->plain_text_password,
key_path => $self->_locate_key_file,
timeout => $self->DEFAULT_TIMEOUT,
more => [qw(-o StrictHostKeyChecking=no)],
$self->_locate_key_file ? ( key_path => $self->_locate_key_file ) : (),
timeout => $self->DEFAULT_TIMEOUT,
stderr_fh => $stderr_fh,
more => [qw( -v -o StrictHostKeyChecking=no)],
);
$self->{connection}->die_on_error("SFTP failure for remote host");
$self->{stderr_fh} = $stderr_fh;
return $self->_abort_operation() if ( $self->{connection}->error );
return $self->_abort_operation($operation) if ( $self->{connection}->error );
$self->add_message(
{
message => $self->{connection}->status,
message => $operation,
type => 'success',
payload => { detail => '' }
payload => {
status => $self->{connection}->status,
error => $self->{connection}->error,
path => $self->{connection}->cwd
}
}
);
@ -97,16 +88,21 @@ Returns true on success or undefined on failure.
sub upload_file {
my ( $self, $local_file, $remote_file ) = @_;
my $operation = "upload";
my $logger = Koha::Logger->get_logger();
$self->{connection}->put( $local_file, $remote_file ) or return $self->_abort_operation();
$self->{connection}->put( $local_file, $remote_file ) or return $self->_abort_operation($operation);
$self->add_message(
{
message => $self->{connection}->status,
message => $operation,
type => 'success',
payload => { detail => '' }
payload => {
status => $self->{connection}->status,
error => $self->{connection}->error,
path => $self->{connection}->cwd
}
}
);
@ -125,14 +121,19 @@ Returns true on success or undefined on failure.
sub download_file {
my ( $self, $remote_file, $local_file ) = @_;
my $operation = 'download';
$self->{connection}->get( $remote_file, $local_file ) or return $self->_abort_operation();
$self->{connection}->get( $remote_file, $local_file ) or return $self->_abort_operation($operation);
$self->add_message(
{
message => $self->{connection}->status,
message => $operation,
type => 'success',
payload => { detail => '' }
payload => {
status => $self->{connection}->status,
error => $self->{connection}->error,
path => $self->{connection}->cwd
}
}
);
@ -151,14 +152,19 @@ Returns true on success or undefined on failure.
sub change_directory {
my ( $self, $remote_directory ) = @_;
my $operation = 'change_directory';
$self->{connection}->setcwd($remote_directory) or return $self->_abort_operation();
$self->{connection}->setcwd($remote_directory) or return $self->_abort_operation( $operation, $remote_directory );
$self->add_message(
{
message => $self->{connection}->status,
message => $operation,
type => 'success',
payload => { detail => '' }
payload => {
status => $self->{connection}->status,
error => $self->{connection}->error,
path => $self->{connection}->cwd
}
}
);
@ -175,14 +181,19 @@ Returns an array of filenames found in the current directory of the server conne
sub list_files {
my ($self) = @_;
my $operation = "list";
my $file_list = $self->{connection}->ls or return $self->_abort_operation();
my $file_list = $self->{connection}->ls or return $self->_abort_operation($operation);
$self->add_message(
{
message => $self->{connection}->status,
message => $operation,
type => 'success',
payload => { detail => '' }
payload => {
status => $self->{connection}->status,
error => $self->{connection}->error,
path => $self->{connection}->cwd
}
}
);
@ -191,6 +202,21 @@ sub list_files {
=head2 Internal methods
=head3 _post_store_trigger
$server->post_store_trigger;
Local trigger run by the parent store method after storage.
Ensures key_file also gets written to the filesystem.
=cut
sub _post_store_trigger {
my ($self) = @_;
$self->_write_key_file;
return $self;
}
=head3 _write_key_file
my $success = $server->_write_key_file;
@ -204,6 +230,8 @@ Returns 1 on success, undef on failure.
sub _write_key_file {
my ($self) = @_;
return unless $self->plain_text_key;
my $upload_path = C4::Context->config('upload_path') or return;
my $logger = Koha::Logger->get;
my $key_path = File::Spec->catdir( $upload_path, 'ssh_keys' );
@ -254,13 +282,21 @@ Helper method to abort the current operation and return.
=cut
sub _abort_operation {
my ($self) = @_;
my ( $self, $operation, $path ) = @_;
my $stderr = $self->{stderr_capture};
$self->{stderr_capture} = '';
$self->add_message(
{
message => $self->{connection}->error,
message => $operation,
type => 'error',
payload => { detail => $self->{connection} ? $self->{connection}->status : '' }
payload => {
status => $self->{connection}->status,
error => $self->{connection}->error,
path => $path ? $path : $self->{connection}->cwd,
error_raw => $stderr
}
}
);
@ -271,4 +307,19 @@ sub _abort_operation {
return;
}
=head3 DESTROY
Ensure proper cleanup of open filehandles
=cut
sub DESTROY {
my ($self) = @_;
# Ensure the filehandle is closed properly
if ( $self->{stderr_fh} ) {
close $self->{stderr_fh} or warn "Failed to close STDERR filehandle: $!";
}
}
1;

View File

@ -409,12 +409,12 @@ sub test_conn {
}
}
$self->update_status('tests_ok');
foreach my $val ( values %$sftp_test_results ) {
if ( defined $val->{'err'} ) {
$self->update_status('tests_failed');
}
}
# $self->update_status('tests_ok');
# foreach my $val ( values %$sftp_test_results ) {
# if ( defined $val->{'err'} ) {
# $self->update_status('tests_failed');
# }
# }
return ( 1, $sftp_test_results );
}

View File

@ -192,25 +192,6 @@ if ( $op eq 'cud-add' ) {
reason => 'invalid_id'
};
}
} elsif ( $op eq 'test_form' ) {
my $sftp_server_id = $input->param('sftp_server_id');
my $sftp_server;
$sftp_server = Koha::File::Transports->find($sftp_server_id)
unless !$sftp_server_id;
if ($sftp_server) {
$template->param(
sftp_server => $sftp_server,
);
} else {
push @messages, {
type => 'alert',
code => 'error_on_test',
reason => 'invalid_id',
};
}
}
if ( $op eq 'list' ) {

View File

@ -58,7 +58,7 @@ properties:
description: Path on the remote server we upload to (optional)
status:
type:
- string
- object
- "null"
description: Most recent status of the FTP/SFTP server (optional)
debug:

View File

@ -0,0 +1,48 @@
[% USE Koha %]
[%- BLOCK operation_description -%]
[%- SWITCH operation.code -%]
[%- CASE 'connection' -%]
<span>Connection</span>
[%- CASE 'upload' -%]
<span>Upload</span>
[%- CASE 'download' -%]
<span>Download</span>
[%- CASE 'list' -%]
<span>List files</span>
[%- CASE 'change_directory' -%]
<span>Change directory</span>
[%- CASE -%]
<span>[% operation.code | html %]</span>
[%- END -%]
[%- END -%]
[% SET report = job.decoded_data %]
[% BLOCK report %]
[% IF job.status == 'finished' %]
[% IF report.status == 'errors' %]
<div class="alert alert-alert"> Connection test completed with errors </div>
[% ELSE %]
<div class="alert alert-success"> Connection test completed without errors </div>
[% END %]
[% ELSIF job.status == 'cancelled' %]
<span>The connection test was cancelled before it finished.</span>
[% END %]
[% END %]
[% BLOCK detail %]
[% FOREACH operation IN report.operations %]
[% IF operation.status == 'error' %]
<div class="text-danger"> <i class="fa-solid fa-circle-xmark"></i> [% PROCESS operation_description %] failed. </div>
[% ELSE %]
<div class="text-success"> <i class="fa-solid fa-circle-check"></i> [% PROCESS operation_description %] passed. </div>
[% END %]
[% END %]
[% IF job.status == 'cancelled' %]
<span>The connection test was cancelled before it finished.</span>
[% END %]
[% END %]
[% BLOCK js %]
[% END %]

View File

@ -226,6 +226,10 @@
'_id': 'import_from_kbart_file',
'_str': _("Import titles from a KBART file").escapeHtml()
},
{
'_id': 'file_transport_test',
'_str': _("File transport connection test")
},
[% FOR job_type IN plugin_job_types %]
{
'_id': '[% job_type.id | html %]',

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
let operationLabels = {
connection: __("Connection"),
upload: __("Upload"),
download: __("Download"),
list: __("List Files"),
change_directory: __("Change Directory"),
};