]> git.proxmox.com Git - pve-common.git/blobdiff - src/PVE/JSONSchema.pm
bump version to 8.2.1
[pve-common.git] / src / PVE / JSONSchema.pm
index f0ac44139883234d4b003e26257f0f89a35a5fb6..115f811043360204c2ab07e86b8feb5278f2d594 100644 (file)
@@ -10,17 +10,22 @@ use Devel::Cycle -quiet; # todo: remove?
 use PVE::Tools qw(split_list $IPV6RE $IPV4RE);
 use PVE::Exception qw(raise);
 use HTTP::Status qw(:constants);
+use JSON;
 use Net::IP qw(:PROC);
 use Data::Dumper;
 
 use base 'Exporter';
 
 our @EXPORT_OK = qw(
-register_standard_option 
+register_standard_option
 get_standard_option
+parse_property_string
+print_property_string
 );
 
-# Note: This class implements something similar to JSON schema, but it is not 100% complete. 
+our $CONFIGID_RE = qr/[a-z][a-z0-9_-]+/i;
+
+# Note: This class implements something similar to JSON schema, but it is not 100% complete.
 # see: http://tools.ietf.org/html/draft-zyp-json-schema-02
 # see: http://json-schema.org/
 
@@ -30,7 +35,7 @@ my $standard_options = {};
 sub register_standard_option {
     my ($name, $schema) = @_;
 
-    die "standard option '$name' already registered\n" 
+    die "standard option '$name' already registered\n"
        if $standard_options->{$name};
 
     $standard_options->{$name} = $schema;
@@ -54,8 +59,10 @@ sub get_standard_option {
 
 register_standard_option('pve-vmid', {
     description => "The (unique) ID of the VM.",
-    type => 'integer', format => 'pve-vmid',
-    minimum => 1
+    type => 'integer',
+    format => 'pve-vmid',
+    minimum => 100,
+    maximum => 999_999_999,
 });
 
 register_standard_option('pve-node', {
@@ -77,13 +84,23 @@ register_standard_option('pve-iface', {
 register_standard_option('pve-storage-id', {
     description => "The storage identifier.",
     type => 'string', format => 'pve-storage-id',
-}); 
+    format_description => 'storage ID',
+});
+
+register_standard_option('pve-bridge-id', {
+    description => "Bridge to attach guest network devices to.",
+    type => 'string', format => 'pve-bridge-id',
+    format_description => 'bridge',
+});
 
 register_standard_option('pve-config-digest', {
-    description => 'Prevent changes if current configuration file has different SHA1 digest. This can be used to prevent concurrent modifications.',
+    description => 'Prevent changes if current configuration file has a different digest. '
+       . 'This can be used to prevent concurrent modifications.',
     type => 'string',
     optional => 1,
-    maxLength => 40, # sha1 hex digest lenght is 40
+    # sha1 hex digests are 40 characters long
+    # sha256 hex digests are 64 characters long (sha256 is used in our Rust code)
+    maxLength => 64,
 });
 
 register_standard_option('skiplock', {
@@ -105,20 +122,41 @@ register_standard_option('fingerprint-sha256', {
     pattern => '([A-Fa-f0-9]{2}:){31}[A-Fa-f0-9]{2}',
 });
 
+register_standard_option('pve-output-format', {
+    type => 'string',
+    description => 'Output format.',
+    enum => [ 'text', 'json', 'json-pretty', 'yaml' ],
+    optional => 1,
+    default => 'text',
+});
+
+register_standard_option('pve-snapshot-name', {
+    description => "The name of the snapshot.",
+    type => 'string', format => 'pve-configid',
+    maxLength => 40,
+});
+
 my $format_list = {};
+my $format_validators = {};
 
 sub register_format {
-    my ($format, $code) = @_;
+    my ($name, $format, $validator) = @_;
 
-    die "JSON schema format '$format' already registered\n" 
-       if $format_list->{$format};
+    die "JSON schema format '$name' already registered\n"
+       if $format_list->{$name};
 
-    $format_list->{$format} = $code;
+    if ($validator) {
+       die "A \$validator function can only be specified for hash-based formats\n"
+           if ref($format) ne 'HASH';
+       $format_validators->{$name} = $validator;
+    }
+
+    $format_list->{$name} = $format;
 }
 
 sub get_format {
-    my ($format) = @_;
-    return $format_list->{$format};
+    my ($name) = @_;
+    return $format_list->{$name};
 }
 
 my $renderer_hash = {};
@@ -154,10 +192,10 @@ sub pve_verify_urlencoded {
 register_format('pve-configid', \&pve_verify_configid);
 sub pve_verify_configid {
     my ($id, $noerr) = @_;
-    if ($id !~ m/^[a-z][a-z0-9_]+$/i) {
+
+    if ($id !~ m/^$CONFIGID_RE$/) {
        return undef if $noerr;
-       die "invalid configuration ID '$id'\n"; 
+       die "invalid configuration ID '$id'\n";
     }
     return $id;
 }
@@ -166,13 +204,36 @@ PVE::JSONSchema::register_format('pve-storage-id', \&parse_storage_id);
 sub parse_storage_id {
     my ($storeid, $noerr) = @_;
 
-    if ($storeid !~ m/^[a-z][a-z0-9\-\_\.]*[a-z0-9]$/i) {
+    return parse_id($storeid, 'storage', $noerr);
+}
+
+PVE::JSONSchema::register_format('pve-bridge-id', \&parse_bridge_id);
+sub parse_bridge_id {
+    my ($id, $noerr) = @_;
+
+    if ($id !~ m/^[-_.\w\d]+$/) {
        return undef if $noerr;
-       die "storage ID '$storeid' contains illegal characters\n";
+       die "invalid bridge ID '$id'\n";
     }
-    return $storeid;
+    return $id;
+}
+
+PVE::JSONSchema::register_format('acme-plugin-id', \&parse_acme_plugin_id);
+sub parse_acme_plugin_id {
+    my ($pluginid, $noerr) = @_;
+
+    return parse_id($pluginid, 'ACME plugin', $noerr);
 }
 
+sub parse_id {
+    my ($id, $type, $noerr) = @_;
+
+     if ($id !~ m/^[a-z][a-z0-9\-\_\.]*[a-z0-9]$/i) {
+       return undef if $noerr;
+       die "$type ID '$id' contains illegal characters\n";
+    }
+    return $id;
+}
 
 register_format('pve-vmid', \&pve_verify_vmid);
 sub pve_verify_vmid {
@@ -196,6 +257,121 @@ sub pve_verify_node_name {
     return $node;
 }
 
+# maps source to target ID using an ID map
+sub map_id {
+    my ($map, $source) = @_;
+
+    return $source if !defined($map);
+
+    return $map->{entries}->{$source}
+       if $map->{entries} && defined($map->{entries}->{$source});
+
+    return $map->{default} if $map->{default};
+
+    # identity (fallback)
+    return $source;
+}
+
+sub parse_idmap {
+    my ($idmap, $idformat) = @_;
+
+    return undef if !$idmap;
+
+    my $map = {};
+
+    foreach my $entry (PVE::Tools::split_list($idmap)) {
+       if ($entry eq '1') {
+           $map->{identity} = 1;
+       } elsif ($entry =~ m/^([^:]+):([^:]+)$/) {
+           my ($source, $target) = ($1, $2);
+           eval {
+               check_format($idformat, $source, '');
+               check_format($idformat, $target, '');
+           };
+           die "entry '$entry' contains invalid ID - $@\n" if $@;
+
+           die "duplicate mapping for source '$source'\n"
+               if exists $map->{entries}->{$source};
+
+           $map->{entries}->{$source} = $target;
+       } else {
+           eval {
+               check_format($idformat, $entry);
+           };
+           die "entry '$entry' contains invalid ID - $@\n" if $@;
+
+           die "default target ID can only be provided once\n"
+               if exists $map->{default};
+
+           $map->{default} = $entry;
+       }
+    }
+
+    die "identity mapping cannot be combined with other mappings\n"
+       if $map->{identity} && ($map->{default} || exists $map->{entries});
+
+    return $map;
+}
+
+my $verify_idpair = sub {
+    my ($input, $noerr, $format) = @_;
+
+    eval { parse_idmap($input, $format) };
+    if ($@) {
+       return undef if $noerr;
+       die "$@\n";
+    }
+
+    return $input;
+};
+
+PVE::JSONSchema::register_standard_option('pve-targetstorage', {
+    description => "Mapping from source to target storages. Providing only a single storage ID maps all source storages to that storage. Providing the special value '1' will map each source storage to itself.",
+    type => 'string',
+    format => 'storage-pair-list',
+    optional => 1,
+});
+
+# note: this only checks a single list entry
+# when using a storage-pair-list map, you need to pass the full parameter to
+# parse_idmap
+register_format('storage-pair', \&verify_storagepair);
+sub verify_storagepair {
+    my ($storagepair, $noerr) = @_;
+    return $verify_idpair->($storagepair, $noerr, 'pve-storage-id');
+}
+
+# note: this only checks a single list entry
+# when using a bridge-pair-list map, you need to pass the full parameter to
+# parse_idmap
+register_format('bridge-pair', \&verify_bridgepair);
+sub verify_bridgepair {
+    my ($bridgepair, $noerr) = @_;
+    return $verify_idpair->($bridgepair, $noerr, 'pve-bridge-id');
+}
+
+register_format('mac-addr', \&pve_verify_mac_addr);
+sub pve_verify_mac_addr {
+    my ($mac_addr, $noerr) = @_;
+
+    # don't allow I/G bit to be set, most of the time it breaks things, see:
+    # https://pve.proxmox.com/pipermail/pve-devel/2019-March/035998.html
+    if ($mac_addr !~ m/^[a-f0-9][02468ace](?::[a-f0-9]{2}){5}$/i) {
+       return undef if $noerr;
+       die "value does not look like a valid unicast MAC address\n";
+    }
+    return $mac_addr;
+
+}
+register_standard_option('mac-addr', {
+    type => 'string',
+    description => 'Unicast MAC address.',
+    verbose_description => 'A common MAC address with the I/G (Individual/Group) bit not set.',
+    format_description => "XX:XX:XX:XX:XX:XX",
+    optional => 1,
+    format => 'mac-addr',
+});
+
 register_format('ipv4', \&pve_verify_ipv4);
 sub pve_verify_ipv4 {
     my ($ipv4, $noerr) = @_;
@@ -229,7 +405,21 @@ sub pve_verify_ip {
     return $ip;
 }
 
+PVE::JSONSchema::register_format('ldap-simple-attr', \&verify_ldap_simple_attr);
+sub verify_ldap_simple_attr {
+    my ($attr, $noerr) = @_;
+
+    if ($attr =~ m/^[a-zA-Z0-9]+$/) {
+       return $attr;
+    }
+
+    die "value '$attr' does not look like a simple ldap attribute name\n" if !$noerr;
+
+    return undef;
+}
+
 my $ipv4_mask_hash = {
+    '0.0.0.0' => 0,
     '128.0.0.0' => 1,
     '192.0.0.0' => 2,
     '224.0.0.0' => 3,
@@ -264,6 +454,11 @@ my $ipv4_mask_hash = {
     '255.255.255.255' => 32,
 };
 
+sub get_netmask_bits {
+    my ($mask) = @_;
+    return $ipv4_mask_hash->{$mask};
+}
+
 register_format('ipv4mask', \&pve_verify_ipv4mask);
 sub pve_verify_ipv4mask {
     my ($mask, $noerr) = @_;
@@ -337,14 +532,25 @@ register_format('email', \&pve_verify_email);
 sub pve_verify_email {
     my ($email, $noerr) = @_;
 
-    # we use same regex as in Utils.js
-    if ($email !~ /^(\w+)([\-+.][\w]+)*@(\w[\-\w]*\.){1,5}([A-Za-z]){2,63}$/) {
+    if ($email !~ /^$PVE::Tools::EMAIL_RE$/) {
           return undef if $noerr;
           die "value does not look like a valid email address\n";
     }
     return $email;
 }
 
+register_format('email-or-username', \&pve_verify_email_or_username);
+sub pve_verify_email_or_username {
+    my ($email, $noerr) = @_;
+
+    if ($email !~ /^$PVE::Tools::EMAIL_RE$/ &&
+       $email !~ /^$PVE::Tools::EMAIL_USER_RE$/) {
+          return undef if $noerr;
+          die "value does not look like a valid email address or user name\n";
+    }
+    return $email;
+}
+
 register_format('dns-name', \&pve_verify_dns_name);
 sub pve_verify_dns_name {
     my ($name, $noerr) = @_;
@@ -358,14 +564,33 @@ sub pve_verify_dns_name {
     return $name;
 }
 
+register_format('timezone', \&pve_verify_timezone);
+sub pve_verify_timezone {
+    my ($timezone, $noerr) = @_;
+
+    return $timezone if $timezone eq 'UTC';
+
+    open(my $fh, "<",  "/usr/share/zoneinfo/zone.tab");
+    while (my $line = <$fh>) {
+       next if $line =~ /^\s*#/;
+       chomp $line;
+       my $zone = (split /\t/, $line)[2];
+       return $timezone if $timezone eq $zone; # found
+    }
+    close $fh;
+
+    return undef if $noerr;
+    die "invalid time zone '$timezone'\n";
+}
+
 # network interface name
 register_format('pve-iface', \&pve_verify_iface);
 sub pve_verify_iface {
     my ($id, $noerr) = @_;
+
     if ($id !~ m/^[a-z][a-z0-9_]{1,20}([:\.]\d+)?$/i) {
        return undef if $noerr;
-       die "invalid network interface name '$id'\n"; 
+       die "invalid network interface name '$id'\n";
     }
     return $id;
 }
@@ -395,9 +620,9 @@ sub pve_verify_disk_size {
 }
 
 register_standard_option('spice-proxy', {
-    description => "SPICE proxy server. This can be used by the client to specify the proxy server. All nodes in a cluster runs 'spiceproxy', so it is up to the client to choose one. By default, we return the node where the VM is currently running. As resonable setting is to use same node you use to connect to the API (This is window.location.hostname for the JS GUI).",
+    description => "SPICE proxy server. This can be used by the client to specify the proxy server. All nodes in a cluster runs 'spiceproxy', so it is up to the client to choose one. By default, we return the node where the VM is currently running. As reasonable setting is to use same node you use to connect to the API (This is window.location.hostname for the JS GUI).",
     type => 'string', format => 'address',
-}); 
+});
 
 register_standard_option('remote-viewer-config', {
     description => "Returned values can be directly passed to the 'remote-viewer' application.",
@@ -431,33 +656,79 @@ my %bwlimit_opt = (
 my $bwlimit_format = {
        default => {
            %bwlimit_opt,
-           description => 'default bandwidth limit in MiB/s',
+           description => 'default bandwidth limit in KiB/s',
        },
        restore => {
            %bwlimit_opt,
-           description => 'bandwidth limit in MiB/s for restoring guests from backups',
+           description => 'bandwidth limit in KiB/s for restoring guests from backups',
        },
        migration => {
            %bwlimit_opt,
-           description => 'bandwidth limit in MiB/s for migrating guests',
+           description => 'bandwidth limit in KiB/s for migrating guests (including moving local disks)',
        },
        clone => {
            %bwlimit_opt,
-           description => 'bandwidth limit in MiB/s for cloning disks',
+           description => 'bandwidth limit in KiB/s for cloning disks',
        },
        move => {
            %bwlimit_opt,
-           description => 'bandwidth limit in MiB/s for moving disks',
+           description => 'bandwidth limit in KiB/s for moving disks',
        },
 };
 register_format('bwlimit', $bwlimit_format);
 register_standard_option('bwlimit', {
-    description => "Set bandwidth/io limits various operations.",
+    description => "Set I/O bandwidth limit for various operations (in KiB/s).",
     optional => 1,
     type => 'string',
     format => $bwlimit_format,
 });
 
+my $remote_format = {
+    host => {
+       type => 'string',
+       description => 'Remote Proxmox hostname or IP',
+       format_description => 'ADDRESS',
+    },
+    port => {
+       type => 'integer',
+       optional => 1,
+       description => 'Port to connect to',
+       format_description => 'PORT',
+    },
+    apitoken => {
+       type => 'string',
+       description => 'A full Proxmox API token including the secret value.',
+       format_description => 'PVEAPIToken=user@realm!token=SECRET',
+    },
+    fingerprint => get_standard_option(
+       'fingerprint-sha256',
+       {
+           optional => 1,
+           description => 'Remote host\'s certificate fingerprint, if not trusted by system store.',
+           format_description => 'FINGERPRINT',
+       }
+    ),
+};
+register_format('proxmox-remote', $remote_format);
+register_standard_option('proxmox-remote', {
+    description => "Specification of a remote endpoint.",
+    type => 'string', format => 'proxmox-remote',
+});
+
+our $PVE_TAG_RE = qr/[a-z0-9_][a-z0-9_\-\+\.]*/i;
+
+# used for pve-tag-list in e.g., guest configs
+register_format('pve-tag', \&pve_verify_tag);
+sub pve_verify_tag {
+    my ($value, $noerr) = @_;
+
+    return $value if $value =~ m/^${PVE_TAG_RE}$/i;
+
+    return undef if $noerr;
+
+    die "invalid characters in tag\n";
+}
+
 sub pve_parse_startup_order {
     my ($value) = @_;
 
@@ -489,43 +760,83 @@ PVE::JSONSchema::register_standard_option('pve-startup-order', {
     typetext => '[[order=]\d+] [,up=\d+] [,down=\d+] ',
 });
 
-sub check_format {
-    my ($format, $value, $path) = @_;
+register_format('pve-tfa-secret', \&pve_verify_tfa_secret);
+sub pve_verify_tfa_secret {
+    my ($key, $noerr) = @_;
 
-    return parse_property_string($format, $value, $path) if ref($format) eq 'HASH';
-    return if $format eq 'regex';
+    # The old format used 16 base32 chars or 40 hex digits. Since they have a common subset it's
+    # hard to distinguish them without the our previous length constraints, so add a 'v2' of the
+    # format to support arbitrary lengths properly:
+    if ($key =~ /^v2-0x[0-9a-fA-F]{16,128}$/ || # hex
+        $key =~ /^v2-[A-Z2-7=]{16,128}$/ ||     # base32
+        $key =~ /^(?:[A-Z2-7=]{16}|[A-Fa-f0-9]{40})$/) # and the old pattern copy&pasted
+    {
+       return $key;
+    }
 
-    if ($format =~ m/^(.*)-a?list$/) {
-       
-       my $code = $format_list->{$1};
+    return undef if $noerr;
 
-       die "undefined format '$format'\n" if !$code;
+    die "unable to decode TFA secret\n";
+}
 
-       # Note: we allow empty lists
-       foreach my $v (split_list($value)) {
-           &$code($v);
-       }
 
-    } elsif ($format =~ m/^(.*)-opt$/) {
+PVE::JSONSchema::register_format('pve-task-status-type', \&verify_task_status_type);
+sub verify_task_status_type {
+    my ($value, $noerr) = @_;
+
+    return $value if $value =~ m/^(ok|error|warning|unknown)$/i;
 
-       my $code = $format_list->{$1};
+    return undef if $noerr;
 
-       die "undefined format '$format'\n" if !$code;
+    die "invalid status '$value'\n";
+}
 
-       return if !$value; # allow empty string
+sub check_format {
+    my ($format, $value, $path) = @_;
+
+    if (ref($format) eq 'HASH') {
+       # hash ref cannot have validator/list/opt handling attached
+       return parse_property_string($format, $value, $path);
+    }
 
-       &$code($value);
+    if (ref($format) eq 'CODE') {
+       # we are the (sole, old-style) validator
+       return $format->($value);
+    }
 
-   } else {
+    return if $format eq 'regex';
 
-       my $code = $format_list->{$format};
+    my $parsed;
+    $format =~ m/^(.*?)(?:-(list|opt))?$/;
+    my ($format_name, $format_type) = ($1, $2 // 'none');
+    my $registered = get_format($format_name);
+    die "undefined format '$format'\n" if !$registered;
 
-       die "undefined format '$format'\n" if !$code;
+    die "'-$format_type' format must have code ref, not hash\n"
+       if $format_type ne 'none' && ref($registered) ne 'CODE';
 
-       return parse_property_string($code, $value, $path) if ref($code) eq 'HASH';
-       &$code($value);
+    if ($format_type eq 'list') {
+       $parsed = [];
+       # Note: we allow empty lists
+       foreach my $v (split_list($value)) {
+           push @{$parsed}, $registered->($v);
+       }
+    } elsif ($format_type eq 'opt') {
+       $parsed = $registered->($value) if $value;
+    } else {
+       if (ref($registered) eq 'HASH') {
+           # Note: this is the only case where a validator function could be
+           # attached, hence it's safe to handle that in parse_property_string.
+           # We do however have to call it with $format_name instead of
+           # $registered, so it knows about the name (and thus any validators).
+           $parsed = parse_property_string($format, $value, $path);
+       } else {
+           $parsed = $registered->($value);
+       }
     }
-} 
+
+    return $parsed;
+}
 
 sub parse_size {
     my ($value) = @_;
@@ -580,9 +891,16 @@ sub parse_property_string {
     $additional_properties = 0 if !defined($additional_properties);
 
     # Support named formats here, too:
+    my $validator;
     if (!ref($format)) {
-       if (my $desc = $format_list->{$format}) {
-           $format = $desc;
+       if (my $reg = get_format($format)) {
+           die "parse_property_string only accepts hash based named formats\n"
+               if ref($reg) ne 'HASH';
+
+           # named formats can have validators attached
+           $validator = $format_validators->{$format};
+
+           $format = $reg;
        } else {
            die "unknown format: $format\n";
        }
@@ -638,6 +956,7 @@ sub parse_property_string {
        raise "format error\n", errors => $errors;
     }
 
+    return $validator->($res) if $validator;
     return $res;
 }
 
@@ -645,7 +964,7 @@ sub add_error {
     my ($errors, $path, $msg) = @_;
 
     $path = '_root' if !$path;
-    
+
     if ($errors->{$path}) {
        $errors->{$path} = join ('\n', $errors->{$path}, $msg);
     } else {
@@ -657,7 +976,7 @@ sub is_number {
     my $value = shift;
 
     # see 'man perlretut'
-    return $value =~ /^[+-]?(\d+\.\d+|\d+\.|\.\d+|\d+)([eE][+-]?\d+)?$/; 
+    return $value =~ /^[+-]?(\d+\.\d+|\d+\.|\.\d+|\d+)([eE][+-]?\d+)?$/;
 }
 
 sub is_integer {
@@ -673,7 +992,7 @@ sub check_type {
 
     if (!defined($value)) {
        return 1 if $type eq 'null';
-       die "internal error" 
+       die "internal error"
     }
 
     if (my $tt = ref($type)) {
@@ -681,16 +1000,16 @@ sub check_type {
            foreach my $t (@$type) {
                my $tmperr = {};
                check_type($path, $t, $value, $tmperr);
-               return 1 if !scalar(%$tmperr); 
+               return 1 if !scalar(%$tmperr);
            }
            my $ttext = join ('|', @$type);
-           add_error($errors, $path, "type check ('$ttext') failed"); 
+           add_error($errors, $path, "type check ('$ttext') failed");
            return undef;
        } elsif ($tt eq 'HASH') {
            my $tmperr = {};
            check_prop($value, $type, $path, $tmperr);
-           return 1 if !scalar(%$tmperr); 
-           add_error($errors, $path, "type check failed");         
+           return 1 if !scalar(%$tmperr);
+           add_error($errors, $path, "type check failed");
            return undef;
        } else {
            die "internal error - got reference type '$tt'";
@@ -733,6 +1052,9 @@ sub check_type {
            return 1;
        } else {
            if ($vt) {
+               if ($type eq 'boolean' && JSON::is_bool($value)) {
+                   return 1;
+               }
                add_error($errors, $path, "type check ('$type') failed - got $vt");
                return undef;
            } else {
@@ -766,7 +1088,17 @@ sub check_type {
                }
            }
        }
-    }  
+    }
+
+    return undef;
+}
+
+my sub get_instance_type {
+    my ($schema, $key, $value) = @_;
+
+    if (my $type_property = $schema->{$key}->{'type-property'}) {
+       return $value->{$type_property};
+    }
 
     return undef;
 }
@@ -789,7 +1121,8 @@ sub check_object {
     }
 
     foreach my $k (keys %$schema) {
-       check_prop($value->{$k}, $schema->{$k}, $path ? "$path.$k" : $k, $errors);
+       my $instance_type = get_instance_type($schema, $k, $value);
+       check_prop($value->{$k}, $schema->{$k}, $path ? "$path.$k" : $k, $errors, $instance_type);
     }
 
     foreach my $k (keys %$value) {
@@ -802,12 +1135,28 @@ sub check_object {
                    #print "TEST: " . Dumper($value) . "\n", Dumper($requires) ;
                    check_prop($value, $requires, $path, $errors);
                } elsif (!defined($value->{$requires})) {
-                   add_error($errors, $path ? "$path.$requires" : $requires, 
+                   add_error($errors, $path ? "$path.$requires" : $requires,
                              "missing property - '$newpath' requires this property");
                }
            }
 
-           next; # value is already checked above
+           # if it's a oneOf, check if there is a matching type
+           my $matched_type = 1;
+           if ($subschema->{oneOf}) {
+               my $instance_type = get_instance_type($schema, $k, $value);
+               $matched_type = 0;
+               for my $alternative ($subschema->{oneOf}->@*) {
+                   if (my $instance_types = $alternative->{'instance-types'}) {
+                       if (!grep { $instance_type eq $_ } $instance_types->@*) {
+                           next;
+                       }
+                   }
+                   $matched_type = 1;
+                   last;
+               }
+           }
+
+           next if $matched_type; # value is already checked above
        }
 
        if (defined ($additional_properties) && !$additional_properties) {
@@ -834,7 +1183,7 @@ sub check_object_warn {
 }
 
 sub check_prop {
-    my ($value, $schema, $path, $errors) = @_;
+    my ($value, $schema, $path, $errors, $instance_type) = @_;
 
     die "internal error - no schema" if !$schema;
     die "internal error" if !$errors;
@@ -847,6 +1196,58 @@ sub check_prop {
        return;
     }
 
+    # must pass any of the given schemas
+    my $optional_for_type = 0;
+    if ($schema->{oneOf}) {
+       # in case we have an instance_type given, just check for that variant
+       if ($schema->{'type-property'}) {
+           $optional_for_type = 1;
+           for (my $i = 0; $i < scalar($schema->{oneOf}->@*); $i++) {
+               last if !$instance_type; # treat as optional if we don't have a type
+               my $inner_schema = $schema->{oneOf}->[$i];
+
+               if (!defined($inner_schema->{'instance-types'})) {
+                   add_error($errors, $path, "missing 'instance-types' in oneOf alternative");
+                   return;
+               }
+
+               next if !grep { $_ eq $instance_type } $inner_schema->{'instance-types'}->@*;
+               $optional_for_type = $inner_schema->{optional} // 0;
+               check_prop($value, $inner_schema, $path, $errors);
+           }
+       } else {
+           my $is_valid = 0;
+           my $collected_errors = {};
+           for (my $i = 0; $i < scalar($schema->{oneOf}->@*); $i++) {
+               my $inner_schema = $schema->{oneOf}->[$i];
+               my $inner_errors = {};
+               check_prop($value, $inner_schema, "$path.oneOf[$i]", $inner_errors);
+               if (!$inner_errors->%*) {
+                   $is_valid = 1;
+                   last;
+               }
+
+               for my $inner_path (keys $inner_errors->%*) {
+                   add_error($collected_errors, $inner_path, $inner_errors->{$path});
+               }
+           }
+
+           if (!$is_valid) {
+               for my $inner_path (keys $collected_errors->%*) {
+                   add_error($errors, $inner_path, $collected_errors->{$path});
+               }
+           }
+       }
+    } elsif ($instance_type) {
+       if (!defined($schema->{'instance-types'})) {
+           add_error($errors, $path, "missing 'instance-types'");
+           return;
+       }
+       if (grep { $_ eq $instance_type} $schema->{'instance_types'}->@*) {
+           $optional_for_type = 1;
+       }
+    }
+
     # if it extends another schema, it must pass that schema as well
     if($schema->{extends}) {
        check_prop($value, $schema->{extends}, $path, $errors);
@@ -854,7 +1255,7 @@ sub check_prop {
 
     if (!defined ($value)) {
        return if $schema->{type} && $schema->{type} eq 'null';
-       if (!$schema->{optional} && !$schema->{alias} && !$schema->{group}) {
+       if (!$schema->{optional} && !$schema->{alias} && !$schema->{group} && !$optional_for_type) {
            add_error($errors, $path, "property is missing and it is not optional");
        }
        return;
@@ -886,7 +1287,7 @@ sub check_prop {
                    }
                }
            }
-           return; 
+           return;
        } elsif ($schema->{properties} || $schema->{additionalProperties}) {
            check_object($path, defined($schema->{properties}) ? $schema->{properties} : {},
                         $value, $schema->{additionalProperties}, $errors);
@@ -923,17 +1324,17 @@ sub check_prop {
                return;
            }
        }
-       
+
        if (is_number($value)) {
            if (defined (my $max = $schema->{maximum})) {
-               if ($value > $max) { 
+               if ($value > $max) {
                    add_error($errors, $path, "value must have a maximum value of $max");
                    return;
                }
            }
 
            if (defined (my $min = $schema->{minimum})) {
-               if ($value < $min) { 
+               if ($value < $min) {
                    add_error($errors, $path, "value must have a minimum value of $min");
                    return;
                }
@@ -967,13 +1368,16 @@ sub validate {
     # we can disable that in the final release
     # todo: is there a better/faster way to detect cycles?
     my $cycles = 0;
-    find_cycle($instance, sub { $cycles = 1 });
+    # 'download' responses can contain a filehandle, don't cycle-check that as
+    # it produces a warning
+    my $is_download = ref($instance) eq 'HASH' && exists($instance->{download});
+    find_cycle($instance, sub { $cycles = 1 }) if !$is_download;
     if ($cycles) {
        add_error($errors, undef, "data structure contains recursive cycles");
     } elsif ($schema) {
        check_prop($instance, $schema, '', $errors);
     }
-    
+
     if (scalar(%$errors)) {
        raise $errmsg, code => HTTP_BAD_REQUEST, errors => $errors;
     }
@@ -998,6 +1402,28 @@ my $default_schema_noref = {
            },
            enum => $schema_valid_types,
        },
+       oneOf => {
+           type => 'array',
+           description => "This represents the alternative options for this Schema instance.",
+           optional => 1,
+           items => {
+               type => 'object',
+               description => "A valid option of the properties",
+           },
+       },
+       'instance-types' => {
+           type => 'array',
+           description => "Indicate to which type the parameter (or variant if inside a oneOf) belongs.",
+           optional => 1,
+           items => {
+               type => 'string',
+           },
+       },
+       'type-property' => {
+           type => 'string',
+           description => "The property to check for instance types.",
+           optional => 1,
+       },
        optional => {
            type => "boolean",
            description => "This indicates that the instance property in the instance object is not required.",
@@ -1038,7 +1464,7 @@ my $default_schema_noref = {
            optional => 1,
            minimum => 0,
            default => 0,
-       },      
+       },
        maxLength => {
            type => "integer",
            description => "When the instance value is a string, this indicates maximum length of the string.",
@@ -1081,6 +1507,11 @@ my $default_schema_noref = {
            optional => 1,
            description => "This provides the title of the property",
        },
+       renderer => {
+           type => "string",
+           optional => 1,
+           description => "This is used to provide rendering hints to format cli command output.",
+       },
        requires => {
            type => [ "string", "object" ],
            optional => 1,
@@ -1160,13 +1591,14 @@ my $default_schema_noref = {
            description => "For CLI context, this defines the maximal width to print before truncating",
            optional => 1,
        },
-    }  
+    }
 };
 
 my $default_schema = Storable::dclone($default_schema_noref);
 
 $default_schema->{properties}->{properties}->{additionalProperties} = $default_schema;
 $default_schema->{properties}->{additionalProperties}->{properties} = $default_schema->{properties};
+$default_schema->{properties}->{oneOf}->{items}->{properties} = $default_schema->{properties};
 
 $default_schema->{properties}->{items}->{properties} = $default_schema->{properties};
 $default_schema->{properties}->{items}->{additionalProperties} = 0;
@@ -1203,7 +1635,7 @@ my $method_schema = {
                     path => {},
                     parameters => {},
                     returns => {},
-                }             
+                }
             },
        },
        method => {
@@ -1214,9 +1646,15 @@ my $method_schema = {
        },
         protected => {
             type => 'boolean',
-           description => "Method needs special privileges - only pvedaemon can execute it",            
+           description => "Method needs special privileges - only pvedaemon can execute it",
            optional => 1,
         },
+       allowtoken => {
+           type => 'boolean',
+           description => "Method is available for clients authenticated using an API token.",
+           optional => 1,
+           default => 1,
+       },
         download => {
             type => 'boolean',
            description => "Method downloads the file content (filename is the return value of the method).",
@@ -1229,7 +1667,7 @@ my $method_schema = {
        },
        proxyto_callback => {
            type =>  'coderef',
-           description => "A function which is called to resolve the proxyto attribute. The default implementaion returns the value of the 'proxyto' parameter.",
+           description => "A function which is called to resolve the proxyto attribute. The default implementation returns the value of the 'proxyto' parameter.",
            optional => 1,
        },
         permissions => {
@@ -1243,15 +1681,15 @@ my $method_schema = {
                     optional => 1,
                },
                 user => {
-                    description => "A simply way to allow access for 'all' authenticated users. Value 'world' is used to allow access without credentials.", 
-                    type => 'string', 
+                    description => "A simply way to allow access for 'all' authenticated users. Value 'world' is used to allow access without credentials.",
+                    type => 'string',
                     enum => ['all', 'world'],
                     optional => 1,
                 },
                 check => {
                     description => "Array of permission checks (prefix notation).",
-                    type => 'array', 
-                    optional => 1 
+                    type => 'array',
+                    optional => 1
                 },
             },
         },
@@ -1269,7 +1707,7 @@ my $method_schema = {
        },
         fragmentDelimiter => {
             type => 'string',
-           description => "A ways to override the default fragment delimiter '/'. This onyl works on a whole sub-class. You can set this to the empty string to match the whole rest of the URI.",            
+           description => "A way to override the default fragment delimiter '/'. This only works on a whole sub-class. You can set this to the empty string to match the whole rest of the URI.",
            optional => 1,
         },
        parameters => {
@@ -1284,7 +1722,7 @@ my $method_schema = {
        },
         code => {
            type => 'coderef',
-           description => "method implementaion (code reference)",
+           description => "method implementation (code reference)",
            optional => 1,
         },
        subclass => {
@@ -1299,15 +1737,15 @@ my $method_schema = {
                     match_name => {},
                     match_re => {},
                     fragmentDelimiter => { optional => 1 }
-                }             
+                }
             },
-       }, 
+       },
     },
 
 };
 
 sub validate_schema {
-    my ($schema) = @_; 
+    my ($schema) = @_;
 
     my $errmsg = "internal error - unable to verify schema\n";
     validate($schema, $default_schema, $errmsg);
@@ -1318,13 +1756,13 @@ sub validate_method_info {
 
     my $errmsg = "internal error - unable to verify method info\n";
     validate($info, $method_schema, $errmsg);
+
     validate_schema($info->{parameters}) if $info->{parameters};
     validate_schema($info->{returns}) if $info->{returns};
 }
 
 # run a self test on load
-# make sure we can verify the default schema 
+# make sure we can verify the default schema
 validate_schema($default_schema_noref);
 validate_schema($method_schema);
 
@@ -1351,7 +1789,7 @@ sub method_get_child_link {
     return $found;
 }
 
-# a way to parse command line parameters, using a 
+# a way to parse command line parameters, using a
 # schema to configure Getopt::Long
 sub get_options {
     my ($schema, $args, $arg_param, $fixed_param, $param_mapping_hash) = @_;
@@ -1383,10 +1821,12 @@ sub get_options {
            # optional and call the mapping function afterwards.
            push @getopt, "$prop:s";
            push @interactive, [$prop, $mapping->{func}];
-       } elsif ($pd->{type} eq 'boolean') {
+       } elsif ($pd->{type} && $pd->{type} eq 'boolean') {
            push @getopt, "$prop:s";
        } else {
-           if ($pd->{format} && $pd->{format} =~ m/-a?list/) {
+           if ($pd->{format} && $pd->{format} =~ m/-list/) {
+               push @getopt, "$prop=s@";
+           } elsif ($pd->{type} && $pd->{type} eq 'array') {
                push @getopt, "$prop=s@";
            } else {
                push @getopt, "$prop=s";
@@ -1405,7 +1845,8 @@ sub get_options {
            $opts->{$list_param} = $args;
            $args = [];
        } elsif (ref($arg_param)) {
-           foreach my $arg_name (@$arg_param) {
+           for (my $i = 0; $i < scalar(@$arg_param); $i++) {
+               my $arg_name = $arg_param->[$i];
                if ($opts->{'extra-args'}) {
                    raise("internal error: extra-args must be the last argument\n", code => HTTP_BAD_REQUEST);
                }
@@ -1414,7 +1855,19 @@ sub get_options {
                    $args = [];
                    next;
                }
-               raise("not enough arguments\n", code => HTTP_BAD_REQUEST) if !@$args;
+               if (!@$args) {
+                   # check if all left-over arg_param are optional, else we
+                   # must die as the mapping is then ambigious
+                   for (; $i < scalar(@$arg_param); $i++) {
+                       my $prop = $arg_param->[$i];
+                       raise("not enough arguments\n", code => HTTP_BAD_REQUEST)
+                           if !$schema->{properties}->{$prop}->{optional};
+                   }
+                   if ($arg_param->[-1] eq 'extra-args') {
+                       $opts->{'extra-args'} = [];
+                   }
+                   last;
+               }
                $opts->{$arg_name} = shift @$args;
            }
            raise("too many arguments\n", code => HTTP_BAD_REQUEST) if @$args;
@@ -1427,7 +1880,7 @@ sub get_options {
            foreach my $arg_name (@$arg_param) {
                if ($arg_name eq 'extra-args') {
                    $opts->{'extra-args'} = [];
-               } else {
+               } elsif (!$schema->{properties}->{$arg_name}->{optional}) {
                    raise("not enough arguments\n", code => HTTP_BAD_REQUEST);
                }
            }
@@ -1462,7 +1915,7 @@ sub get_options {
 
     foreach my $p (keys %$opts) {
        if (my $pd = $schema->{properties}->{$p}) {
-           if ($pd->{type} eq 'boolean') {
+           if ($pd->{type} && $pd->{type} eq 'boolean') {
                if ($opts->{$p} eq '') {
                    $opts->{$p} = 1;
                } elsif (defined(my $bool = parse_boolean($opts->{$p}))) {
@@ -1476,19 +1929,9 @@ sub get_options {
                    # allow --vmid 100 --vmid 101 and --vmid 100,101
                    # allow --dow mon --dow fri and --dow mon,fri
                    $opts->{$p} = join(",", @{$opts->{$p}}) if ref($opts->{$p}) eq 'ARRAY';
-               } elsif ($pd->{format} =~ m/-alist/) {
-                   # we encode array as \0 separated strings
-                   # Note: CGI.pm also use this encoding
-                   if (scalar(@{$opts->{$p}}) != 1) {
-                       $opts->{$p} = join("\0", @{$opts->{$p}});
-                   } else {
-                       # st that split_list knows it is \0 terminated
-                       my $v = $opts->{$p}->[0];
-                       $opts->{$p} = "$v\0";
-                   }
                }
            }
-       }       
+       }
     }
 
     foreach my $p (keys %$fixed_param) {
@@ -1499,41 +1942,68 @@ sub get_options {
 }
 
 # A way to parse configuration data by giving a json schema
-sub parse_config {
-    my ($schema, $filename, $raw) = @_;
+sub parse_config : prototype($$$;$) {
+    my ($schema, $filename, $raw, $comment_key) = @_;
 
     # do fast check (avoid validate_schema($schema))
-    die "got strange schema" if !$schema->{type} || 
+    die "got strange schema" if !$schema->{type} ||
        !$schema->{properties} || $schema->{type} ne 'object';
 
     my $cfg = {};
 
+    my $comment_data;
+    my $handle_comment = sub { $_[0] =~ /^#/ };
+    if (defined($comment_key)) {
+       $comment_data = '';
+       my $comment_re = qr/^\Q$comment_key\E:\s*(.*\S)\s*$/;
+       $handle_comment = sub {
+           if ($_[0] =~ /^\#(.*)\s*$/ || $_[0] =~ $comment_re) {
+               $comment_data .= PVE::Tools::decode_text($1) . "\n";
+               return 1;
+           }
+           return undef;
+       };
+    }
+
     while ($raw =~ /^\s*(.+?)\s*$/gm) {
        my $line = $1;
 
-       next if $line =~ /^#/;
+       next if $handle_comment->($line);
 
        if ($line =~ m/^(\S+?):\s*(.*)$/) {
            my $key = $1;
            my $value = $2;
-           if ($schema->{properties}->{$key} && 
+           if ($schema->{properties}->{$key} &&
                $schema->{properties}->{$key}->{type} eq 'boolean') {
 
                $value = parse_boolean($value) // $value;
            }
+           if (
+               $schema->{properties}->{$key}
+               && $schema->{properties}->{$key}->{type} eq 'array'
+           ) {
+
+               $cfg->{$key} //= [];
+               push $cfg->{$key}->@*, $value;
+               next;
+           }
            $cfg->{$key} = $value;
        } else {
            warn "ignore config line: $line\n"
        }
     }
 
+    if (defined($comment_data)) {
+       $cfg->{$comment_key} = $comment_data;
+    }
+
     my $errors = {};
     check_prop($cfg, $schema, '', $errors);
 
     foreach my $k (keys %$errors) {
        warn "parse error in '$filename' - '$k': $errors->{$k}\n";
        delete $cfg->{$k};
-    } 
+    }
 
     return $cfg;
 }
@@ -1543,14 +2013,14 @@ sub dump_config {
     my ($schema, $filename, $cfg) = @_;
 
     # do fast check (avoid validate_schema($schema))
-    die "got strange schema" if !$schema->{type} || 
+    die "got strange schema" if !$schema->{type} ||
        !$schema->{properties} || $schema->{type} ne 'object';
 
     validate($cfg, $schema, "validation error in '$filename'\n");
 
     my $data = '';
 
-    foreach my $k (keys %$cfg) {
+    foreach my $k (sort keys %$cfg) {
        $data .= "$k: $cfg->{$k}\n";
     }
 
@@ -1689,9 +2159,12 @@ sub generate_typetext {
 sub print_property_string {
     my ($data, $format, $skip, $path) = @_;
 
+    my $validator;
     if (ref($format) ne 'HASH') {
        my $schema = get_format($format);
        die "not a valid format: $format\n" if !$schema;
+       # named formats can have validators attached
+       $validator = $format_validators->{$format};
        $format = $schema;
     }
 
@@ -1701,6 +2174,8 @@ sub print_property_string {
        raise "format error", errors => $errors;
     }
 
+    $data = $validator->($data) if $validator;
+
     my ($default_key, $keyAliasProps) = &$find_schema_default_key($format);
 
     my $res = '';