]> git.proxmox.com Git - pve-common.git/blobdiff - src/PVE/SectionConfig.pm
bump version to 8.1.2
[pve-common.git] / src / PVE / SectionConfig.pm
index b46b59ed259fae053b0880e3f1a7deb3acab9ca0..a18e9d877850dd0e0fcca6e4967e2bc14c3dec8e 100644 (file)
@@ -8,6 +8,67 @@ use Digest::SHA;
 
 use PVE::Exception qw(raise_param_exc);
 use PVE::JSONSchema qw(get_standard_option);
+use PVE::Tools;
+
+# This package provides a way to have multiple (often similar) types of entries
+# in the same config file, each in its own section, thus "Section Config".
+#
+# The intended structure is to have a single 'base' plugin that inherits from
+# this class and provides meaningful defaults in its '$defaultData', e.g. a
+# default list of the core properties in its propertyList (most often only 'id'
+# and 'type')
+#
+# Each 'real' plugin then has it's own package that should inherit from the
+# 'base' plugin and returns it's specific properties in the 'properties' method,
+# its type in the 'type' method and all the known options, from both parent and
+# itself, in the 'options' method.
+# The options method can also be used to define if a property is 'optional' or
+# 'fixed' (only settable on config entity-creation), for example:
+#
+# ````
+# sub options {
+#     return {
+#         'some-optional-property' => { optional => 1 },
+#         'a-fixed-property' => { fixed => 1 },
+#         'a-required-but-not-fixed-property' => {},
+#     };
+# }
+# ```
+#
+# 'fixed' options can be set on create, but not changed afterwards.
+#
+# To actually use it, you have to first register all the plugins and then init
+# the 'base' plugin, like so:
+#
+# ```
+# use PVE::Dummy::Plugin1;
+# use PVE::Dummy::Plugin2;
+# use PVE::Dummy::BasePlugin;
+#
+# PVE::Dummy::Plugin1->register();
+# PVE::Dummy::Plugin2->register();
+# PVE::Dummy::BasePlugin->init();
+# ```
+#
+# There are two modes for how properties are exposed, the default 'unified'
+# mode and the 'isolated' mode.
+# In the default unified mode, there is only a global list of properties
+# which the plugins can use, so you cannot define the same property name twice
+# in different plugins. The reason for this is to force the use of identical
+# properties for multiple plugins.
+#
+# The second way is to use the 'isolated' mode, which can be achieved by
+# calling init with `1` as its parameter like this:
+#
+# ```
+# PVE::Dummy::BasePlugin->init(property_isolation => 1);
+# ```
+#
+# With this, each plugin get's their own isolated list of properties which it
+# can use. Note that in this mode, you only have to specify the property in the
+# options method when it is either 'fixed' or comes from the global list of
+# properties. All locally defined ones get automatically added to the schema
+# for that plugin.
 
 my $defaultData = {
     options => {},
@@ -51,51 +112,126 @@ sub plugindata {
     return {};
 }
 
+sub has_isolated_properties {
+    my ($class) = @_;
+
+    my $isolatedPropertyList = $class->private()->{isolatedPropertyList};
+
+    return defined($isolatedPropertyList) && scalar(keys $isolatedPropertyList->%*) > 0;
+}
+
+my sub compare_property {
+    my ($a, $b, $skip_opts) = @_;
+
+    my $merged = {$a->%*, $b->%*};
+    delete $merged->{$_} for $skip_opts->@*;
+
+    for my $opt (keys $merged->%*) {
+       return 0 if !PVE::Tools::is_deeply($a->{$opt}, $b->{$opt});
+    }
+
+    return 1;
+};
+
+my sub add_property {
+    my ($props, $key, $prop, $type) = @_;
+
+    if (!defined($props->{$key})) {
+       $props->{$key} = $prop;
+       return;
+    }
+
+    if (!defined($props->{$key}->{oneOf})) {
+       if (compare_property($props->{$key}, $prop, ['instance-types'])) {
+           push $props->{$key}->{'instance-types'}->@*, $type;
+       } else {
+           my $new_prop = delete $props->{$key};
+           delete $new_prop->{'type-property'};
+           delete $prop->{'type-property'};
+           $props->{$key} = {
+               'type-property' => 'type',
+               oneOf => [
+                   $new_prop,
+                   $prop,
+               ],
+           };
+       }
+    } else {
+       for my $existing_prop ($props->{$key}->{oneOf}->@*) {
+           if (compare_property($existing_prop, $prop, ['instance-types', 'type-property'])) {
+               push $existing_prop->{'instance-types'}->@*, $type;
+               return;
+           }
+       }
+
+       push $props->{$key}->{oneOf}->@*, $prop;
+    }
+};
+
 sub createSchema {
-    my ($class, $skip_type) = @_;
+    my ($class, $skip_type, $base) = @_;
 
     my $pdata = $class->private();
     my $propertyList = $pdata->{propertyList};
     my $plugins = $pdata->{plugins};
 
-    my $props = {};
-
-    my $copy_property = sub {
-       my ($src) = @_;
-
-       my $res = {};
-       foreach my $k (keys %$src) {
-           $res->{$k} = $src->{$k};
-       }
+    my $props = $base || {};
 
-       return $res;
-    };
+    if (!$class->has_isolated_properties()) {
+       foreach my $p (keys %$propertyList) {
+           next if $skip_type && $p eq 'type';
 
-    foreach my $p (keys %$propertyList) {
-       next if $skip_type && $p eq 'type';
+           if (!$propertyList->{$p}->{optional}) {
+               $props->{$p} = $propertyList->{$p};
+               next;
+           }
 
-       if (!$propertyList->{$p}->{optional}) {
-           $props->{$p} = $propertyList->{$p};
-           next;
-       }
+           my $required = 1;
 
-       my $required = 1;
+           my $copts = $class->options();
+           $required = 0 if defined($copts->{$p}) && $copts->{$p}->{optional};
 
-       my $copts = $class->options();
-       $required = 0 if defined($copts->{$p}) && $copts->{$p}->{optional};
+           foreach my $t (keys %$plugins) {
+               my $opts = $pdata->{options}->{$t} || {};
+               $required = 0 if !defined($opts->{$p}) || $opts->{$p}->{optional};
+           }
 
-       foreach my $t (keys %$plugins) {
-           my $opts = $pdata->{options}->{$t} || {};
-           $required = 0 if !defined($opts->{$p}) || $opts->{$p}->{optional};
+           if ($required) {
+               # make a copy, because we modify the optional property
+               my $res = {$propertyList->{$p}->%*}; # shallow copy
+               $res->{optional} = 0;
+               $props->{$p} = $res;
+           } else {
+               $props->{$p} = $propertyList->{$p};
+           }
        }
-
-       if ($required) {
-           # make a copy, because we modify the optional property
-           my $res = &$copy_property($propertyList->{$p});
-           $res->{optional} = 0;
-           $props->{$p} = $res;
-       } else {
-           $props->{$p} = $propertyList->{$p};
+    } else {
+       for my $type (sort keys %$plugins) {
+           my $opts = $pdata->{options}->{$type} || {};
+           for my $key (sort keys $opts->%*) {
+               my $schema = $class->get_property_schema($type, $key);
+               my $prop = {$schema->%*};
+               $prop->{'instance-types'} = [$type];
+               $prop->{'type-property'} = 'type';
+               $prop->{optional} = 1 if $opts->{$key}->{optional};
+
+               add_property($props, $key, $prop, $type);
+           }
+       }
+       # add remaining global properties
+       for my $opt (keys $propertyList->%*) {
+           next if $props->{$opt};
+           $props->{$opt} = {$propertyList->{$opt}->%*};
+       }
+       for my $opt (keys $props->%*) {
+           if (my $necessaryTypes = $props->{$opt}->{'instance-types'}) {
+               if ($necessaryTypes->@* == scalar(keys $plugins->%*)) {
+                   delete $props->{$opt}->{'instance-types'};
+                   delete $props->{$opt}->{'type-property'};
+               } else {
+                   $props->{$opt}->{optional} = 1;
+               }
+           }
        }
     }
 
@@ -107,40 +243,71 @@ sub createSchema {
 }
 
 sub updateSchema {
-    my ($class, $single_class) = @_;
+    my ($class, $single_class, $base) = @_;
 
     my $pdata = $class->private();
     my $propertyList = $pdata->{propertyList};
     my $plugins = $pdata->{plugins};
 
-    my $props = {};
+    my $props = $base || {};
 
-    my $filter_type = $class->type() if $single_class;
+    my $filter_type = $single_class ? $class->type() : undef;
 
-    foreach my $p (keys %$propertyList) {
-       next if $p eq 'type';
+    if (!$class->has_isolated_properties()) {
+       foreach my $p (keys %$propertyList) {
+           next if $p eq 'type';
 
-       my $copts = $class->options();
+           my $copts = $class->options();
 
-       next if defined($filter_type) && !defined($copts->{$p});
+           next if defined($filter_type) && !defined($copts->{$p});
 
-       if (!$propertyList->{$p}->{optional}) {
-           $props->{$p} = $propertyList->{$p};
-           next;
-       }
+           if (!$propertyList->{$p}->{optional}) {
+               $props->{$p} = $propertyList->{$p};
+               next;
+           }
+
+           my $modifyable = 0;
 
-       my $modifyable = 0;
+           $modifyable = 1 if defined($copts->{$p}) && !$copts->{$p}->{fixed};
 
-       $modifyable = 1 if defined($copts->{$p}) && !$copts->{$p}->{fixed};
+           foreach my $t (keys %$plugins) {
+               my $opts = $pdata->{options}->{$t} || {};
+               next if !defined($opts->{$p});
+               $modifyable = 1 if !$opts->{$p}->{fixed};
+           }
+           next if !$modifyable;
 
-       foreach my $t (keys %$plugins) {
-           my $opts = $pdata->{options}->{$t} || {};
-           next if !defined($opts->{$p});
-           $modifyable = 1 if !$opts->{$p}->{fixed};
+           $props->{$p} = $propertyList->{$p};
+       }
+    } else {
+       for my $type (sort keys %$plugins) {
+           my $opts = $pdata->{options}->{$type} || {};
+           for my $key (sort keys $opts->%*) {
+               next if $opts->{$key}->{fixed};
+
+               my $schema = $class->get_property_schema($type, $key);
+               my $prop = {$schema->%*};
+               $prop->{'instance-types'} = [$type];
+               $prop->{'type-property'} = 'type';
+               $prop->{optional} = 1;
+
+               add_property($props, $key, $prop, $type);
+           }
+       }
+
+       for my $opt (keys $propertyList->%*) {
+           next if $props->{$opt};
+           $props->{$opt} = {$propertyList->{$opt}->%*};
        }
-       next if !$modifyable;
 
-       $props->{$p} = $propertyList->{$p};
+       for my $opt (keys $props->%*) {
+           if (my $necessaryTypes = $props->{$opt}->{'instance-types'}) {
+               if ($necessaryTypes->@* == scalar(keys $plugins->%*)) {
+                   delete $props->{$opt}->{'instance-types'};
+                   delete $props->{$opt}->{'type-property'};
+               }
+           }
+       }
     }
 
     $props->{digest} = get_standard_option('pve-config-digest');
@@ -159,23 +326,37 @@ sub updateSchema {
     };
 }
 
+# the %param hash controls some behavior of the section config, currently the following options are
+# understood:
+#
+# - property_isolation: if set, each child-plugin has a fully isolated property (schema) namespace.
+#   By default this is off, meaning all child-plugins share the schema of properties with the same
+#   name. Normally one wants to use oneOf schema's when enabling isolation.
 sub init {
-    my ($class) = @_;
+    my ($class, %param) = @_;
+
+    my $property_isolation = $param{property_isolation};
 
     my $pdata = $class->private();
 
-    foreach my $k (qw(options plugins plugindata propertyList)) {
+    foreach my $k (qw(options plugins plugindata propertyList isolatedPropertyList)) {
        $pdata->{$k} = {} if !$pdata->{$k};
     }
 
     my $plugins = $pdata->{plugins};
     my $propertyList = $pdata->{propertyList};
+    my $isolatedPropertyList = $pdata->{isolatedPropertyList};
 
     foreach my $type (keys %$plugins) {
        my $props = $plugins->{$type}->properties();
        foreach my $p (keys %$props) {
-           die "duplicate property '$p'" if defined($propertyList->{$p});
-           my $res = $propertyList->{$p} = {};
+           my $res;
+           if ($property_isolation) {
+               $res = $isolatedPropertyList->{$type}->{$p} = {};
+           } else {
+               die "duplicate property '$p'" if defined($propertyList->{$p});
+               $res = $propertyList->{$p} = {};
+           }
            my $data = $props->{$p};
            for my $a (keys %$data) {
                $res->{$a} = $data->{$a};
@@ -187,8 +368,23 @@ sub init {
     foreach my $type (keys %$plugins) {
        my $opts = $plugins->{$type}->options();
        foreach my $p (keys %$opts) {
-           die "undefined property '$p'" if !$propertyList->{$p};
+           my $prop;
+           if ($property_isolation) {
+               $prop = $isolatedPropertyList->{$type}->{$p};
+           }
+           $prop //= $propertyList->{$p};
+           die "undefined property '$p'" if !$prop;
+       }
+
+       # automatically the properties to options (if not specified explicitly)
+       if ($property_isolation) {
+           foreach my $p (keys $isolatedPropertyList->{$type}->%*) {
+               next if $opts->{$p};
+               $opts->{$p} = {};
+               $opts->{$p}->{optional} = 1 if $isolatedPropertyList->{$type}->{$p}->{optional};
+           }
        }
+
        $pdata->{options}->{$type} = $opts;
     }
 
@@ -241,7 +437,7 @@ sub check_value {
 
     die "unexpected property '$key'\n" if !defined($opts->{$key});
 
-    my $schema = $pdata->{propertyList}->{$key};
+    my $schema = $class->get_property_schema($type, $key);
     die "unknown property type\n" if !$schema;
 
     my $ct = $schema->{type};
@@ -254,7 +450,15 @@ sub check_value {
 
     if (!$skipSchemaCheck) {
        my $errors = {};
-       PVE::JSONSchema::check_prop($value, $schema, '', $errors);
+
+       my $checkschema = $schema;
+
+       if ($ct eq 'array') {
+           die "no item schema for array" if !defined($schema->{items});
+           $checkschema = $schema->{items};
+       }
+
+       PVE::JSONSchema::check_prop($value, $checkschema, '', $errors);
        if (scalar(keys %$errors)) {
            die "$errors->{$key}\n" if $errors->{$key};
            die "$errors->{_root}\n" if $errors->{_root};
@@ -287,9 +491,23 @@ sub format_section_header {
     return "$type: $sectionId\n";
 }
 
+sub get_property_schema {
+    my ($class, $type, $key) = @_;
+
+    my $pdata = $class->private();
+    my $opts = $pdata->{options}->{$type};
+
+    my $schema;
+    if ($class->has_isolated_properties()) {
+       $schema = $pdata->{isolatedPropertyList}->{$type}->{$key};
+    }
+    $schema //= $pdata->{propertyList}->{$key};
+
+    return $schema;
+}
 
 sub parse_config {
-    my ($class, $filename, $raw) = @_;
+    my ($class, $filename, $raw, $allow_unknown) = @_;
 
     my $pdata = $class->private();
 
@@ -311,6 +529,16 @@ sub parse_config {
        }
     };
 
+    my $is_array = sub {
+       my ($type, $key) = @_;
+
+       my $schema = $class->get_property_schema($type, $key);
+       die "unknown property type\n" if !$schema;
+
+       return $schema->{type} eq 'array';
+    };
+
+    my $errors = [];
     while (@lines) {
        my $line = $nextline->();
        next if !$line;
@@ -319,26 +547,31 @@ sub parse_config {
 
        my ($type, $sectionId, $errmsg, $config) = $class->parse_section_header($line);
        if ($config) {
-           my $ignore = 0;
+           my $skip = 0;
+           my $unknown = 0;
 
            my $plugin;
 
            if ($errmsg) {
-               $ignore = 1;
+               $skip = 1;
                chomp $errmsg;
                warn "$errprefix (skip section '$sectionId'): $errmsg\n";
            } elsif (!$type) {
-               $ignore = 1;
+               $skip = 1;
                warn "$errprefix (skip section '$sectionId'): missing type - internal error\n";
            } else {
                if (!($plugin = $pdata->{plugins}->{$type})) {
-                   $ignore = 1;
-                   warn "$errprefix (skip section '$sectionId'): unsupported type '$type'\n";
+                   if ($allow_unknown) {
+                       $unknown = 1;
+                   } else {
+                       $skip = 1;
+                       warn "$errprefix (skip section '$sectionId'): unsupported type '$type'\n";
+                   }
                }
            }
 
            while ($line = $nextline->()) {
-               next if $ignore; # skip
+               next if $skip; # skip
 
                $errprefix = "file $filename line $lineno";
 
@@ -346,20 +579,51 @@ sub parse_config {
                    my ($k, $v) = ($1, $3);
 
                    eval {
-                       die "duplicate attribute\n" if defined($config->{$k});
-                       $config->{$k} = $plugin->check_value($type, $k, $v, $sectionId);
+                       if ($unknown) {
+                           if (!defined($config->{$k})) {
+                               $config->{$k} = $v;
+                           } else {
+                               if (!ref($config->{$k})) {
+                                   $config->{$k} = [$config->{$k}];
+                               }
+                               push $config->{$k}->@*, $v;
+                           }
+                       } elsif ($is_array->($type, $k)) {
+                           $v = $plugin->check_value($type, $k, $v, $sectionId);
+                           $config->{$k} = [] if !defined($config->{$k});
+                           push $config->{$k}->@*, $v;
+                       } else {
+                           die "duplicate attribute\n" if defined($config->{$k});
+                           $v = $plugin->check_value($type, $k, $v, $sectionId);
+                           $config->{$k} = $v;
+                       }
                    };
-                   warn "$errprefix (section '$sectionId') - unable to parse value of '$k': $@" if $@;
+                   if (my $err = $@) {
+                       warn "$errprefix (section '$sectionId') - unable to parse value of '$k': $err";
+                       push @$errors, {
+                           context => $errprefix,
+                           section => $sectionId,
+                           key => $k,
+                           err => $err,
+                       };
+                   }
 
                } else {
                    warn "$errprefix (section '$sectionId') - ignore config line: $line\n";
                }
            }
 
-           if (!$ignore && $type && $plugin && $config) {
+           if ($unknown) {
                $config->{type} = $type;
-               eval { $ids->{$sectionId} = $plugin->check_config($sectionId, $config, 1, 1); };
-               warn "$errprefix (skip section '$sectionId'): $@" if $@;
+               $ids->{$sectionId} = $config;
+               $order->{$sectionId} = $pri++;
+           } elsif (!$skip && $type && $plugin && $config) {
+               $config->{type} = $type;
+               if (!$unknown) {
+                   $config = eval { $config = $plugin->check_config($sectionId, $config, 1, 1); };
+                   warn "$errprefix (skip section '$sectionId'): $@" if $@;
+               }
+               $ids->{$sectionId} = $config;
                $order->{$sectionId} = $pri++;
            }
 
@@ -368,8 +632,12 @@ sub parse_config {
        }
     }
 
-
-    my $cfg = { ids => $ids, order => $order, digest => $digest};
+    my $cfg = {
+       ids => $ids,
+       order => $order,
+       digest => $digest
+    };
+    $cfg->{errors} = $errors if scalar(@$errors) > 0;
 
     return $cfg;
 }
@@ -420,16 +688,22 @@ my $format_config_line = sub {
     if ($ct eq 'boolean') {
        return "\t$key " . ($value ? 1 : 0) . "\n"
            if defined($value);
+    } elsif ($ct eq 'array') {
+       die "property '$key' is not an array" if ref($value) ne 'ARRAY';
+       my $result = '';
+       for my $line ($value->@*) {
+           $result .= "\t$key $line\n" if $value ne '';
+       }
+       return $result;
     } else {
        return "\t$key $value\n" if "$value" ne '';
     }
 };
 
 sub write_config {
-    my ($class, $filename, $cfg) = @_;
+    my ($class, $filename, $cfg, $allow_unknown) = @_;
 
     my $pdata = $class->private();
-    my $propertyList = $pdata->{propertyList};
 
     my $out = '';
 
@@ -451,16 +725,38 @@ sub write_config {
        my $scfg = $ids->{$sectionId};
        my $type = $scfg->{type};
        my $opts = $pdata->{options}->{$type};
+       my $global_opts = $pdata->{options}->{__global};
 
-       die "unknown section type '$type'\n" if !$opts;
+       die "unknown section type '$type'\n" if !$opts && !$allow_unknown;
 
        my $done_hash = {};
 
        my $data = $class->format_section_header($type, $sectionId, $scfg, $done_hash);
+
+       if (!$opts && $allow_unknown) {
+           $done_hash->{type} = 1;
+           my @first = exists($scfg->{comment}) ? ('comment') : ();
+           for my $k (@first, sort keys %$scfg) {
+               next if defined($done_hash->{$k});
+               $done_hash->{$k} = 1;
+               my $v = $scfg->{$k};
+               my $ref = ref($v);
+               if (defined($ref) && $ref eq 'ARRAY') {
+                   $data .= "\t$k $_\n" for $v->@*;
+               } else {
+                   $data .= "\t$k $v\n";
+               }
+           }
+           $out .= "$data\n";
+           next;
+       }
+
+
        if ($scfg->{comment} && !$done_hash->{comment}) {
            my $k = 'comment';
            my $v = $class->encode_value($type, $k, $scfg->{$k});
-           $data .= &$format_config_line($propertyList->{$k}, $k, $v);
+           my $prop = $class->get_property_schema($type, $k);
+           $data .= &$format_config_line($prop, $k, $v);
        }
 
        $data .= "\tdisable\n" if $scfg->{disable} && !$done_hash->{disable};
@@ -477,7 +773,8 @@ sub write_config {
            die "section '$sectionId' - missing value for required option '$k'\n"
                if !defined ($v);
            $v = $class->encode_value($type, $k, $v);
-           $data .= &$format_config_line($propertyList->{$k}, $k, $v);
+           my $prop = $class->get_property_schema($type, $k);
+           $data .= &$format_config_line($prop, $k, $v);
        }
 
        foreach my $k (@option_keys) {
@@ -485,7 +782,8 @@ sub write_config {
            my $v = $scfg->{$k};
            next if !defined($v);
            $v = $class->encode_value($type, $k, $v);
-           $data .= &$format_config_line($propertyList->{$k}, $k, $v);
+           my $prop = $class->get_property_schema($type, $k);
+           $data .= &$format_config_line($prop, $k, $v);
        }
 
        $out .= "$data\n";
@@ -500,4 +798,19 @@ sub assert_if_modified {
     PVE::Tools::assert_if_modified($cfg->{digest}, $digest);
 }
 
+sub delete_from_config {
+    my ($config, $option_schema, $new_options, $to_delete) = @_;
+
+    for my $k ($to_delete->@*) {
+       my $d = $option_schema->{$k} || die "no such option '$k'\n";
+       die "unable to delete required option '$k'\n" if !$d->{optional};
+       die "unable to delete fixed option '$k'\n" if $d->{fixed};
+       die "cannot set and delete property '$k' at the same time!\n"
+           if defined($new_options->{$k});
+       delete $config->{$k};
+    }
+
+    return $config;
+}
+
 1;