[pve-devel] [RFC v1 pve-storage 2/6] rework plugin loading and registration mechanism
Wolfgang Bumiller
w.bumiller at proxmox.com
Wed Feb 5 12:33:13 CET 2025
On Thu, Jan 30, 2025 at 03:51:20PM +0100, Max Carrara wrote:
> This commit changes how plugins, both inbuilt and custom, are loaded
> and registered by the `PVE::Storage` module from the ground up.
>
> This is done for mainly two reasons:
>
> 1. Unifying the plugin loading mechanism so that inbuilt and custom
> plugins are subjected to (almost) the same checks.
>
> 2. Making it possible to easily extend the loading / registering
> mechanism for additional compile-time (BEGIN phase [1]) checks.
>
> The new plugin registration mechanism essentially consists of four
> stages:
>
> 1. Load plugins into the Perl interpreter
> 2. Perform pre-registration checks
> 3. Registration
> 4. Perform post-registration checks
>
> Each stage will perform its specific actions on inbuilt plugins first
> and on custom plugins afterwards.
>
> In general, should a stage fail for an inbuilt plugin, an exception is
> thrown. For custom plugins a warning is emitted instead and the plugin
> won't be considered in the subsequent stage(s).
>
> The new registration mechanism is equivalent to the old one, with a
> few minor, but notable exceptions:
>
> 1. Stage 1. will also verify whether each plugin (inbuilt or custom)
> is installed inside `/usr/share/perl5/` and not anywhere else in
> order to guarantee that there are no conflicts while loading.
And also breaks testing changes with `perl -I`/PERLLIB env vars.
>
> Example: A particularly impertinent custom plugin *may* install
> itself to another path in `@INC`, such as
> `/usr/local/lib/x86_64-linux-gnu/perl/5.36.0` e.g., and mask
> another inbuilt or custom plugin by having the exact same
> namespace.
>
> While unlikely, this is now guarded against.
>
> 2. The new registration machinery also checks whether inbuilt
> plugins derive from `PVE::Storage::Plugin`.
That's not new?
>
> 3. Inbuilt plugins are no longer imported using `use`, the same logic
> using `require` is now used for both inbuilt and custom plugins.
As mentioned in the cover letter, this seems to trip up your point 1.
>
> 4. The registration now happens inside the `import()` subroutine,
> which makes it more predictable when plugins are actually loaded.
>
> This means that plugins from here on out are only registered when
> running `use PVE::Storage;` and not when using `require
> PVE::Storage;`. The difference here is somewhat subtle; to
> elaborate, the statement `use PVE::Storage;` is equivalent to the
> following [2]:
>
> BEGIN {
> require "PVE/Storage.pm";
> 'PVE::Storage'->import();
> }
>
> Because `PVE::Storage` isn't imported using a plain `require`
> anywhere in our code, this change is safe to make.
>
> [1]: https://perldoc.perl.org/perlmod#BEGIN%2C-UNITCHECK%2C-CHECK%2C-INIT-and-END
> [2]: https://perldoc.perl.org/functions/use
>
> Signed-off-by: Max Carrara <m.carrara at proxmox.com>
> ---
> src/PVE/Storage.pm | 559 +++++++++++++++++++++++++++++++++++++++------
> 1 file changed, 489 insertions(+), 70 deletions(-)
>
> diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
> index df4d62f..0c8ba64 100755
> --- a/src/PVE/Storage.pm
> +++ b/src/PVE/Storage.pm
> @@ -10,6 +10,8 @@ use IO::Socket::IP;
> use IPC::Open3;
> use File::Basename;
> use File::Path;
> +use File::Spec;
> +use Carp;
> use Cwd 'abs_path';
> use Socket;
> use Time::Local qw(timelocal);
> @@ -27,82 +29,12 @@ use PVE::RESTEnvironment qw(log_warn);
> use PVE::Storage::Version;
>
> use PVE::Storage::Plugin;
> -use PVE::Storage::DirPlugin;
> -use PVE::Storage::LVMPlugin;
> -use PVE::Storage::LvmThinPlugin;
> -use PVE::Storage::NFSPlugin;
> -use PVE::Storage::CIFSPlugin;
> -use PVE::Storage::ISCSIPlugin;
> -use PVE::Storage::RBDPlugin;
> -use PVE::Storage::CephFSPlugin;
> -use PVE::Storage::ISCSIDirectPlugin;
> -use PVE::Storage::GlusterfsPlugin;
> -use PVE::Storage::ZFSPoolPlugin;
> -use PVE::Storage::ZFSPlugin;
> -use PVE::Storage::PBSPlugin;
> -use PVE::Storage::BTRFSPlugin;
> -use PVE::Storage::ESXiPlugin;
>
> use constant APIVER => PVE::Storage::Version::APIVER;
> use constant APIAGE => PVE::Storage::Version::APIAGE;
>
> our $KNOWN_EXPORT_FORMATS = ['raw+size', 'tar+size', 'qcow2+size', 'vmdk+size', 'zfs', 'btrfs'];
>
> -# load standard plugins
> -PVE::Storage::DirPlugin->register();
> -PVE::Storage::LVMPlugin->register();
> -PVE::Storage::LvmThinPlugin->register();
> -PVE::Storage::NFSPlugin->register();
> -PVE::Storage::CIFSPlugin->register();
> -PVE::Storage::ISCSIPlugin->register();
> -PVE::Storage::RBDPlugin->register();
> -PVE::Storage::CephFSPlugin->register();
> -PVE::Storage::ISCSIDirectPlugin->register();
> -PVE::Storage::GlusterfsPlugin->register();
> -PVE::Storage::ZFSPoolPlugin->register();
> -PVE::Storage::ZFSPlugin->register();
> -PVE::Storage::PBSPlugin->register();
> -PVE::Storage::BTRFSPlugin->register();
> -PVE::Storage::ESXiPlugin->register();
> -
> -# load third-party plugins
> -if ( -d '/usr/share/perl5/PVE/Storage/Custom' ) {
> - dir_glob_foreach('/usr/share/perl5/PVE/Storage/Custom', '.*\.pm$', sub {
> - my ($file) = @_;
> - my $modname = 'PVE::Storage::Custom::' . $file;
> - $modname =~ s!\.pm$!!;
> - $file = 'PVE/Storage/Custom/' . $file;
> -
> - eval {
> - require $file;
> -
> - # Check perl interface:
> - die "not derived from PVE::Storage::Plugin\n" if !$modname->isa('PVE::Storage::Plugin');
> - die "does not provide an api() method\n" if !$modname->can('api');
> - # Check storage API version and that file is really storage plugin.
> - my $version = $modname->api();
> - die "implements an API version newer than current ($version > " . APIVER . ")\n"
> - if $version > APIVER;
> - my $min_version = (APIVER - APIAGE);
> - die "API version too old, please update the plugin ($version < $min_version)\n"
> - if $version < $min_version;
> - # all OK, do import and register (i.e., "use")
> - import $file;
> - $modname->register();
> -
> - # If we got this far and the API version is not the same, make some noise:
> - warn "Plugin \"$modname\" is implementing an older storage API, an upgrade is recommended\n"
> - if $version != APIVER;
> - };
> - if ($@) {
> - warn "Error loading storage plugin \"$modname\": $@";
> - }
> - });
> -}
^ The above was so much shorter... (and worked :P)
> -
> -# initialize all plugins
> -PVE::Storage::Plugin->init();
> -
> # the following REs indicate the number or capture groups via the trailing digit
> # CAUTION don't forget to update the digits accordingly after messing with the capture groups
>
> @@ -124,6 +56,493 @@ our $OVA_CONTENT_RE_1 = qr/${SAFE_CHAR_WITH_WHITESPACE_CLASS_RE}+\.(qcow2|raw|vm
> # FIXME remove with PVE 9.0, add versioned breaks for pve-manager
> our $vztmpl_extension_re = $VZTMPL_EXT_RE_1;
>
> +# PVE::Storage plugin registration machinery
> +
> +my $plugin_register_state = {
> + index => {},
^ A comment explaining the contents there might be useful.
> + plugins_initialised => 0,
> +};
> +
> +=head3 get_standard_plugin_names()
> +
> +Returns an array or a reference to an array of the module names of our inbuilt
> +storage plugins, depending on the context of the caller.
> +
> + # both valid
> + my $standard_plugins = get_standard_plugin_names();
> + my @standard_plugins = get_standard_plugin_names();
More POD clutter for something unnecessary 😬.
> +
> +=cut
> +
> +my sub get_standard_plugin_names : prototype() {
This should not be a sub.
> + my @standard_plugins = qw(
> + PVE::Storage::DirPlugin
> + PVE::Storage::LVMPlugin
> + PVE::Storage::LvmThinPlugin
> + PVE::Storage::NFSPlugin
> + PVE::Storage::CIFSPlugin
> + PVE::Storage::ISCSIPlugin
> + PVE::Storage::RBDPlugin
> + PVE::Storage::CephFSPlugin
> + PVE::Storage::ISCSIDirectPlugin
> + PVE::Storage::GlusterfsPlugin
> + PVE::Storage::ZFSPoolPlugin
> + PVE::Storage::ZFSPlugin
> + PVE::Storage::PBSPlugin
> + PVE::Storage::BTRFSPlugin
> + PVE::Storage::ESXiPlugin
> + );
> +
> + return @standard_plugins if wantarray;
> + return \@standard_plugins;
> +}
> +
> +=head3 get_custom_plugin_names()
> +
> +Returns an array or a reference to an array of the module names of custom
> +storage plugins inside C</usr/share/perl5/PVE/Storage/Custom>, depending on the
> +context of the caller.
> +
> + # both valid
> + my $custom_plugins = get_custom_plugin_names();
> + my @custom_plugins = get_custom_plugin_names();
😬
and still not convinced private oneshot subs should use POD.
> +
> +=cut
> +
> +my sub get_custom_plugin_names : prototype() {
> + my @custom_plugins = ();
> +
> + if ( -d '/usr/share/perl5/PVE/Storage/Custom' ) {
> + dir_glob_foreach('/usr/share/perl5/PVE/Storage/Custom', '.*\.pm$', sub {
> + my ($file) = @_;
> +
> + my $modname = 'PVE::Storage::Custom::' . $file;
> + $modname =~ s!\.pm$!!;
> +
> + push(@custom_plugins, $modname);
> + });
> + }
> +
> + return @custom_plugins if wantarray;
> + return \@custom_plugins;
> +}
> +
> +=head3 get_indexed_plugins(%params)
> +
> +Returns the names of the currently indexed storage plugins, optionally allowing
> +certain filters flags to be passed. Returns an array or a reference to an array
> +depending on the context of the caller.
> +
> + my @all_plugins = get_indexed_plugins();
> +
> + my $standard_plugins = get_indexed_plugins(is_custom => 0);
> + my @custom_plugins = get_indexed_plugins(is_custom => 1);
> +
> + my @registered_custom_plugins = get_indexed_plugins(is_custom => 1, is_registered => 1);
> +
> +The following key-value pairs may optionally be passed and will cause this
> +subroutine to filter out any plugin names for which they don't apply:
> +
> +=over
> +
> +=item * C<< is_custom => [0|1] >>
> +
> +Whether to only return names of custom plugin names or inbuilt ones.
> +
> +=item * C<< is_checked => [0|1]>>
> +
> +Whether to only return plugins that passed or haven't passed the
> +pre-registration check.
> +
> +=item * C<< is_registered => [0|1] >>
> +
> +Whether to only return plugins that were or weren't registered.
> +
> +=back
> +
> +=cut
> +
> +my sub get_indexed_plugins : prototype(;%) {
> + my (%params) = @_;
> +
> + my $plugin_index = $plugin_register_state->{index};
> +
> + my $test_index_for_flag = sub {
> + my ($plugin_name, $flag) = @_;
> +
> + return 0 if !defined($plugin_index->{$_});
> + return 0 if !defined($plugin_index->{$_}->{$flag});
> +
> + return $plugin_index->{$_}->{$flag} == $params{$flag};
> + };
> +
> + my @plugins = keys $plugin_index->%*;
> +
> + @plugins = grep { $test_index_for_flag->($_, 'is_custom') } @plugins
> + if defined($params{is_custom});
^ The helper-closure could do the entire `grep` statement to make this
just be
$grep_for_flags->($flag) for (qw(is_checked ...));
at which point we could drop the closure and just do
for my $flag (qw(is_custom is_checked is_registered) {
# contents of helper sub
}
IMO more readable.
> +
> + @plugins = grep { $test_index_for_flag->($_, 'is_checked') } @plugins
> + if defined($params{is_checked});
> +
> + @plugins = grep { $test_index_for_flag->($_, 'is_registered') } @plugins
> + if defined($params{is_registered});
> +
> + return @plugins if wantarray;
> + return @plugins;
^ no
> +}
> +
> +=head3 do_try_load_plugin(%params)
> +
> +Actual loading mechanism used inside C<L<< try_load_plugins()|/"try_load_plugins()" >>>.
> +Attempts to load the plugin provided via the C<name> key. Optionally, C<< is_custom => 1 >>
> +may be passed if a custom plugin is being loaded.
> +
> + do_try_load_plugin(name => 'PVE::Storage::DirPlugin');
> +
> + do_try_load_plugin(name => 'PVE::Storage::Custom::FooPlugin', is_custom => 1);
> +
> +The given plugin is loaded dynamically via Perl's C<require> and then added to
> +the plugin index for further operations, such as pre- and post-registration
> +checks as well as the actual registration itself.
> +
> +This subroutine performs additional sanity checks and will raise an exception if
> +the plugin doesn't exist in C</usr/share/perl5/> or if the plugin was already loaded.
> +
> +=cut
> +
> +my sub do_try_load_plugin : prototype(%) {
> + my (%params) = @_;
> +
> + my $plugin_name = $params{name};
> + my $is_custom = $params{is_custom};
> +
> + croak "no plugin name provided - must be fully qualified namespace, e.g. Foo::Bar::Baz"
> + if !defined($plugin_name);
> +
> + my $base_path = '/usr/share/perl5/';
> +
> + my $plugin_filepath = File::Spec->catdir(split('::', $plugin_name)) . '.pm';
> + my ($_volume, $plugin_dir, $plugin_filename) = File::Spec->splitpath($plugin_filepath);
> +
> + my $plugin_filepath_abs = File::Spec->catfile($base_path, $plugin_dir, $plugin_filename);
> +
> + croak "plugin '$plugin_name' does not exist at path '$plugin_filepath_abs'"
> + if (! -e $plugin_filepath_abs);
> +
> + eval {
> + require $plugin_filepath;
> + };
> + croak "Failed to load plugin '$plugin_name' - $@" if $@;
> +
> + my $plugin_index = $plugin_register_state->{index};
> +
> + confess "Plugin '$plugin_name' is already loaded" if exists($plugin_index->{$plugin_name});
> +
> + $plugin_index->{$plugin_name} = {
> + absolute_path => $plugin_filepath_abs,
> + is_custom => $is_custom || 0,
> + is_checked => 0,
> + is_registered => 0,
> + };
> +
> + return;
> +}
> +
> +=head3 try_load_plugins()
> +
> +Attempts to load all available plugins, with inbuilt ("standard") plugins being
> +loaded first and custom plugins loaded afterwards.
> +
> +All standard plugins must successfully load, otherwise an exception is thrown.
> +If a custom plugin fails to load, a warning is emitted instead.
> +
> +=cut
> +
> +my sub try_load_plugins : prototype() {
> + my @errs = ();
> +
> + for my $plugin (get_standard_plugin_names()) {
> + eval {
> + do_try_load_plugin(name => $plugin);
> + };
> +
> + push(@errs, $@) if $@;
> + }
> +
> + confess("Encountered unexpected error(s) while loading plugins:\n", join("\n", @errs), "\n\n... ")
> + if scalar(@errs);
> +
> + for my $plugin (get_custom_plugin_names()) {
> + eval {
> + do_try_load_plugin(name => $plugin, is_custom => 1);
> + };
> +
> + warn "Error loading storage plugin \"$plugin\" - $@" if $@;
> + }
> +
> + return;
> +}
> +
> +=head3 do_pre_register_check_plugin(%params)
> +
> +Used within C<L<< pre_register_check_plugins()|/"pre_register_check_plugins()" >>>
> +to perform the actual pre-registration checks for the plugin given by the C<name>
> +key.
> +
> + do_pre_register_check_plugin(name => 'PVE::Storage::DirPlugin');
> +
> +This checks whether the plugin is derived from C<L<PVE::Storage::Plugin>>. If the
> +plugin is a custom plugin, its API conformance is also verified:
> +
> +=over
> +
> +=item * The plugin must provide an C<api()> method returning its API version
> +
> +=item * Its API version must not be newer than the current storage API version
> +
> +=item * Its API version must not be older than the storage API age allows
> +
> +=back
> +
> +=cut
> +
> +my sub do_pre_register_check_plugin : prototype(%) {
> + my (%params) = @_;
> +
> + my $plugin_name = $params{name};
> +
> + my $plugin_index = $plugin_register_state->{index};
> +
> + croak "Plugin '$plugin_name' does not exist in index"
> + if !defined($plugin_index->{$plugin_name});
> +
> + my $is_custom = $plugin_index->{$plugin_name}->{is_custom};
> +
> + my $check_derives_base = sub {
> + # so that we may call methods on $plugin_name
> + no strict 'refs'; ## no critic
> +
> + die "not derived from PVE::Storage::Plugin\n"
> + if !$plugin_name->isa('PVE::Storage::Plugin');
> + };
> +
> + my $check_plugin_api = sub {
> + # so that we may call methods on $plugin_name
> + no strict 'refs'; ## no critic
> +
> + die "does not provide an 'api()' method\n"
> + if !$plugin_name->can('api');
> +
> + my $version = $plugin_name->api();
> + my $min_version = (APIVER - APIAGE);
> +
> + die "implements an API version newer than current ($version > @{[APIVER]})\n"
> + if $version > APIVER;
> +
> + die "API version too old, please update the plugin ($version < $min_version)\n"
> + if $version < $min_version;
> + };
> +
> + eval {
> + $check_derives_base->();
> + $check_plugin_api->() if $is_custom;
> + };
> +
> + croak "Pre-registration check failed for plugin '$plugin_name' - $@" if $@;
> +
> + $plugin_index->{$plugin_name}->{is_checked} = 1;
> +
> + return;
> +}
> +
> +=head3 pre_register_check_plugins()
> +
> +Performs various pre-registration checks for all plugins.
> +
> +Standard plugins must always pass all checks. If a custom plugin fails a check,
> +a warning is emitted instead and it will not be registered later on.
> +
> +For more details on which checks are performed, see
> +C<L<< do_pre_register_check_plugin()|/"do_pre_register_check_plugin(%params)" >>>.
> +
> +=cut
> +
> +my sub pre_register_check_plugins : prototype() {
> + my @errs = ();
> +
> + my @standard_plugins = get_indexed_plugins(is_custom => 0);
> + for my $plugin (@standard_plugins) {
> + eval {
> + do_pre_register_check_plugin(name => $plugin);
> + };
> +
> + push(@errs, $@) if $@;
> + }
> +
> + if (scalar(@errs)) {
> + my $err_msg = "Encountered unexpected error while performing"
> + . " pre-registration check for inbuilt plugins:\n";
> +
> + confess($err_msg, join("\n", @errs), "\n\n... ");
> + }
> +
> + my @custom_plugins = get_indexed_plugins(is_custom => 1);
> + for my $plugin (@custom_plugins) {
> + eval {
> + do_pre_register_check_plugin(name => $plugin);
> + };
> +
> + if ($@) {
> + warn "Encountered unexpected error while performing pre-registration"
> + . " check for custom plugin \"$plugin\":\n$@\n";
> + warn "Plugin will not be registered.\n";
> + }
> + }
> +
> + return;
> +}
> +
> +=head3 try_register_plugins()
> +
> +Attempts to register all plugins of the plugin index for which the
> +L<pre-registration check|/"pre_register_check_plugins()"> was successful, with
> +standard plugins being registered first and custom plugins afterwards.
> +
> +In detail, for each plugin, the C<import()> subroutine is first run, followed
> +by C<L<< register()|PVE::SectionConfig/register >>>.
> +
> +For all standard plugins both invocations must succeed, otherwise an exception
> +is thrown. If a call to either subroutine fails for a custom plugin, a warning
> +is emitted instead and the custom plugin remains unregistered.
> +
> +=cut
> +
> +my sub try_register_plugins : prototype() {
> + my @errs = ();
> +
> + my $plugin_index = $plugin_register_state->{index};
> +
> + my $try_import = sub {
> + my ($plugin_name) = @_;
> +
> + # so that we may call methods on $plugin_name
> + no strict 'refs'; ## no critic
> + $plugin_name->import();
This needs to happen in `try_load_plugins` or
`pre_register_check_plugins`, otherwise `->isa()` won't work.
> + };
> +
> + my $try_register = sub {
> + my ($plugin_name) = @_;
> +
> + # so that we may call methods on $plugin_name
> + no strict 'refs'; ## no critic
> + $plugin_name->register();
> +
> + $plugin_index->{$plugin_name}->{is_registered} = 1;
> + };
> +
> + my @standard_plugins = get_indexed_plugins(is_custom => 0, is_checked => 1);
> + for my $plugin (@standard_plugins) {
> + eval {
> + $try_import->($plugin);
> + };
> +
> + if ($@) {
> + push(@errs, "Encountered unexpected error while running '$plugin\->import()' - $@");
> + next;
> + }
> +
> + eval {
> + $try_register->($plugin);
> + };
> +
> + if ($@) {
> + push(@errs, "Encountered unexpected error while running '$plugin\->register()' - $@");
> + next;
> + }
> + }
> +
> + confess join("\n\n", @errs, "\n\n... ") if scalar(@errs);
> +
> + my @custom_plugins = get_indexed_plugins(is_custom => 1, is_checked => 1);
> + for my $plugin (@custom_plugins) {
> + eval {
> + $try_import->($plugin);
> + };
> +
> + if ($@) {
> + carp "Failed to run 'import' on custom plugin '$plugin' - $@";
> + carp "Please check if the plugin is installed correctly.";
> + carp "Continuing to register remaining custom plugins (if any).";
> +
> + next;
> + }
> +
> + eval {
> + $try_register->($plugin);
> + };
> +
> + if ($@) {
> + carp "Failed to register custom plugin '$plugin' - $@";
> + carp "Please verify whether the plugin is installed correctly.";
> + carp "Continuing to register remaining custom plugins (if any).";
> +
> + next;
> + }
> + }
> +
> + return;
> +}
> +
> +=head3 post_register_check_plugins()
> +
> +Used to perform various checks on each plugin after the plugins' registration.
> +
> +This subroutine currently only emits a warning if a custom plugin's API version
> +is older than the current one.
> +
> +=cut
> +
> +my sub post_register_check_plugins : prototype() {
> + my @custom_plugins = get_indexed_plugins(is_custom => 1, is_registered => 1);
> + for my $plugin (@custom_plugins) {
> + # so that we may call methods on $plugin
> + no strict 'refs'; ## no critic
> +
> + my $version = $plugin->api();
> + warn "Plugin \"$plugin\" is implementing an older storage API, an upgrade is recommended\n"
> + if $version != APIVER;
> + }
> +
> + return;
> +}
> +
> +# NOTE: This is *not* like Exporter's import (that you get with e.g. `use parent qw(Exporter);`)
> +# which supports @EXPORT_OK etc.
> +#
> +# If there is ever a need to support exporting subroutines (via `Exporter`),
> +# see the following: https://perldoc.perl.org/Exporter#Exporting-Without-Using-Exporter's-import-Method
> +# Beware that this requires you to remove any keys that ought not be passed
> +# to exporter from @_.
> +
> +sub import {
> + my ($package, %params) = @_;
> +
> + # this sub may be called more than once, so avoid registering plugins again
> + if ($plugin_register_state->{plugins_initialised} == 1) {
> + return;
> + }
> +
> + try_load_plugins();
> + pre_register_check_plugins();
> + try_register_plugins();
> + post_register_check_plugins();
> +
> + PVE::Storage::Plugin->init();
> + $plugin_register_state->{plugins_initialised} = 1;
> +
> + return;
> +}
> +
> # PVE::Storage utility functions
>
> sub config {
> --
> 2.39.5
More information about the pve-devel
mailing list