#!/usr/bin/perl -wT # # Copyright (c) 2000-2014 University of Utah and the Flux Group. # # {{{EMULAB-LICENSE # # This file is part of the Emulab network testbed software. # # This file is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or (at # your option) any later version. # # This file is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public # License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this file. If not, see . # # }}} # use English; use strict; use Getopt::Std; use XML::Simple; use Data::Dumper; use URI::Escape; # # Back-end script to create new Image descriptors (EZ Form.) # sub usage() { print("Usage: newimageid [-v] [-a] [-s] \n"); exit(-1); } my $optlist = "dvfas"; my $debug = 0; my $force = 0; my $verify = 0; # Check data and return status only. my $allpc = 0; # insert mappings for all pc types. my $skipadmin = 0; # Skip SLOT_ADMINONLY checks. # # Configure variables # my $TB = "@prefix@"; my $TBOPS = "@TBOPSEMAIL@"; my $TBAUDIT = "@TBAUDITEMAIL@"; my $TBGROUP_DIR = "@GROUPSROOT_DIR@"; my $TBPROJ_DIR = "@PROJROOT_DIR@"; # # Untaint the path # $ENV{'PATH'} = "$TB/bin:$TB/sbin:/bin:/usr/bin:/usr/bin:/usr/sbin"; delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'}; # # Turn off line buffering on output # $| = 1; # # Load the Testbed support stuff. # use lib "@prefix@/lib"; use libdb; use libtestbed; use User; use Project; use Image; use OSinfo; use Node; # Protos sub fatal($); sub UserError(;$); sub escapeshellarg($); # Set this is we need to instert an os_submap entry. my $parentos; # # Parse command arguments. Once we return from getopts, all that should be # left are the required arguments. # my %options = (); if (! getopts($optlist, \%options)) { usage(); } if (defined($options{"d"})) { $debug = 1; } if (defined($options{"f"})) { $force = 1; } if (defined($options{"v"})) { $verify = 1; } if (defined($options{"s"})) { $skipadmin = 1; } if (defined($options{"a"})) { $allpc = 1; } if (@ARGV != 1) { usage(); } my $xmlfile = shift(@ARGV); # # Map invoking user to object. # If invoked as "nobody" we are coming from the web interface and the # current user context is "implied" (see tbauth.php3). # my $this_user; if (getpwuid($UID) ne "nobody") { $this_user = User->ThisUser(); if (! defined($this_user)) { fatal("You ($UID) do not exist!"); } # You don't need admin privileges to create new Image descriptors. } else { # # Check the filename when invoked from the web interface; must be a # file in /tmp. # if ($xmlfile =~ /^([-\w\.\/]+)$/) { $xmlfile = $1; } else { fatal("Bad data in pathname: $xmlfile"); } # Use realpath to resolve any symlinks. my $translated = `realpath $xmlfile`; if ($translated =~ /^(\/tmp\/[-\w\.\/]+)$/) { $xmlfile = $1; } else { fatal("Bad data in translated pathname: $xmlfile"); } # The web interface (and in the future the xmlrpc interface) sets this. $this_user = User->ImpliedUser(); if (! defined($this_user)) { fatal("Cannot determine implied user!"); } } # # These are the fields that we allow to come in from the XMLfile. # my $SLOT_OPTIONAL = 0x1; # The field is not required. my $SLOT_REQUIRED = 0x2; # The field is required and must be non-null. my $SLOT_ADMINONLY = 0x4; # Only admins can set this field. # # XXX We should encode all of this in the DB so that we can generate the # forms on the fly, as well as this checking code. # my %xmlfields = # XML Field Name DB slot name Flags Default ("imagename" => ["imagename", $SLOT_REQUIRED], "nodetype" => ["nodetype", $SLOT_OPTIONAL], "nodeclass" => ["nodeclass", $SLOT_OPTIONAL], "pid" => ["pid", $SLOT_REQUIRED], "gid" => ["gid", $SLOT_OPTIONAL], "description" => ["description", $SLOT_REQUIRED], "loadpart" => ["loadpart", $SLOT_REQUIRED], "OS" => ["OS", $SLOT_REQUIRED], "version" => ["version", $SLOT_OPTIONAL, ""], "path" => ["path", $SLOT_OPTIONAL, ""], "node_id" => ["node_id", $SLOT_OPTIONAL, ""], "osfeatures", => ["osfeatures", $SLOT_OPTIONAL, ""], "op_mode", => ["op_mode", $SLOT_REQUIRED], "mtype_*" => ["mtype", $SLOT_OPTIONAL], "wholedisk", => ["wholedisk", $SLOT_OPTIONAL, 0], "max_concurrent", => ["max_concurrent", $SLOT_OPTIONAL, 0], "shared", => ["shared", $SLOT_OPTIONAL, 0], "global", => ["global", $SLOT_OPTIONAL, 0], "noexport", => ["noexport", $SLOT_OPTIONAL, 0], "mbr_version", => ["mbr_version", $SLOT_OPTIONAL], "metadata_url", => ["metadata_url", $SLOT_ADMINONLY], "imagefile_url", => ["imagefile_url", $SLOT_ADMINONLY], "reboot_waittime", => ["reboot_waittime", $SLOT_OPTIONAL], "hash", => ["hash", $SLOT_ADMINONLY], "nextosid", => ["nextosid", $SLOT_ADMINONLY], "def_parentosid", => ["def_parentosid", $SLOT_OPTIONAL], ); # # Must wrap the parser in eval since it exits on error. # my $xmlparse = eval { XMLin($xmlfile, VarAttr => 'name', ContentKey => '-content', SuppressEmpty => undef); }; fatal($@) if ($@); # # Process and dump the errors (formatted for the web interface). # We should probably XML format the errors instead but not sure I want # to go there yet. # my %errors = (); # # Make sure all the required arguments were provided. # my $key; foreach $key (keys(%xmlfields)) { my (undef, $required, undef) = @{$xmlfields{$key}}; $errors{$key} = "Required value not provided" if ($required & $SLOT_REQUIRED && ! exists($xmlparse->{'attribute'}->{"$key"})); } UserError() if (keys(%errors)); # # We build up an array of arguments to pass to Image->Create() as we check # the attributes. # my %newimageid_args = (); # # Wildcard keys have one or more *'s in them like simple glob patterns. # This allows multiple key instances for categories of attributes, and # putting a "type signature" in the key for arg checking, as well. # # Wildcards are made into regex's by anchoring the ends and changing each * to # a "word" (group of alphahumeric.) A tail * means "the rest", allowing # multiple words separated by underscores or dashes. # my $wordpat = '[a-zA-Z0-9]+'; my $tailpat = '[-\w]+'; my %wildcards; foreach $key (keys(%xmlfields)) { if (index($key, "*") >= 0) { my $regex = '^' . $key . '$'; $regex =~ s/\*\$$/$tailpat/; $regex =~ s/\*/$wordpat/g; $wildcards{$key} = $regex; } } # Key ordering is lost in a hash. # Put longer matching wildcard keys before their prefix. my @wildkeys = reverse(sort(keys(%wildcards))); foreach $key (keys(%{ $xmlparse->{'attribute'} })) { my $value = $xmlparse->{'attribute'}->{"$key"}->{'value'}; if (!defined($value)) { # Empty string comes from XML as an undef value. $xmlparse->{'attribute'}->{"$key"}->{'value'} = $value = ""; } print STDERR "User attribute: '$key' -> '$value'\n" if ($debug); my $field = $key; my $wild; if (!exists($xmlfields{$key})) { # Not a regular key; look for a wildcard regex match. foreach my $wildkey (@wildkeys) { my $regex = $wildcards{$wildkey}; if ($wild = $key =~ /$regex/) { $field = $wildkey; print STDERR "Wildcard: '$key' matches '$wildkey'\n" if ($debug); last; # foreach $wildkey } } if (!$wild) { $errors{$key} = "Unknown attribute"; next; # foreach $key } } my ($dbslot, $required, $default) = @{$xmlfields{$field}}; if ($required & $SLOT_REQUIRED) { # A slot that must be provided, so do not allow a null value. if (!defined($value)) { $errors{$key} = "Must provide a non-null value"; next; } } if ($required & $SLOT_OPTIONAL) { # Optional slot. If value is null skip it. Might not be the correct # thing to do all the time? if (!defined($value)) { next if (!defined($default)); $value = $default; } } if ($required & $SLOT_ADMINONLY && !$skipadmin) { # Admin implies optional, but thats probably not correct approach. $errors{$key} = "Administrators only" if (! $this_user->IsAdmin()); } # # Special case nextosid so it can be in pid,osname format. # if (($dbslot eq "nextosid" || $dbslot eq "def_parentosid") && $value =~ /^.+,.+$/) { my ($pid,$osname) = ($value =~ /^(.*),(.*)$/); # Now check that the value is legal. if (! TBcheck_dbslot($pid, "projects", "pid", TBDB_CHECKDBSLOT_ERROR)) { $errors{$key} = TBFieldErrorString(); next; } if (! TBcheck_dbslot($osname, "os_info", "osname", TBDB_CHECKDBSLOT_ERROR)) { $errors{$key} = TBFieldErrorString(); next; } } else { # Now check that the value is legal. if (! TBcheck_dbslot($value, "images", $dbslot, TBDB_CHECKDBSLOT_ERROR)) { $errors{$key} = TBFieldErrorString(); next; } } $newimageid_args{$key} = $value; } UserError() if (keys(%errors)); # # Options for using this page with different types of nodes. # nodetype controls the view and trumps nodeclass. # nodeclass determines what node types are visible from the DB. # my $nodeclass; if (exists($newimageid_args{"nodetype"}) && $newimageid_args{"nodetype"} == "mote") { $nodeclass = "mote"; } else { # Defaults to PC view. if (!exists($newimageid_args{nodeclass})) { $nodeclass = "pc"; } else { $nodeclass = escapeshellarg($nodeclass); } } # # Need a list of node types. We join this over the nodes table so that # we get a list of just the nodes that are currently in the testbed, not # just in the node_types table. Limit by class if given. # my $types_querystring; if ($allpc) { $types_querystring = "select nt.type,nt.class from node_types as nt ". "left join node_type_attributes as a on a.type=nt.type ". "where a.attrkey='imageable' and a.attrvalue!='0'"; } else { $types_querystring = "select distinct n.type,nt.class from nodes as n ". "left join node_types as nt on n.type=nt.type ". "left join node_type_attributes as a on a.type=n.type ". "where a.attrkey='imageable' and ". " a.attrvalue!='0' and n.role='testnode'"; } if ($nodeclass) { $types_querystring .= " and nt.class='$nodeclass' "; } my $types_result = DBQueryFatal($types_querystring); # Save the valid types in a new array for later. my %mtypes_array; if ($types_result->numrows) { while (my ($type,$class) = $types_result->fetchrow_array()) { $mtypes_array{$type} = $class; $xmlfields{"mtype_$type"} = ["mtype", $SLOT_OPTIONAL]; } } else { $mtypes_array{"pc"} = "pc"; $xmlfields{"mtype_pc"} = ["mtype", $SLOT_OPTIONAL]; } # # Now do special checks. # my $isadmin = $this_user->IsAdmin(); my $imagename = $newimageid_args{"imagename"}; my $OS = $newimageid_args{"OS"}; # In this form, we make the images:imagename and the os_info:osname the # same. if ( length($imagename) > TB_OSID_OSNAMELEN()) { UserError("Descriptor Name: Too long! ". "Must be less than or equal to " . TB_OSID_OSNAMELEN()); } my $project = Project->Lookup($newimageid_args{"pid"}); if (!defined($project)) { UserError("Project: No such project"); } my $group; if (exists($newimageid_args{"gid"})) { my $gid = $newimageid_args{"gid"}; $group = Group->LookupByPidGid($project->pid(), $gid); if (!defined($group)) { UserError("Group: No such group $gid"); } } else { $group = $project->GetProjectGroup(); } # Permission check if we managed to get a proper group above. if ($group && ! $group->AccessCheck($this_user, TB_PROJECT_MAKEIMAGEID())) { UserError("Project: Not enough permission"); } # # Only admin types can set the global bit for an image. Ignore silently. # my $global = 0; if (($isadmin || $skipadmin) && exists($newimageid_args{"global"}) && $newimageid_args{"global"} eq "1") { $global = 1; } else { delete $newimageid_args{"global"}; } my $shared = 0; if (exists($newimageid_args{"shared"}) && $newimageid_args{"shared"} eq "1") { $shared = 1; } # Does not make sense to do this. if ($global && $shared) { UserError("Global: Image declared both shared and global"); } if (exists($newimageid_args{"metadata_url"})) { $newimageid_args{"metadata_url"} = uri_unescape($newimageid_args{"metadata_url"}) } if (exists($newimageid_args{"imagefile_url"})) { $newimageid_args{"imagefile_url"} = uri_unescape($newimageid_args{"imagefile_url"}) } # Nextosid check. Must exist. admin check done above. if (exists($newimageid_args{"nextosid"})) { my $nextos = OSinfo->Lookup($newimageid_args{"nextosid"}); if (!defined($nextos)) { UserError("Nextosid: Does not exist"); } # Might be in pid,osname format. $newimageid_args{"nextosid"} = $nextos->osid(); } # Ditto def_parentosid if (exists($newimageid_args{"def_parentosid"})) { $parentos = OSinfo->Lookup($newimageid_args{"def_parentosid"}); if (!defined($parentos)) { UserError("def_parentosid: Does not exist"); } # Might be in pid,osname format. $newimageid_args{"def_parentosid"} = $parentos->osid(); } # # The path must not contain illegal chars and it must be more than # the original /proj/$pid we gave the user. We allow admins to specify # a path outside of /proj though. # if (!exists($newimageid_args{"path"}) || $newimageid_args{"path"} eq "") { UserError("Path: Missing Field"); } elsif (! $isadmin) { my $pdef = ""; if (!$shared && exists($newimageid_args{"gid"}) && $newimageid_args{"gid"} ne "" && $newimageid_args{"gid"} ne $newimageid_args{"pid"}) { $pdef = "$TBGROUP_DIR/" . $newimageid_args{"pid"} . "/" . $newimageid_args{"gid"} . "/"; } else { $pdef = "$TBPROJ_DIR/" . $newimageid_args{"pid"} . "/images/"; } if (index($newimageid_args{"path"}, $pdef) < 0) { UserError("Path: Invalid Path"); } } # We allow loadpart=0 for whole-disk images in the Long Form, and it uses the # same table_regex checking patterns. Here "wholedisk" is a separate checkbox. my $loadpart = $newimageid_args{"loadpart"}; if ($loadpart < 1 || $loadpart > 4) { UserError("DOS Partion: Out of range."); } # # Check sanity of node name and that user can create an image from it. # my ($node, $node_id); if (exists($newimageid_args{"node_id"}) && $newimageid_args{"node_id"} ne "") { if (!($node = Node->Lookup($newimageid_args{"node_id"}))) { UserError("Node: Invalid node name"); } elsif (!$node->AccessCheck($this_user, TB_NODEACCESS_LOADIMAGE())) { UserError("Node: Not enough permission"); } else { $node_id = $node->node_id(); } } # # If no MBR version was specified, and a snapshot node was given, # try to deduce the default MBR version based on what is currently # on the node we are snapshotting. # if (!exists($newimageid_args{"mbr_version"}) && defined($node_id)) { my $mbrvers = 1; # # If there is only one MBR version for all images on the disk, # use that. Otherwise, if there is no or ambiguous info, default # to version 1. # my $query_result = DBQueryFatal("select mbr_version from partitions as p, images as i ". " where p.imageid=i.imageid and p.node_id='$node_id' ". " group by mbr_version"); if ($query_result && $query_result->numrows == 1) { ($mbrvers) = $query_result->fetchrow_array(); } $newimageid_args{"mbr_version"} = $mbrvers; } # # See what node types this image will work on. Must be at least one! # UserError("Node Types: Must have at least one node type") if (!keys(%mtypes_array)); my $node_types_selected = 0; # Check validity of mtype_* args, since the keys are dynamically generated. my @mtype_keys = grep(/^mtype_/, keys(%newimageid_args)); foreach $key (@mtype_keys) { my $value = $newimageid_args{$key}; print STDERR "mtype: '$key' -> '$value'\n" if ($debug); my $type = $key; $type =~ s/^mtype_//; # Treat pcvm special for now. if ($type eq "pcvm" || grep(/^${type}$/, keys(%mtypes_array))) { $node_types_selected++ if ($value eq "1"); } else { $errors{$key} = "Illegal node type." } } # # When -a specified, add mappings for all pc types, does not matter if # there are nodes of that type. Skip the stub pc/pc entry though. # if ($allpc) { foreach my $type (keys(%mtypes_array)) { my $class = $mtypes_array{$type}; next if ($class ne "pc" || $type eq $class); $newimageid_args{"mtype_${type}"} = "1"; $node_types_selected++; } } UserError("Node Types: Must select at least one node type") if ($node_types_selected == 0 && !$force); # # We perform a further check for non-admins. When a node to snapshot # has been specified, we check the OSID of the appropriate partition # and see which node types it is appropriate for, and further restrict # the list as necessary. This prevents creation of custom images based on # old OSes from being checked as runnable on newer HW where they do not # stand a chance. # if (!$isadmin && defined($node) && !$node->isvirtnode()) { my $query_result = DBQueryFatal("select oi.type from osidtoimageid as oi ". "left join partitions as p on oi.osid=p.osid ". "where p.node_id='$node_id' and p.partition=$loadpart"); if ($query_result->numrows != 0) { my %otypes; while (my ($ntype) = $query_result->fetchrow_array()) { $otypes{$ntype} = 1; } my @invalid_node_types; foreach my $ntype (@mtype_keys) { $ntype =~ s/^mtype_//; if (!exists($otypes{$ntype})) { push @invalid_node_types, $ntype; } } if (@invalid_node_types) { UserError("Node Types: Current image on $node_id". " cannot run on the following node types: ". join(' ', @invalid_node_types)); } } else { UserError("Partition: No image originally loaded in partition $loadpart on $node_id; this is probably not the partition you meant to save"); } } # XXX Allowable OS types, OS features, and OpModes need to be # XXX converted from constants in osiddefs.php and put into the DB. # XXX Validity checks go here and in the similar place in newosid.in . my %osid_reboot_waitlist = ("Linux", 120, "Fedora", 120, "FreeBSD", 120, "NetBSD", 120, "Windows", 240, "TinyOS", 60, "Oskit", 60, "Other", 60); # # Reboot waittime. Only admin users can set this. Grab default # if not set. If no default, complain (unless they failed to specify an OS # in which case we will complain about that instead). # my $reboot_waittime; if (!exists($osid_reboot_waitlist{$OS})) { UserError("OS: Invalid OS"); } elsif (exists($newimageid_args{"reboot_waittime"})) { $reboot_waittime = $newimageid_args{"reboot_waittime"} } else { $reboot_waittime = $osid_reboot_waitlist{$OS}; } exit(0) if ($verify); # # Now safe to create new OS and image descriptors. # # We pass the imagename along as an argument to Create(), so remove it from # the argument array. (Same name is used for both Creates.) # delete($newimageid_args{"imagename"}); # # XXX note that osid "path" is not the same as imageid "path". The former # is for multiboot (aka OSKit) kernels. So we remove the path arg temporary. # my $ipath = $newimageid_args{"path"}; delete($newimageid_args{"path"}); # Cross-connect: Make the os descriptor first with the imagename, then take # the osid index and use it as the imageid index as well. $newimageid_args{"ezid"} = 1; # OSInfo->Create args not quite the same as Image->Create ones so copy # newimageid_args into a new hash and fix up values. my %newosid_args = %newimageid_args; $newosid_args{"shared"} = $newimageid_args{"global"} if exists $newimageid_args{"global"}; my $usrerr; my $new_osinfo = OSinfo->Create($project, $this_user, $imagename, \%newosid_args, \$usrerr); UserError($usrerr) if (defined($usrerr)); fatal("Could not create new OSID!") if (!defined($new_osinfo)); $newimageid_args{"path"} = $ipath; my $osid = $new_osinfo->osid(); # # Special option. Whole disk image, but only one partition that actually # matters. # my $loadlen = 1; $newimageid_args{"part${loadpart}_osid"} = $osid; if ($newimageid_args{"wholedisk"}) { $loadlen = 4; $loadpart = 0; } $newimageid_args{"loadpart"} = $loadpart; $newimageid_args{"loadlength"} = $loadlen; $newimageid_args{"default_osid"} = $osid; # Create the osidtoimageid mapping too. $newimageid_args{"makedefault"} = 1 if ($node_types_selected); my $new_image = Image->Create($project, $group, $this_user, $imagename, $osid, \%newimageid_args, \$usrerr); UserError($usrerr) if (defined($usrerr)); fatal("Could not create new Image!") if (!defined($new_image)); my $imageid = $new_image->imageid(); # # Insert a submap entry. # if (defined($parentos)) { $new_osinfo->SetRunsOnParent($parentos); # # Temp hack until we put all mappings into XML file. # if ($parentos->osname() =~ /^XEN4/) { my $other = ($parentos->osname() =~ /^XEN41/ ? "XEN43-64-STD" : "XEN41-64-STD"); my $otheros = OSinfo->LookupByName($other); if (defined($otheros)) { $new_osinfo->SetRunsOnParent($otheros); } } } # The web interface requires this line to be printed. print "IMAGE $imagename/$imageid has been created\n"; exit(0); sub fatal($) { my ($mesg) = @_; print STDERR "*** $0:\n". " $mesg\n"; # Exit with negative status so web interface treats it as system error. exit(-1); } sub UserError(;$) { my ($mesg) = @_; if (keys(%errors)) { foreach my $key (keys(%errors)) { my $val = $errors{$key}; print "${key}: $val\n"; } } print "$mesg\n" if (defined($mesg)); # Exit with positive status so web interface treats it as user error. exit(1); } sub escapeshellarg($) { my ($str) = @_; $str =~ s/[^[:alnum:]]/\\$&/g; return $str; }