X-Git-Url: https://git.proxmox.com/?p=pve-common.git;a=blobdiff_plain;f=src%2FPVE%2FJSONSchema.pm;h=41a66528681e1c40812531798826462bb90c3c48;hp=cde941e2f4592890e42abe170ed8d31649c879e9;hb=4842b6510546f76906b216cb05d98ec9768f9e8e;hpb=445e8267b2a81ce901b059331d64d4774e83caec diff --git a/src/PVE/JSONSchema.pm b/src/PVE/JSONSchema.pm index cde941e..41a6652 100644 --- a/src/PVE/JSONSchema.pm +++ b/src/PVE/JSONSchema.pm @@ -4,11 +4,14 @@ use strict; use warnings; use Storable; # for dclone use Getopt::Long; +use Encode::Locale; +use Encode; 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 Net::IP qw(:PROC); +use Data::Dumper; use base 'Exporter'; @@ -83,6 +86,12 @@ register_standard_option('pve-config-digest', { maxLength => 40, # sha1 hex digest lenght is 40 }); +register_standard_option('skiplock', { + description => "Ignore locks - only root is allowed to use this option.", + type => 'boolean', + optional => 1, +}); + register_standard_option('extra-args', { description => "Extra arguments as array", type => 'array', @@ -90,6 +99,12 @@ register_standard_option('extra-args', { optional => 1 }); +register_standard_option('fingerprint-sha256', { + description => "Certificate SHA 256 fingerprint.", + type => 'string', + pattern => '([A-Fa-f0-9]{2}:){31}[A-Fa-f0-9]{2}', +}); + my $format_list = {}; sub register_format { @@ -228,7 +243,9 @@ my $ipv4_mask_hash = { '255.255.255.224' => 27, '255.255.255.240' => 28, '255.255.255.248' => 29, - '255.255.255.252' => 30 + '255.255.255.252' => 30, + '255.255.255.254' => 31, + '255.255.255.255' => 32, }; register_format('ipv4mask', \&pve_verify_ipv4mask); @@ -246,7 +263,7 @@ register_format('CIDRv6', \&pve_verify_cidrv6); sub pve_verify_cidrv6 { my ($cidr, $noerr) = @_; - if ($cidr =~ m!^(?:$IPV6RE)(?:/(\d+))$! && ($1 > 7) && ($1 <= 120)) { + if ($cidr =~ m!^(?:$IPV6RE)(?:/(\d+))$! && ($1 > 7) && ($1 <= 128)) { return $cidr; } @@ -389,6 +406,42 @@ sub pve_verify_startup_order { die "unable to parse startup options\n"; } +my %bwlimit_opt = ( + optional => 1, + type => 'number', minimum => '0', + format_description => 'LIMIT', +); + +my $bwlimit_format = { + default => { + %bwlimit_opt, + description => 'default bandwidth limit in MiB/s', + }, + restore => { + %bwlimit_opt, + description => 'bandwidth limit in MiB/s for restoring guests from backups', + }, + migration => { + %bwlimit_opt, + description => 'bandwidth limit in MiB/s for migrating guests', + }, + clone => { + %bwlimit_opt, + description => 'bandwidth limit in MiB/s for cloning disks', + }, + move => { + %bwlimit_opt, + description => 'bandwidth limit in MiB/s for moving disks', + }, +}; +register_format('bwlimit', $bwlimit_format); +register_standard_option('bwlimit', { + description => "Set bandwidth/io limits various operations.", + optional => 1, + type => 'string', + format => $bwlimit_format, +}); + sub pve_parse_startup_order { my ($value) = @_; @@ -497,12 +550,30 @@ sub format_size { return "${tb}T"; }; +sub parse_boolean { + my ($bool) = @_; + return 1 if $bool =~ m/^(1|on|yes|true)$/i; + return 0 if $bool =~ m/^(0|off|no|false)$/i; + return undef; +} + sub parse_property_string { my ($format, $data, $path, $additional_properties) = @_; # In property strings we default to not allowing additional properties $additional_properties = 0 if !defined($additional_properties); + # Support named formats here, too: + if (!ref($format)) { + if (my $desc = $format_list->{$format}) { + $format = $desc; + } else { + die "unknown format: $format\n"; + } + } elsif (ref($format) ne 'HASH') { + die "unexpected format value of type ".ref($format)."\n"; + } + my $default_key; my $res = {}; @@ -513,20 +584,18 @@ sub parse_property_string { my ($k, $v) = ($1, $2); die "duplicate key in comma-separated list property: $k\n" if defined($res->{$k}); my $schema = $format->{$k}; - if (my $group = $schema->{group}) { - die "keys $res->{$group} and $k are part of the same group and cannot be used together\n" - if defined($res->{$group}); - $res->{$group} = $k; - $schema = $format->{$group}; - } if (my $alias = $schema->{alias}) { + if (my $key_alias = $schema->{keyAlias}) { + die "key alias '$key_alias' is already defined\n" if defined($res->{$key_alias}); + $res->{$key_alias} = $k; + } $k = $alias; $schema = $format->{$k}; } + die "invalid key in comma-separated list property: $k\n" if !$schema; if ($schema->{type} && $schema->{type} eq 'boolean') { - $v = 1 if $v =~ m/^(1|on|yes|true)$/i; - $v = 0 if $v =~ m/^(0|off|no|false)$/i; + $v = parse_boolean($v) // $v; } $res->{$k} = $v; } elsif ($part !~ /=/) { @@ -556,91 +625,6 @@ sub parse_property_string { return $res; } -sub print_property_string { - my ($data, $format, $skip, $path) = @_; - - if (ref($format) ne 'HASH') { - my $schema = $format_list->{$format}; - die "not a valid format: $format\n" if !$schema; - $format = $schema; - } - - my $errors = {}; - check_object($path, $format, $data, undef, $errors); - if (scalar(%$errors)) { - raise "format error", errors => $errors; - } - - my $default_key; - my %skipped = map { $_ => 1 } @$skip; - my %allowed; - my %required; # this is a set, all present keys are required regardless of value - my %group_for_key; - foreach my $key (keys %$format) { - $allowed{$key} = 1; - my $keyfmt = $format->{$key}; - my $group = $keyfmt->{group}; - if (defined($group)) { - $skipped{$group} = 1; - if (defined(my $grpalias = $format->{$group}->{alias})) { - $group_for_key{$grpalias} = $group; - } else { - $group_for_key{$key} = $group; - } - } - if (!$keyfmt->{optional} && !$keyfmt->{alias} && !defined($group) && !$skipped{$key}) { - $required{$key} = 1; - } - - # Skip default keys - if ($keyfmt->{default_key}) { - if ($default_key) { - warn "multiple default keys in schema ($default_key, $key)\n"; - } else { - $default_key = $key; - $skipped{$key} = 1; - } - } - } - - my ($text, $comma); - if ($default_key && !defined($format->{$default_key}->{alias})) { - $text = "$data->{$default_key}"; - $comma = ','; - } else { - $text = ''; - $comma = ''; - } - - foreach my $key (sort keys %$data) { - delete $required{$key}; - next if $skipped{$key}; - die "invalid key: $key\n" if !$allowed{$key}; - - my $keyfmt = $format->{$key}; - my $typeformat = $keyfmt->{format}; - my $value = $data->{$key}; - next if !defined($value); - if (my $group = $group_for_key{$key}) { - $key = $data->{$group}; - } - $text .= $comma; - $comma = ','; - if ($typeformat && $typeformat eq 'disk-size') { - $text .= "$key=" . format_size($value); - } else { - die "illegal value with commas for $key\n" if $value =~ /,/; - $text .= "$key=$value"; - } - } - - if (my $missing = join(',', keys %required)) { - die "missing properties: $missing\n"; - } - - return $text; -} - sub add_error { my ($errors, $path, $msg) = @_; @@ -728,6 +712,9 @@ sub check_type { return undef; } return 1; + } elsif ($type eq 'string' && $vt eq 'Regexp') { + # qr// regexes can be used as strings and make sense for format=regex + return 1; } else { if ($vt) { add_error($errors, $path, "type check ('$type') failed - got $vt"); @@ -741,7 +728,7 @@ sub check_type { return 1; #} elsif ($value =~ m/^(0|false|no|off)$/i) { } elsif ($value eq '0') { - return 0; + return 1; # return success (not value) } else { add_error($errors, $path, "type check ('$type') failed - got '$value'"); return undef; @@ -785,31 +772,8 @@ sub check_object { return; } - my %groups; foreach my $k (keys %$schema) { - if (defined(my $group = $schema->{$k}->{group})) { - # When a group is aliased then the key/value pair will match the - # schema, but if it's not then the group key contains the key-name - # which will not match the group key's defined schema and we have - # to match it against that... - if (!defined($schema->{$group}->{alias})) { - $groups{$group} = 1; - } - } - } - foreach my $k (keys %$schema) { - my $orig_key = $k; - my $v; - if ($groups{$k}) { - if (defined($orig_key = $value->{$k})) { - $v = $value->{$orig_key}; - } else { - $orig_key = $k; # now only used for the 'path' parameter - } - } else { - $v = $value->{$k}; - } - check_prop($v, $schema->{$k}, $path ? "$path.$orig_key" : $orig_key, $errors); + check_prop($value->{$k}, $schema->{$k}, $path ? "$path.$k" : $k, $errors); } foreach my $k (keys %$value) { @@ -823,7 +787,7 @@ sub check_object { check_prop($value, $requires, $path, $errors); } elsif (!defined($value->{$requires})) { add_error($errors, $path ? "$path.$requires" : $requires, - "missing property - '$newpath' requiers this property"); + "missing property - '$newpath' requires this property"); } } @@ -1086,6 +1050,11 @@ my $default_schema_noref = { optional => 1, description => "This provides a description of the purpose the instance property. The value can be a string or it can be an object with properties corresponding to various different instance languages (with an optional default property indicating the default description).", }, + verbose_description => { + type => "string", + optional => 1, + description => "This provides a more verbose description.", + }, format_description => { type => "string", optional => 1, @@ -1116,10 +1085,11 @@ my $default_schema_noref = { optional => 1, description => "When a key represents the same property as another it can be an alias to it, causing the parsed datastructure to use the other key to store the current value under.", }, - group => { + keyAlias => { type => 'string', optional => 1, - description => "If a key is part of a group then setting it will additionally set the group name in the resulting data structure to the key used to fill the group. Only one key of a group can be assigned.", + description => "Allows to store the current 'key' as value of another property. Only valid if used together with 'alias'.", + requires => 'alias', }, default => { type => "any", @@ -1169,6 +1139,11 @@ my $default_schema_noref = { }, }, }, + print_width => { + type => "integer", + description => "For CLI context, this defines the maximal width to print before truncating", + optional => 1, + }, } }; @@ -1226,11 +1201,21 @@ my $method_schema = { description => "Method needs special privileges - only pvedaemon can execute it", optional => 1, }, + download => { + type => 'boolean', + description => "Method downloads the file content (filename is the return value of the method).", + optional => 1, + }, proxyto => { type => 'string', description => "A parameter name. If specified, all calls to this method are proxied to the host contained in that parameter.", optional => 1, }, + 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.", + optional => 1, + }, permissions => { type => 'object', description => "Required access permissions. By default only 'root' is allowed to access this method.", @@ -1276,11 +1261,6 @@ my $method_schema = { description => "JSON Schema for parameters.", optional => 1, }, - formatter => { - type => 'object', - description => "Used to store page formatter information (set by PVE::RESTHandler->register_page_formatter).", - optional => 1, - }, returns => { type => 'object', description => "JSON Schema for return value.", @@ -1358,7 +1338,7 @@ sub method_get_child_link { # a way to parse command line parameters, using a # schema to configure Getopt::Long sub get_options { - my ($schema, $args, $arg_param, $fixed_param, $pwcallback) = @_; + my ($schema, $args, $arg_param, $fixed_param, $param_mapping_hash) = @_; if (!$schema || !$schema->{properties}) { raise("too many arguments\n", code => HTTP_BAD_REQUEST) @@ -1374,17 +1354,19 @@ sub get_options { $list_param = $arg_param; } + my @interactive = (); my @getopt = (); foreach my $prop (keys %{$schema->{properties}}) { my $pd = $schema->{properties}->{$prop}; next if $list_param && $prop eq $list_param; next if defined($fixed_param->{$prop}); - if ($prop eq 'password' && $pwcallback) { - # we do not accept plain password on input line, instead - # we turn this into a boolean option and ask for password below - # using $pwcallback() (for security reasons). - push @getopt, "$prop"; + my $mapping = $param_mapping_hash->{$prop}; + if ($mapping && $mapping->{interactive}) { + # interactive parameters such as passwords: make the argument + # optional and call the mapping function afterwards. + push @getopt, "$prop:s"; + push @interactive, [$prop, $mapping->{func}]; } elsif ($pd->{type} eq 'boolean') { push @getopt, "$prop:s"; } else { @@ -1424,27 +1406,51 @@ sub get_options { raise("too many arguments\n", code => HTTP_BAD_REQUEST) if scalar(@$args) != 0; } + } else { + if (ref($arg_param)) { + foreach my $arg_name (@$arg_param) { + if ($arg_name eq 'extra-args') { + $opts->{'extra-args'} = []; + } else { + raise("not enough arguments\n", code => HTTP_BAD_REQUEST); + } + } + } } - if (my $pd = $schema->{properties}->{password}) { - if ($pd->{type} ne 'boolean' && $pwcallback) { - if ($opts->{password} || !$pd->{optional}) { - $opts->{password} = &$pwcallback(); - } + foreach my $entry (@interactive) { + my ($opt, $func) = @$entry; + my $pd = $schema->{properties}->{$opt}; + my $value = $opts->{$opt}; + if (defined($value) || !$pd->{optional}) { + $opts->{$opt} = $func->($value); } } - $opts = PVE::Tools::decode_utf8_parameters($opts); + # decode after Getopt as we are not sure how well it handles unicode + foreach my $p (keys %$opts) { + if (!ref($opts->{$p})) { + $opts->{$p} = decode('locale', $opts->{$p}); + } elsif (ref($opts->{$p}) eq 'ARRAY') { + my $tmp = []; + foreach my $v (@{$opts->{$p}}) { + push @$tmp, decode('locale', $v); + } + $opts->{$p} = $tmp; + } elsif (ref($opts->{$p}) eq 'SCALAR') { + $opts->{$p} = decode('locale', $$opts->{$p}); + } else { + raise("decoding options failed, unknown reference\n", code => HTTP_BAD_REQUEST); + } + } foreach my $p (keys %$opts) { if (my $pd = $schema->{properties}->{$p}) { if ($pd->{type} eq 'boolean') { if ($opts->{$p} eq '') { $opts->{$p} = 1; - } elsif ($opts->{$p} =~ m/^(1|true|yes|on)$/i) { - $opts->{$p} = 1; - } elsif ($opts->{$p} =~ m/^(0|false|no|off)$/i) { - $opts->{$p} = 0; + } elsif (defined(my $bool = parse_boolean($opts->{$p}))) { + $opts->{$p} = $bool; } else { raise("unable to parse boolean option\n", code => HTTP_BAD_REQUEST); } @@ -1497,8 +1503,7 @@ sub parse_config { if ($schema->{properties}->{$key} && $schema->{properties}->{$key}->{type} eq 'boolean') { - $value = 1 if $value =~ m/^(1|on|yes|true)$/i; - $value = 0 if $value =~ m/^(0|off|no|false)$/i; + $value = parse_boolean($value) // $value; } $cfg->{$key} = $value; } else { @@ -1536,4 +1541,268 @@ sub dump_config { return $data; } +# helpers used to generate our manual pages + +my $find_schema_default_key = sub { + my ($format) = @_; + + my $default_key; + my $keyAliasProps = {}; + + foreach my $key (keys %$format) { + my $phash = $format->{$key}; + if ($phash->{default_key}) { + die "multiple default keys in schema ($default_key, $key)\n" + if defined($default_key); + die "default key '$key' is an alias - this is not allowed\n" + if defined($phash->{alias}); + die "default key '$key' with keyAlias attribute is not allowed\n" + if $phash->{keyAlias}; + $default_key = $key; + } + my $key_alias = $phash->{keyAlias}; + die "found keyAlias without 'alias definition for '$key'\n" + if $key_alias && !$phash->{alias}; + + if ($phash->{alias} && $key_alias) { + die "inconsistent keyAlias '$key_alias' definition" + if defined($keyAliasProps->{$key_alias}) && + $keyAliasProps->{$key_alias} ne $phash->{alias}; + $keyAliasProps->{$key_alias} = $phash->{alias}; + } + } + + return wantarray ? ($default_key, $keyAliasProps) : $default_key; +}; + +sub generate_typetext { + my ($format, $list_enums) = @_; + + my ($default_key, $keyAliasProps) = &$find_schema_default_key($format); + + my $res = ''; + my $add_sep = 0; + + my $add_option_string = sub { + my ($text, $optional) = @_; + + if ($add_sep) { + $text = ",$text"; + $res .= ' '; + } + $text = "[$text]" if $optional; + $res .= $text; + $add_sep = 1; + }; + + my $format_key_value = sub { + my ($key, $phash) = @_; + + die "internal error" if defined($phash->{alias}); + + my $keytext = $key; + + my $typetext = ''; + + if (my $desc = $phash->{format_description}) { + $typetext .= "<$desc>"; + } elsif (my $text = $phash->{typetext}) { + $typetext .= $text; + } elsif (my $enum = $phash->{enum}) { + if ($list_enums || (scalar(@$enum) <= 3)) { + $typetext .= '<' . join('|', @$enum) . '>'; + } else { + $typetext .= ''; + } + } elsif ($phash->{type} eq 'boolean') { + $typetext .= '<1|0>'; + } elsif ($phash->{type} eq 'integer') { + $typetext .= ''; + } elsif ($phash->{type} eq 'number') { + $typetext .= ''; + } else { + die "internal error: neither format_description nor typetext found for option '$key'"; + } + + if (defined($default_key) && ($default_key eq $key)) { + &$add_option_string("[$keytext=]$typetext", $phash->{optional}); + } else { + &$add_option_string("$keytext=$typetext", $phash->{optional}); + } + }; + + my $done = {}; + + my $cond_add_key = sub { + my ($key) = @_; + + return if $done->{$key}; # avoid duplicates + + $done->{$key} = 1; + + my $phash = $format->{$key}; + + return if !$phash; # should not happen + + return if $phash->{alias}; + + &$format_key_value($key, $phash); + + }; + + &$cond_add_key($default_key) if defined($default_key); + + # add required keys first + foreach my $key (sort keys %$format) { + my $phash = $format->{$key}; + &$cond_add_key($key) if $phash && !$phash->{optional}; + } + + # add the rest + foreach my $key (sort keys %$format) { + &$cond_add_key($key); + } + + foreach my $keyAlias (sort keys %$keyAliasProps) { + &$add_option_string("<$keyAlias>=<$keyAliasProps->{$keyAlias }>", 1); + } + + return $res; +} + +sub print_property_string { + my ($data, $format, $skip, $path) = @_; + + if (ref($format) ne 'HASH') { + my $schema = get_format($format); + die "not a valid format: $format\n" if !$schema; + $format = $schema; + } + + my $errors = {}; + check_object($path, $format, $data, undef, $errors); + if (scalar(%$errors)) { + raise "format error", errors => $errors; + } + + my ($default_key, $keyAliasProps) = &$find_schema_default_key($format); + + my $res = ''; + my $add_sep = 0; + + my $add_option_string = sub { + my ($text) = @_; + + $res .= ',' if $add_sep; + $res .= $text; + $add_sep = 1; + }; + + my $format_value = sub { + my ($key, $value, $format) = @_; + + if (defined($format) && ($format eq 'disk-size')) { + return format_size($value); + } else { + die "illegal value with commas for $key\n" if $value =~ /,/; + return $value; + } + }; + + my $done = { map { $_ => 1 } @$skip }; + + my $cond_add_key = sub { + my ($key, $isdefault) = @_; + + return if $done->{$key}; # avoid duplicates + + $done->{$key} = 1; + + my $value = $data->{$key}; + + return if !defined($value); + + my $phash = $format->{$key}; + + # try to combine values if we have key aliases + if (my $combine = $keyAliasProps->{$key}) { + if (defined(my $combine_value = $data->{$combine})) { + my $combine_format = $format->{$combine}->{format}; + my $value_str = &$format_value($key, $value, $phash->{format}); + my $combine_str = &$format_value($combine, $combine_value, $combine_format); + &$add_option_string("${value_str}=${combine_str}"); + $done->{$combine} = 1; + return; + } + } + + if ($phash && $phash->{alias}) { + $phash = $format->{$phash->{alias}}; + } + + die "invalid key '$key'\n" if !$phash; + die "internal error" if defined($phash->{alias}); + + my $value_str = &$format_value($key, $value, $phash->{format}); + if ($isdefault) { + &$add_option_string($value_str); + } else { + &$add_option_string("$key=${value_str}"); + } + }; + + # add default key first + &$cond_add_key($default_key, 1) if defined($default_key); + + # add required keys first + foreach my $key (sort keys %$data) { + my $phash = $format->{$key}; + &$cond_add_key($key) if $phash && !$phash->{optional}; + } + + # add the rest + foreach my $key (sort keys %$data) { + &$cond_add_key($key); + } + + return $res; +} + +sub schema_get_type_text { + my ($phash, $style) = @_; + + my $type = $phash->{type} || 'string'; + + if ($phash->{typetext}) { + return $phash->{typetext}; + } elsif ($phash->{format_description}) { + return "<$phash->{format_description}>"; + } elsif ($phash->{enum}) { + return "<" . join(' | ', sort @{$phash->{enum}}) . ">"; + } elsif ($phash->{pattern}) { + return $phash->{pattern}; + } elsif ($type eq 'integer' || $type eq 'number') { + # NOTE: always access values as number (avoid converion to string) + if (defined($phash->{minimum}) && defined($phash->{maximum})) { + return "<$type> (" . ($phash->{minimum} + 0) . " - " . + ($phash->{maximum} + 0) . ")"; + } elsif (defined($phash->{minimum})) { + return "<$type> (" . ($phash->{minimum} + 0) . " - N)"; + } elsif (defined($phash->{maximum})) { + return "<$type> (-N - " . ($phash->{maximum} + 0) . ")"; + } + } elsif ($type eq 'string') { + if (my $format = $phash->{format}) { + $format = get_format($format) if ref($format) ne 'HASH'; + if (ref($format) eq 'HASH') { + my $list_enums = 0; + $list_enums = 1 if $style && $style eq 'config-sub'; + return generate_typetext($format, $list_enums); + } + } + } + + return "<$type>"; +} + 1;