]> git.proxmox.com Git - pve-common.git/blobdiff - src/PVE/JSONSchema.pm
update changelog
[pve-common.git] / src / PVE / JSONSchema.pm
index c925ecbad664164e4670d51e2a5002fa48d0725e..cde941e2f4592890e42abe170ed8d31649c879e9 100644 (file)
@@ -71,19 +71,19 @@ register_standard_option('pve-iface', {
     minLength => 2, maxLength => 20,
 });
 
-PVE::JSONSchema::register_standard_option('pve-storage-id', {
+register_standard_option('pve-storage-id', {
     description => "The storage identifier.",
     type => 'string', format => 'pve-storage-id',
 }); 
 
-PVE::JSONSchema::register_standard_option('pve-config-digest', {
+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.',
     type => 'string',
     optional => 1,
     maxLength => 40, # sha1 hex digest lenght is 40
 });
 
-PVE::JSONSchema::register_standard_option('extra-args', {
+register_standard_option('extra-args', {
     description => "Extra arguments as array",
     type => 'array',
     items => { type => 'string' },
@@ -101,10 +101,25 @@ sub register_format {
     $format_list->{$format} = $code;
 }
 
+sub get_format {
+    my ($format) = @_;
+    return $format_list->{$format};
+}
+
 # register some common type for pve
 
 register_format('string', sub {}); # allow format => 'string-list'
 
+register_format('urlencoded', \&pve_verify_urlencoded);
+sub pve_verify_urlencoded {
+    my ($text, $noerr) = @_;
+    if ($text !~ /^[-%a-zA-Z0-9_.!~*'()]*$/) {
+       return undef if $noerr;
+       die "invalid urlencoded string: $text\n";
+    }
+    return $text;
+}
+
 register_format('pve-configid', \&pve_verify_configid);
 sub pve_verify_configid {
     my ($id, $noerr) = @_;
@@ -132,7 +147,7 @@ register_format('pve-vmid', \&pve_verify_vmid);
 sub pve_verify_vmid {
     my ($vmid, $noerr) = @_;
 
-    if ($vmid !~ m/^[1-9][0-9]+$/) {
+    if ($vmid !~ m/^[1-9][0-9]{2,8}$/) {
        return undef if $noerr;
        die "value does not look like a valid VM ID\n";
     }
@@ -227,26 +242,70 @@ sub pve_verify_ipv4mask {
     return $mask;
 }
 
-register_format('CIDR', \&pve_verify_cidr);
-sub pve_verify_cidr {
+register_format('CIDRv6', \&pve_verify_cidrv6);
+sub pve_verify_cidrv6 {
     my ($cidr, $noerr) = @_;
 
-    if ($cidr =~ m!^(?:$IPV4RE)(?:/(\d+))$! && ($1 > 7) &&  ($1 < 32)) {
+    if ($cidr =~ m!^(?:$IPV6RE)(?:/(\d+))$! && ($1 > 7) &&  ($1 <= 120)) {
        return $cidr;
-    } elsif ($cidr =~ m!^(?:$IPV6RE)(?:/(\d+))$! && ($1 > 7) &&  ($1 <= 120)) {
+    }
+
+    return undef if $noerr;
+    die "value does not look like a valid IPv6 CIDR network\n";
+}
+
+register_format('CIDRv4', \&pve_verify_cidrv4);
+sub pve_verify_cidrv4 {
+    my ($cidr, $noerr) = @_;
+
+    if ($cidr =~ m!^(?:$IPV4RE)(?:/(\d+))$! && ($1 > 7) &&  ($1 <= 32)) {
        return $cidr;
     }
 
     return undef if $noerr;
-    die "value does not look like a valid CIDR network\n";
+    die "value does not look like a valid IPv4 CIDR network\n";
+}
+
+register_format('CIDR', \&pve_verify_cidr);
+sub pve_verify_cidr {
+    my ($cidr, $noerr) = @_;
+
+    if (!(pve_verify_cidrv4($cidr, 1) ||
+         pve_verify_cidrv6($cidr, 1)))
+    {
+       return undef if $noerr;
+       die "value does not look like a valid CIDR network\n";
+    }
+
+    return $cidr;
+}
+
+register_format('pve-ipv4-config', \&pve_verify_ipv4_config);
+sub pve_verify_ipv4_config {
+    my ($config, $noerr) = @_;
+
+    return $config if $config =~ /^(?:dhcp|manual)$/ ||
+                      pve_verify_cidrv4($config, 1);
+    return undef if $noerr;
+    die "value does not look like a valid ipv4 network configuration\n";
+}
+
+register_format('pve-ipv6-config', \&pve_verify_ipv6_config);
+sub pve_verify_ipv6_config {
+    my ($config, $noerr) = @_;
+
+    return $config if $config =~ /^(?:auto|dhcp|manual)$/ ||
+                      pve_verify_cidrv6($config, 1);
+    return undef if $noerr;
+    die "value does not look like a valid ipv6 network configuration\n";
 }
 
 register_format('email', \&pve_verify_email);
 sub pve_verify_email {
     my ($email, $noerr) = @_;
 
-    # we use same regex as extjs Ext.form.VTypes.email
-    if ($email !~ /^(\w+)([\-+.][\w]+)*@(\w[\-\w]*\.){1,5}([A-Za-z]){2,6}$/) {
+    # we use same regex as in Utils.js
+    if ($email !~ /^(\w+)([\-+.][\w]+)*@(\w[\-\w]*\.){1,5}([A-Za-z]){2,63}$/) {
           return undef if $noerr;
           die "value does not look like a valid email address\n";
     }
@@ -292,6 +351,16 @@ sub pve_verify_address {
     return $addr;
 }
 
+register_format('disk-size', \&pve_verify_disk_size);
+sub pve_verify_disk_size {
+    my ($size, $noerr) = @_;
+    if (!defined(parse_size($size))) {
+       return undef if $noerr;
+       die "value does not look like a valid disk size: $size\n";
+    }
+    return $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).",
     type => 'string', format => 'address',
@@ -352,8 +421,9 @@ PVE::JSONSchema::register_standard_option('pve-startup-order', {
 });
 
 sub check_format {
-    my ($format, $value) = @_;
+    my ($format, $value, $path) = @_;
 
+    return parse_property_string($format, $value, $path) if ref($format) eq 'HASH';
     return if $format eq 'regex';
 
     if ($format =~ m/^(.*)-a?list$/) {
@@ -383,10 +453,194 @@ sub check_format {
 
        die "undefined format '$format'\n" if !$code;
 
+       return parse_property_string($code, $value, $path) if ref($code) eq 'HASH';
        &$code($value);
     }
 } 
 
+sub parse_size {
+    my ($value) = @_;
+
+    return undef if $value !~ m/^(\d+(\.\d+)?)([KMGT])?$/;
+    my ($size, $unit) = ($1, $3);
+    if ($unit) {
+       if ($unit eq 'K') {
+           $size = $size * 1024;
+       } elsif ($unit eq 'M') {
+           $size = $size * 1024 * 1024;
+       } elsif ($unit eq 'G') {
+           $size = $size * 1024 * 1024 * 1024;
+       } elsif ($unit eq 'T') {
+           $size = $size * 1024 * 1024 * 1024 * 1024;
+       }
+    }
+    return int($size);
+};
+
+sub format_size {
+    my ($size) = @_;
+
+    $size = int($size);
+
+    my $kb = int($size/1024);
+    return $size if $kb*1024 != $size;
+
+    my $mb = int($kb/1024);
+    return "${kb}K" if $mb*1024 != $kb;
+
+    my $gb = int($mb/1024);
+    return "${mb}M" if $gb*1024 != $mb;
+
+    my $tb = int($gb/1024);
+    return "${gb}G" if $tb*1024 != $gb;
+
+    return "${tb}T";
+};
+
+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);
+
+    my $default_key;
+
+    my $res = {};
+    foreach my $part (split(/,/, $data)) {
+       next if $part =~ /^\s*$/;
+
+       if ($part =~ /^([^=]+)=(.+)$/) {
+           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}) {
+               $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;
+           }
+           $res->{$k} = $v;
+       } elsif ($part !~ /=/) {
+           die "duplicate key in comma-separated list property: $default_key\n" if $default_key;
+           foreach my $key (keys %$format) {
+               if ($format->{$key}->{default_key}) {
+                   $default_key = $key;
+                   if (!$res->{$default_key}) {
+                       $res->{$default_key} = $part;
+                       last;
+                   }
+                   die "duplicate key in comma-separated list property: $default_key\n";
+               }
+           }
+           die "value without key, but schema does not define a default key\n" if !$default_key;
+       } else {
+           die "missing key in comma-separated list property\n";
+       }
+    }
+
+    my $errors = {};
+    check_object($path, $format, $res, $additional_properties, $errors);
+    if (scalar(%$errors)) {
+       raise "format error\n", errors => $errors;
+    }
+
+    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) = @_;
 
@@ -531,8 +785,31 @@ sub check_object {
        return;
     }
 
+    my %groups;
     foreach my $k (keys %$schema) {
-       check_prop($value->{$k}, $schema->{$k}, $path ? "$path.$k" : $k, $errors);
+       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);
     }
 
     foreach my $k (keys %$value) {
@@ -563,6 +840,19 @@ sub check_object {
     }
 }
 
+sub check_object_warn {
+    my ($path, $schema, $value, $additional_properties) = @_;
+    my $errors = {};
+    check_object($path, $schema, $value, $additional_properties, $errors);
+    if (scalar(%$errors)) {
+       foreach my $k (keys %$errors) {
+           warn "parse error: $k: $errors->{$k}\n";
+       }
+       return 0;
+    }
+    return 1;
+}
+
 sub check_prop {
     my ($value, $schema, $path, $errors) = @_;
 
@@ -584,7 +874,7 @@ sub check_prop {
 
     if (!defined ($value)) {
        return if $schema->{type} && $schema->{type} eq 'null';
-       if (!$schema->{optional}) {
+       if (!$schema->{optional} && !$schema->{alias} && !$schema->{group}) {
            add_error($errors, $path, "property is missing and it is not optional");
        }
        return;
@@ -626,7 +916,7 @@ sub check_prop {
     } else {
 
        if (my $format = $schema->{format}) {
-           eval { check_format($format, $value); };
+           eval { check_format($format, $value, $path); };
            if ($@) {
                add_error($errors, $path, "invalid format - $@");
                return;
@@ -782,11 +1072,10 @@ my $default_schema_noref = {
        pattern => {
            type => "string",
            format => "regex",
-           description => "When the instance value is a string, this provides a regular expression that a instance string value should match in order to be valid.",
+           description => "When the instance value is a string, this provides a regular expression that a instance string value should match in order to be valid.",
            optional => 1,
            default => ".*",
-        },
-
+       },
        enum => {
            type => "array",
            optional => 1,
@@ -797,60 +1086,85 @@ 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).",
        },
-        title => {
-           type => "string",
+       format_description => {
+           type => "string",
            optional => 1,
-           description => "This provides the title of the property",
-        },
-        requires => {
-           type => [ "string", "object" ],
+           description => "This provides a shorter (usually just one word) description for a property used to generate descriptions for comma separated list property strings.",
+       },
+       title => {
+           type => "string",
            optional => 1,
-           description => "indicates a required property or a schema that must be validated if this property is present",
-        },
-        format => {
-           type => "string",
+           description => "This provides the title of the property",
+       },
+       requires => {
+           type => [ "string", "object" ],
            optional => 1,
-           description => "This indicates what format the data is among some predefined formats which may include:\n\ndate - a string following the ISO format \naddress \nschema - a schema definition object \nperson \npage \nhtml - a string representing HTML",
-        },
+           description => "indicates a required property or a schema that must be validated if this property is present",
+       },
+       format => {
+           type => [ "string", "object" ],
+           optional => 1,
+           description => "This indicates what format the data is among some predefined formats which may include:\n\ndate - a string following the ISO format \naddress \nschema - a schema definition object \nperson \npage \nhtml - a string representing HTML",
+       },
+       default_key => {
+           type => "boolean",
+           optional => 1,
+           description => "Whether this is the default key in a comma separated list property string.",
+       },
+       alias => {
+           type => 'string',
+           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 => {
+           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.",
+       },
        default => {
            type => "any",
            optional => 1,
            description => "This indicates the default for the instance property."
        },
-        disallow => {
-           type => "object",
+       completion => {
+           type => 'coderef',
+           description => "Bash completion function. This function should return a list of possible values.",
+           optional => 1,
+       },
+       disallow => {
+           type => "object",
            optional => 1,
-           description => "This attribute may take the same values as the \"type\" attribute, however if the instance matches the type or if this value is an array and the instance matches any type or schema in the array, than this instance is not valid.",
+           description => "This attribute may take the same values as the \"type\" attribute, however if the instance matches the type or if this value is an array and the instance matches any type or schema in the array, then this instance is not valid.",
        },
-        extends => {
-           type => "object",
+       extends => {
+           type => "object",
            optional => 1,
-           description => "This indicates the schema extends the given schema. All instances of this schema must be valid to by the extended schema also.",
+           description => "This indicates the schema extends the given schema. All instances of this schema must be valid to by the extended schema also.",
            default => {},
-        },
-        # this is from hyper schema
-        links => {
-            type => "array",
-            description => "This defines the link relations of the instance objects",
-           optional => 1,
+       },
+       # this is from hyper schema
+       links => {
+           type => "array",
+           description => "This defines the link relations of the instance objects",
+           optional => 1,
            items => {
-               type => "object",
-               properties => {
-                   href => {
-                       type => "string",
-                       description => "This defines the target URL for the relation and can be parameterized using {propertyName} notation. It should be resolved as a URI-reference relative to the URI that was used to retrieve the instance document",
-                   },
-                   rel => {
-                       type => "string",
-                       description => "This is the name of the link relation",
-                       optional => 1,
-                       default => "full",
-                   },
+               type => "object",
+               properties => {
+                   href => {
+                       type => "string",
+                       description => "This defines the target URL for the relation and can be parameterized using {propertyName} notation. It should be resolved as a URI-reference relative to the URI that was used to retrieve the instance document",
+                   },
+                   rel => {
+                       type => "string",
+                       description => "This is the name of the link relation",
+                       optional => 1,
+                       default => "full",
+                   },
                    method => {
-                       type => "string",
-                       description => "For submission links, this defines the method that should be used to access the target resource",
-                       optional => 1,
-                       default => "GET",
+                       type => "string",
+                       description => "For submission links, this defines the method that should be used to access the target resource",
+                       optional => 1,
+                       default => "GET",
                    },
                },
            },
@@ -962,6 +1276,11 @@ 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.",
@@ -1077,6 +1396,8 @@ sub get_options {
        }
     }
 
+    Getopt::Long::Configure('prefix_pattern=(--|-)');
+
     my $opts = {};
     raise("unable to parse option\n", code => HTTP_BAD_REQUEST)
        if !Getopt::Long::GetOptionsFromArray($args, $opts, @getopt);
@@ -1132,7 +1453,7 @@ sub get_options {
                if ($pd->{format} =~ m/-list/) {
                    # allow --vmid 100 --vmid 101 and --vmid 100,101
                    # allow --dow mon --dow fri and --dow mon,fri
-                   $opts->{$p} = join(",", @{$opts->{$p}});
+                   $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