define systemd.log_target=console when used with systemd-debug
[plcapi.git] / PLC / Methods / GetBootMedium.py
index 083e08f..368ae1e 100644 (file)
@@ -1,4 +1,3 @@
-# $Id$
 import random
 import base64
 import os
 import random
 import base64
 import os
@@ -13,18 +12,32 @@ from PLC.Auth import Auth
 from PLC.Nodes import Node, Nodes
 from PLC.Interfaces import Interface, Interfaces
 from PLC.InterfaceTags import InterfaceTag, InterfaceTags
 from PLC.Nodes import Node, Nodes
 from PLC.Interfaces import Interface, Interfaces
 from PLC.InterfaceTags import InterfaceTag, InterfaceTags
+from PLC.NodeTags import NodeTag, NodeTags
+
+from PLC.Debug import log
+
+from PLC.Accessors.Accessors_standard import *                  # import node accessors
 
 # could not define this in the class..
 
 # could not define this in the class..
-boot_medium_actions = [ 'node-preview',
-                        'node-floppy',
-                        'node-iso',
-                        'node-usb',
-                        'generic-iso',
-                        'generic-usb',
-                        ]
+# create a dict with the allowed actions for each type of node
+# reservable nodes being more recent, we do not support the floppy stuff anymore
+allowed_actions = {
+    'regular' :
+    [ 'node-preview',
+      'node-floppy',
+      'node-iso',
+      'node-usb',
+      'generic-iso',
+      'generic-usb',
+      ],
+    'reservable':
+    [ 'node-preview',
+      'node-iso',
+      'node-usb',
+      ],
+    }
 
 # compute a new key
 
 # compute a new key
-# xxx used by GetDummyBoxMedium
 def compute_key():
     # Generate 32 random bytes
     bytes = random.sample(xrange(0, 256), 32)
 def compute_key():
     # Generate 32 random bytes
     bytes = random.sample(xrange(0, 256), 32)
@@ -37,21 +50,24 @@ def compute_key():
 
 class GetBootMedium(Method):
     """
 
 class GetBootMedium(Method):
     """
-    This method is a redesign based on former, supposedly dedicated, 
+    This method is a redesign based on former, supposedly dedicated,
     AdmGenerateNodeConfFile
 
     AdmGenerateNodeConfFile
 
-    As compared with its ancestor, this method provides a much more detailed
+    As compared with its ancestor, this method provides a much more
     detailed interface, that allows to
     detailed interface, that allows to
-    (*) either just preview the node config file -- in which case 
+    (*) either just preview the node config file -- in which case
         the node key is NOT recomputed, and NOT provided in the output
         the node key is NOT recomputed, and NOT provided in the output
-    (*) or regenerate the node config file for storage on a floppy 
-        that is, exactly what the ancestor method used todo, 
+    (*) or regenerate the node config file for storage on a floppy
+        that is, exactly what the ancestor method used todo,
         including renewing the node's key
     (*) or regenerate the config file and bundle it inside an ISO or USB image
         including renewing the node's key
     (*) or regenerate the config file and bundle it inside an ISO or USB image
-    (*) or just provide the generic ISO or USB boot images 
+    (*) or just provide the generic ISO or USB boot images
         in which case of course the node_id_or_hostname parameter is not used
 
         in which case of course the node_id_or_hostname parameter is not used
 
-    action is expected among the following string constants
+    action is expected among the following string constants according the
+    node type value:
+
+    for a 'regular' node:
     (*) node-preview
     (*) node-floppy
     (*) node-iso
     (*) node-preview
     (*) node-floppy
     (*) node-iso
@@ -61,48 +77,58 @@ class GetBootMedium(Method):
 
     Apart for the preview mode, this method generates a new node key for the
     specified node, effectively invalidating any old boot medium.
 
     Apart for the preview mode, this method generates a new node key for the
     specified node, effectively invalidating any old boot medium.
+    Note that 'reservable' nodes do not support 'node-floppy',
+    'generic-iso' nor 'generic-usb'.
 
     In addition, two return mechanisms are supported.
 
     In addition, two return mechanisms are supported.
-    (*) The default behaviour is that the file's content is returned as a 
+    (*) The default behaviour is that the file's content is returned as a
         base64-encoded string. This is how the ancestor method used to work.
         To use this method, pass an empty string as the file parameter.
 
         base64-encoded string. This is how the ancestor method used to work.
         To use this method, pass an empty string as the file parameter.
 
-    (*) Or, for efficiency -- this makes sense only when the API is used 
-        by the web pages that run on the same host -- the caller may provide 
-        a filename, in which case the resulting file is stored in that location instead. 
-        The filename argument can use the following markers, that are expanded 
+    (*) Or, for efficiency -- this makes sense only when the API is used
+        by the web pages that run on the same host -- the caller may provide
+        a filename, in which case the resulting file is stored in that location instead.
+        The filename argument can use the following markers, that are expanded
         within the method
         - %d : default root dir (some builtin dedicated area under /var/tmp/)
                Using this is recommended, and enforced for non-admin users
         within the method
         - %d : default root dir (some builtin dedicated area under /var/tmp/)
                Using this is recommended, and enforced for non-admin users
-        - %n : the node's name when this makes sense, or a mktemp-like name when 
+        - %n : the node's name when this makes sense, or a mktemp-like name when
                generic media is requested
         - %s : a file suffix appropriate in the context (.txt, .iso or the like)
         - %v : the bootcd version string (e.g. 4.0)
         - %p : the PLC name
         - %f : the nodefamily
         - %a : arch
                generic media is requested
         - %s : a file suffix appropriate in the context (.txt, .iso or the like)
         - %v : the bootcd version string (e.g. 4.0)
         - %p : the PLC name
         - %f : the nodefamily
         - %a : arch
-        With the file-based return mechanism, the method returns the full pathname 
-        of the result file; 
+        With the file-based return mechanism, the method returns the full pathname
+        of the result file;
         ** WARNING **
         It is the caller's responsability to remove this file after use.
 
         ** WARNING **
         It is the caller's responsability to remove this file after use.
 
-    Options: an optional array of keywords. 
+    Options: an optional array of keywords.
         options are not supported for generic images
         options are not supported for generic images
-    Currently supported are
+      Currently supported are
         - 'partition' - for USB actions only
         - 'cramfs'
         - 'serial' or 'serial:<console_spec>'
         - 'partition' - for USB actions only
         - 'cramfs'
         - 'serial' or 'serial:<console_spec>'
-        - 'no-hangcheck'
         console_spec (or 'default') is passed as-is to bootcd/build.sh
         it is expected to be a colon separated string denoting
         tty - baudrate - parity - bits
         e.g. ttyS0:115200:n:8
         console_spec (or 'default') is passed as-is to bootcd/build.sh
         it is expected to be a colon separated string denoting
         tty - baudrate - parity - bits
         e.g. ttyS0:115200:n:8
+        - 'variant:<variantname>'
+        passed to build.sh as -V <variant>
+        variants are used to run a different kernel on the bootCD
+        see kvariant.sh for how to create a variant
+        - 'no-hangcheck' - disable hangcheck
+        - 'systemd-debug' - turn on systemd debug in bootcd
+
+    Tags: the following tags are taken into account when attached to the node:
+        'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck', 'systemd-debug'
 
     Security:
         - Non-admins can only generate files for nodes at their sites.
         - Non-admins, when they provide a filename, *must* specify it in the %d area
 
 
     Security:
         - Non-admins can only generate files for nodes at their sites.
         - Non-admins, when they provide a filename, *must* specify it in the %d area
 
-   Housekeeping: 
+   Housekeeping:
         Whenever needed, the method stores intermediate files in a
         private area, typically not located under the web server's
         accessible area, and are cleaned up by the method.
         Whenever needed, the method stores intermediate files in a
         private area, typically not located under the web server's
         accessible area, and are cleaned up by the method.
@@ -115,17 +141,19 @@ class GetBootMedium(Method):
         Auth(),
         Mixed(Node.fields['node_id'],
               Node.fields['hostname']),
         Auth(),
         Mixed(Node.fields['node_id'],
               Node.fields['hostname']),
-        Parameter (str, "Action mode, expected in " + "|".join(boot_medium_actions)),
+        Parameter (str, "Action mode, expected value depends of the type of node"),
         Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
         Parameter ([str], "Options"),
         ]
 
     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
 
         Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
         Parameter ([str], "Options"),
         ]
 
     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
 
+    # define globals for regular nodes, override later for other types
     BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
     BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
     GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
     WORKDIR = "/var/tmp/bootmedium"
     BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
     BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
     GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
     WORKDIR = "/var/tmp/bootmedium"
+    LOGDIR = "/var/tmp/bootmedium/logs/"
     DEBUG = False
     # uncomment this to preserve temporary area and bootcustom logs
     #DEBUG = True
     DEBUG = False
     # uncomment this to preserve temporary area and bootcustom logs
     #DEBUG = True
@@ -137,22 +165,30 @@ class GetBootMedium(Method):
         # Split hostname into host and domain parts
         parts = node['hostname'].split(".", 1)
         if len(parts) < 2:
         # Split hostname into host and domain parts
         parts = node['hostname'].split(".", 1)
         if len(parts) < 2:
-            raise PLCInvalidArgument, "Node hostname %s is invalid"%node['hostname']
+            raise PLCInvalidArgument("Node hostname {} is invalid".format(node['hostname']))
         return parts
         return parts
-        
-    # plnode.txt content
+
+    # Generate the node (plnode.txt) configuration content.
+    #
+    # This function will create the configuration file a node
+    # composed by:
+    #  - a common part, regardless of the 'node_type' tag
+    #  - XXX a special part, depending on the 'node_type' tag value.
     def floppy_contents (self, node, renew_key):
 
     def floppy_contents (self, node, renew_key):
 
+        # Do basic checks
         if node['peer_id'] is not None:
         if node['peer_id'] is not None:
-            raise PLCInvalidArgument, "Not a local node"
+            raise PLCInvalidArgument("Not a local node {}".format(node['hostname']))
 
         # If we are not an admin, make sure that the caller is a
         # member of the site at which the node is located.
         if 'admin' not in self.caller['roles']:
             if node['site_id'] not in self.caller['site_ids']:
 
         # If we are not an admin, make sure that the caller is a
         # member of the site at which the node is located.
         if 'admin' not in self.caller['roles']:
             if node['site_id'] not in self.caller['site_ids']:
-                raise PLCPermissionDenied, "Not allowed to generate a configuration file for %s"%node['hostname']
+                raise PLCPermissionDenied(
+                    "Not allowed to generate a configuration file for {}"\
+                    .format(node['hostname']))
 
 
-        # Get node networks for this node
+        # Get interface for this node
         primary = None
         interfaces = Interfaces(self.api, node['interface_ids'])
         for interface in interfaces:
         primary = None
         interfaces = Interfaces(self.api, node['interface_ids'])
         for interface in interfaces:
@@ -160,40 +196,43 @@ class GetBootMedium(Method):
                 primary = interface
                 break
         if primary is None:
                 primary = interface
                 break
         if primary is None:
-            raise PLCInvalidArgument, "No primary network configured on %s"%node['hostname']
+            raise PLCInvalidArgument(
+                "No primary network configured on {}".format(node['hostname']))
 
 
-        ( host, domain ) = self.split_hostname (node)
+        host, domain = self.split_hostname (node)
 
 
+        # renew the key and save it on the database
         if renew_key:
             node['key'] = compute_key()
         if renew_key:
             node['key'] = compute_key()
-            # Save it
+            node.update_last_download(commit=False)
             node.sync()
 
         # Generate node configuration file suitable for BootCD
         file = ""
 
         if renew_key:
             node.sync()
 
         # Generate node configuration file suitable for BootCD
         file = ""
 
         if renew_key:
-            file += 'NODE_ID="%d"\n' % node['node_id']
-            file += 'NODE_KEY="%s"\n' % node['key']
+            file += 'NODE_ID="{}"\n'.format(node['node_id'])
+            file += 'NODE_KEY="{}"\n'.format(node['key'])
             # not used anywhere, just a note for operations people
             # not used anywhere, just a note for operations people
-            file += 'KEY_RENEWAL_DATE="%s"\n' % time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime())
+            file += 'KEY_RENEWAL_DATE="{}"\n'\
+                .format(time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime()))
 
         if primary['mac']:
 
         if primary['mac']:
-            file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
+            file += 'NET_DEVICE="{}"\n'.format(primary['mac'].lower())
 
 
-        file += 'IP_METHOD="%s"\n' % primary['method']
+        file += 'IP_METHOD="{}"\n'.format(primary['method'])
 
         if primary['method'] == 'static':
 
         if primary['method'] == 'static':
-            file += 'IP_ADDRESS="%s"\n' % primary['ip']
-            file += 'IP_GATEWAY="%s"\n' % primary['gateway']
-            file += 'IP_NETMASK="%s"\n' % primary['netmask']
-            file += 'IP_NETADDR="%s"\n' % primary['network']
-            file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
-            file += 'IP_DNS1="%s"\n' % primary['dns1']
-            file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
+            file += 'IP_ADDRESS="{}"\n'.format(primary['ip'])
+            file += 'IP_GATEWAY="{}"\n'.format(primary['gateway'])
+            file += 'IP_NETMASK="{}"\n'.format(primary['netmask'])
+            file += 'IP_NETADDR="{}"\n'.format(primary['network'])
+            file += 'IP_BROADCASTADDR="{}"\n'.format(primary['broadcast'])
+            file += 'IP_DNS1="{}"\n'.format(primary['dns1'])
+            file += 'IP_DNS2="{}"\n'.format(primary['dns2'] or "")
 
 
-        file += 'HOST_NAME="%s"\n' % host
-        file += 'DOMAIN_NAME="%s"\n' % domain
+        file += 'HOST_NAME="{}"\n'.format(host)
+        file += 'DOMAIN_NAME="{}"\n'.format(domain)
 
         # define various interface settings attached to the primary interface
         settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
 
         # define various interface settings attached to the primary interface
         settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
@@ -202,108 +241,243 @@ class GetBootMedium(Method):
         for setting in settings:
             if setting['category'] is not None:
                 categories.add(setting['category'])
         for setting in settings:
             if setting['category'] is not None:
                 categories.add(setting['category'])
-        
+
         for category in categories:
         for category in categories:
-            category_settings = InterfaceTags(self.api,{'interface_id':interface['interface_id'],
-                                                              'category':category})
+            category_settings = InterfaceTags(self.api,{'interface_id' : interface['interface_id'],
+                                                        'category' : category})
             if category_settings:
             if category_settings:
-                file += '### Category : %s\n'%category
+                file += '### Category : {}\n'.format(category)
                 for setting in category_settings:
                 for setting in category_settings:
-                    file += '%s_%s="%s"\n'%(category.upper(),setting['name'].upper(),setting['value'])
+                    file += '{}_{}="{}"\n'\
+                        .format(category.upper(), setting['tagname'].upper(), setting['value'])
 
         for interface in interfaces:
             if interface['method'] == 'ipmi':
 
         for interface in interfaces:
             if interface['method'] == 'ipmi':
-                file += 'IPMI_ADDRESS="%s"\n' % interface['ip']
+                file += 'IPMI_ADDRESS="{}"\n'.format(interface['ip'])
                 if interface['mac']:
                 if interface['mac']:
-                    file += 'IPMI_MAC="%s"\n' % interface['mac'].lower()
+                    file += 'IPMI_MAC="{}"\n'.format(interface['mac'].lower())
                 break
 
         return file
 
                 break
 
         return file
 
-    # see also InstallBootstrapFS in bootmanager that does similar things
-    def get_nodefamily (self, node):
-        # get defaults from the myplc build
-        try:
-            (pldistro,arch) = file("/etc/planetlab/nodefamily").read().strip().split("-")
-        except:
-            (pldistro,arch) = ("planetlab","i386")
-            
-        # with no valid argument, return system-wide defaults
+    # see also GetNodeFlavour that does similar things
+    def get_nodefamily (self, node, auth):
+        pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
+        fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
+        arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
         if not node:
         if not node:
-            return (pldistro,arch)
+            return (pldistro,fcdistro,arch)
 
 
-        node_id=node['node_id']
+        node_id = node['node_id']
 
 
-        tag=Nodes(self.api,[node_id],['arch'])[0]['arch']
-        if tag: arch=tag
-        tag=Nodes(self.api,[node_id],['pldistro'])[0]['pldistro']
-        if tag: pldistro=tag
+        # no support for deployment-based BootCD's, use kvariants instead
+        node_pldistro = GetNodePldistro (self.api,self.caller).call(auth, node_id)
+        if node_pldistro:
+            pldistro = node_pldistro
 
 
-        return (pldistro,arch)
+        node_fcdistro = GetNodeFcdistro (self.api,self.caller).call(auth, node_id)
+        if node_fcdistro:
+            fcdistro = node_fcdistro
+
+        node_arch = GetNodeArch (self.api,self.caller).call(auth,node_id)
+        if node_arch:
+            arch = node_arch
+
+        return (pldistro,fcdistro,arch)
 
     def bootcd_version (self):
         try:
             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
         except:
 
     def bootcd_version (self):
         try:
             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
         except:
-            raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
-    
+            raise Exception("Unknown boot cd version - probably wrong bootcd dir : {}"\
+                            .format(self.BOOTCDDIR))
+
     def cleantrash (self):
         for file in self.trash:
             if self.DEBUG:
     def cleantrash (self):
         for file in self.trash:
             if self.DEBUG:
-                print 'DEBUG -- preserving',file
+                print >> log, 'DEBUG -- preserving',file
             else:
                 os.unlink(file)
 
             else:
                 os.unlink(file)
 
+    ### handle filename
+    # build the filename string
+    # check for permissions and concurrency
+    # returns the filename
+    def handle_filename (self, filename, nodename, suffix, arch):
+        # allow to set filename to None or any other empty value
+        if not filename: filename=''
+        filename = filename.replace ("%d",self.WORKDIR)
+        filename = filename.replace ("%n",nodename)
+        filename = filename.replace ("%s",suffix)
+        filename = filename.replace ("%p",self.api.config.PLC_NAME)
+        # let's be cautious
+        try: filename = filename.replace ("%f", self.nodefamily)
+        except: pass
+        try: filename = filename.replace ("%a", arch)
+        except: pass
+        try: filename = filename.replace ("%v",self.bootcd_version())
+        except: pass
+
+        ### Check filename location
+        if filename != '':
+            if 'admin' not in self.caller['roles']:
+                if ( filename.index(self.WORKDIR) != 0):
+                    raise PLCInvalidArgument("File {} not under {}".format(filename, self.WORKDIR))
+
+            ### output should not exist (concurrent runs ..)
+            # numerous reports of issues with this policy
+            # looks like people sometime suspend/cancel their download
+            # and this leads to the old file sitting in there forever
+            # so, if the file is older than 5 minutes, we just trash
+            grace=5
+            if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
+                os.unlink(filename)
+            if os.path.exists(filename):
+                raise PLCInvalidArgument(
+                    "Resulting file {} already exists - please try again in {} minutes"\
+                    .format(filename, grace))
+
+            ### we can now safely create the file,
+            ### either we are admin or under a controlled location
+            filedir=os.path.dirname(filename)
+            # dirname does not return "." for a local filename like its shell counterpart
+            if filedir:
+                if not os.path.exists(filedir):
+                    try:
+                        os.makedirs (filedir,0777)
+                    except:
+                        raise PLCPermissionDenied("Could not create dir {}".format(filedir))
+
+        return filename
+
+    def build_command(self, nodename, node_type, build_sh_spec, node_image, type, floppy_file):
+        """
+        returns a tuple
+        (*) build command to be run
+        (*) location of the log_file
+        """
+
+        command = ""
+
+        # regular node, make build's arguments
+        # and build the full command line to be called
+        if node_type not in [ 'regular', 'reservable' ]:
+            print >> log, "GetBootMedium.build_command: unexpected node_type {}".format(node_type)
+            return command, None
+        
+        build_sh_options=""
+        if "cramfs" in build_sh_spec:
+            type += "_cramfs"
+        if "serial" in build_sh_spec:
+            build_sh_options += " -s {}".format(build_sh_spec['serial'])
+        if "variant" in build_sh_spec:
+            build_sh_options += " -V {}".format(build_sh_spec['variant'])
+
+        for karg in build_sh_spec['kargs']:
+            build_sh_options += ' -k "{}"'.format(karg)
+
+        import time
+        date = time.strftime('%Y-%m-%d-%H-%M', time.gmtime())
+        if not os.path.isdir(self.LOGDIR):
+            os.makedirs(self.LOGDIR)
+        log_file = "{}/{}-{}.log".format(self.LOGDIR, date, nodename)
+
+        command = '{} -f "{}" -o "{}" -t "{}" {} > {} 2>&1'\
+                  .format(self.BOOTCDBUILD,
+                          floppy_file,
+                          node_image,
+                          type,
+                          build_sh_options,
+                          log_file)
+        
+        print >> log, "The build command line is {}".format(command)
+
+        return command, log_file
+
     def call(self, auth, node_id_or_hostname, action, filename, options = []):
 
         self.trash=[]
     def call(self, auth, node_id_or_hostname, action, filename, options = []):
 
         self.trash=[]
-        ### check action
-        if action not in boot_medium_actions:
-            raise PLCInvalidArgument, "Unknown action %s"%action
 
         ### compute file suffix and type
         if action.find("-iso") >= 0 :
 
         ### compute file suffix and type
         if action.find("-iso") >= 0 :
-            suffix=".iso"
-            type = "iso"
+            suffix = ".iso"
+            type   = "iso"
         elif action.find("-usb") >= 0:
         elif action.find("-usb") >= 0:
-            suffix=".usb"
-            type = "usb"
+            suffix = ".usb"
+            type   = "usb"
         else:
         else:
-            suffix=".txt"
-            type = "txt"
+            suffix = ".txt"
+            type   = "txt"
+
+        # check for node existence and get node_type
+        nodes = Nodes(self.api, [node_id_or_hostname])
+        if not nodes:
+            raise PLCInvalidArgument("No such node {}".format(node_id_or_hostname))
+        node = nodes[0]
+
+        print >> log, "GetBootMedium: {} requested on node {}. Node type is: {}"\
+            .format(action, node['node_id'], node['node_type'])
+
+        # check the required action against the node type
+        node_type = node['node_type']
+        if action not in allowed_actions[node_type]:
+            raise PLCInvalidArgument("Action {} not valid for {} nodes, valid actions are {}"\
+                                   .format(action, node_type, "|".join(allowed_actions[node_type])))
 
 
-        # handle / caconicalize options
+        # handle / canonicalize options
         if type == "txt":
             if options:
         if type == "txt":
             if options:
-                raise PLCInvalidArgument, "Options are not supported for node configs"
+                raise PLCInvalidArgument("Options are not supported for node configs")
         else:
         else:
-            # create a dict for build.sh 
+            # create a dict for build.sh
             build_sh_spec={'kargs':[]}
             build_sh_spec={'kargs':[]}
+            # use node tags as defaults
+            # check for node tag equivalents
+            tags = NodeTags(self.api,
+                            {'node_id': node['node_id'],
+                             'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
+                                         'no-hangcheck', 'systemd-debug' ]},
+                            ['tagname', 'value'])
+            if tags:
+                for tag in tags:
+                    if tag['tagname'] == 'serial':
+                        build_sh_spec['serial'] = tag['value']
+                    elif tag['tagname'] == 'cramfs':
+                        build_sh_spec['cramfs'] = True
+                    elif tag['tagname'] == 'kvariant':
+                        build_sh_spec['variant'] = tag['value']
+                    elif tag['tagname'] == 'kargs':
+                        build_sh_spec['kargs'] += tag['value'].split()
+                    elif tag['tagname'] == 'no-hangcheck':
+                        build_sh_spec['kargs'].append('hcheck_reboot0')
+                    elif tag['tagname'] == 'systemd-debug':
+                        build_sh_spec['kargs'].append('systemd.log_level=debug')
+                        build_sh_spec['kargs'].append('systemd.log_target=console')
+            # then options can override tags
             for option in options:
                 if option == "cramfs":
                     build_sh_spec['cramfs']=True
                 elif option == 'partition':
                     if type != "usb":
             for option in options:
                 if option == "cramfs":
                     build_sh_spec['cramfs']=True
                 elif option == 'partition':
                     if type != "usb":
-                        raise PLCInvalidArgument, "option 'partition' is for USB images only"
+                        raise PLCInvalidArgument("option 'partition' is for USB images only")
                     else:
                         type="usb_partition"
                 elif option == "serial":
                     build_sh_spec['serial']='default'
                 elif option.find("serial:") == 0:
                     build_sh_spec['serial']=option.replace("serial:","")
                     else:
                         type="usb_partition"
                 elif option == "serial":
                     build_sh_spec['serial']='default'
                 elif option.find("serial:") == 0:
                     build_sh_spec['serial']=option.replace("serial:","")
+                elif option.find("variant:") == 0:
+                    build_sh_spec['variant']=option.replace("variant:","")
                 elif option == "no-hangcheck":
                     build_sh_spec['kargs'].append('hcheck_reboot0')
                 elif option == "no-hangcheck":
                     build_sh_spec['kargs'].append('hcheck_reboot0')
+                elif option == "systemd-debug":
+                    build_sh_spec['kargs'].append('systemd.log_level=debug')
                 else:
                 else:
-                    raise PLCInvalidArgument, "unknown option %s"%option
+                    raise PLCInvalidArgument("unknown option {}".format(option))
 
 
-        ### check node if needed
+        # compute nodename according the action
         if action.find("node-") == 0:
         if action.find("node-") == 0:
-            nodes = Nodes(self.api, [node_id_or_hostname])
-            if not nodes:
-                raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
-            node = nodes[0]
             nodename = node['hostname']
             nodename = node['hostname']
-
         else:
             node = None
             # compute a 8 bytes random number
         else:
             node = None
             # compute a 8 bytes random number
@@ -312,98 +486,69 @@ class GetBootMedium(Method):
             nodename = "".join(map(hexa2,tempbytes))
 
         # get nodefamily
             nodename = "".join(map(hexa2,tempbytes))
 
         # get nodefamily
-        (pldistro,arch) = self.get_nodefamily(node)
-        self.nodefamily="%s-%s"%(pldistro,arch)
+        (pldistro,fcdistro,arch) = self.get_nodefamily(node, auth)
+        self.nodefamily="{}-{}-{}".format(pldistro, fcdistro, arch)
+
         # apply on globals
         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
         # apply on globals
         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
-            
-        ### handle filename
-        # allow to set filename to None or any other empty value
-        if not filename: filename=''
-        filename = filename.replace ("%d",self.WORKDIR)
-        filename = filename.replace ("%n",nodename)
-        filename = filename.replace ("%s",suffix)
-        filename = filename.replace ("%p",self.api.config.PLC_NAME)
-        # let's be cautious
-        try: filename = filename.replace ("%f", self.nodefamily)
-        except: pass
-        try: filename = filename.replace ("%a", arch)
-        except: pass
-        try: filename = filename.replace ("%v",self.bootcd_version())
-        except: pass
 
 
-        ### Check filename location
-        if filename != '':
-            if 'admin' not in self.caller['roles']:
-                if ( filename.index(self.WORKDIR) != 0):
-                    raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
+        filename = self.handle_filename(filename, nodename, suffix, arch)
 
 
-            ### output should not exist (concurrent runs ..)
-            if os.path.exists(filename):
-                raise PLCInvalidArgument, "Resulting file %s already exists"%filename
-
-            ### we can now safely create the file, 
-            ### either we are admin or under a controlled location
-            filedir=os.path.dirname(filename)
-            # dirname does not return "." for a local filename like its shell counterpart
-            if filedir:
-                if not os.path.exists(filedir):
-                    try:
-                        os.makedirs (filedir,0777)
-                    except:
-                        raise PLCPermissionDenied, "Could not create dir %s"%filedir
-
-        
         # log call
         if node:
         # log call
         if node:
-            self.message='GetBootMedium on node %s - action=%s'%(nodename,action)
+            self.message='GetBootMedium on node {} - action={}'.format(nodename, action)
             self.event_objects={'Node': [ node ['node_id'] ]}
         else:
             self.event_objects={'Node': [ node ['node_id'] ]}
         else:
-            self.message='GetBootMedium - generic - action=%s'%action
+            self.message='GetBootMedium - generic - action={}'.format(action)
 
         ### generic media
         if action == 'generic-iso' or action == 'generic-usb':
             if options:
 
         ### generic media
         if action == 'generic-iso' or action == 'generic-usb':
             if options:
-                raise PLCInvalidArgument, "Options are not supported for generic images"
+                raise PLCInvalidArgument("Options are not supported for generic images")
             # this raises an exception if bootcd is missing
             version = self.bootcd_version()
             # this raises an exception if bootcd is missing
             version = self.bootcd_version()
-            generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
-                                             version,
-                                             suffix)
-            generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
+            generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
+            generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
 
             if filename:
 
             if filename:
-                ret=os.system ("cp %s %s"%(generic_path,filename))
+                ret=os.system ('cp "{}" "{}"'.format(generic_path, filename))
                 if ret==0:
                     return filename
                 else:
                 if ret==0:
                     return filename
                 else:
-                    raise PLCPermissionDenied, "Could not copy %s into"%(generic_path,filename)
+                    raise PLCPermissionDenied("Could not copy {} into {}"\
+                                              .format(generic_path, filename))
             else:
                 ### return the generic medium content as-is, just base64 encoded
                 return base64.b64encode(file(generic_path).read())
 
             else:
                 ### return the generic medium content as-is, just base64 encoded
                 return base64.b64encode(file(generic_path).read())
 
-       ### config file preview or regenerated
-       if action == 'node-preview' or action == 'node-floppy':
+        ### config file preview or regenerated
+        if action == 'node-preview' or action == 'node-floppy':
             renew_key = (action == 'node-floppy')
             floppy = self.floppy_contents (node,renew_key)
             renew_key = (action == 'node-floppy')
             floppy = self.floppy_contents (node,renew_key)
-           if filename:
-               try:
-                   file(filename,'w').write(floppy)
-               except:
-                   raise PLCPermissionDenied, "Could not write into %s"%filename
-               return filename
-           else:
-               return floppy
+            if filename:
+                try:
+                    file(filename,'w').write(floppy)
+                except:
+                    raise PLCPermissionDenied("Could not write into {}".format(filename))
+                return filename
+            else:
+                return floppy
 
         ### we're left with node-iso and node-usb
 
         ### we're left with node-iso and node-usb
+        # the steps involved in the image creation are:
+        # - create and test the working environment
+        # - generate the configuration file
+        # - build and invoke the build command
+        # - delivery the resulting image file
+
         if action == 'node-iso' or action == 'node-usb':
 
             ### check we've got required material
             version = self.bootcd_version()
         if action == 'node-iso' or action == 'node-usb':
 
             ### check we've got required material
             version = self.bootcd_version()
-            
+
             if not os.path.isfile(self.BOOTCDBUILD):
             if not os.path.isfile(self.BOOTCDBUILD):
-                raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
+                raise PLCAPIError("Cannot locate bootcd/build.sh script {}".format(self.BOOTCDBUILD))
 
             # create the workdir if needed
             if not os.path.isdir(self.WORKDIR):
 
             # create the workdir if needed
             if not os.path.isdir(self.WORKDIR):
@@ -411,69 +556,58 @@ class GetBootMedium(Method):
                     os.makedirs(self.WORKDIR,0777)
                     os.chmod(self.WORKDIR,0777)
                 except:
                     os.makedirs(self.WORKDIR,0777)
                     os.chmod(self.WORKDIR,0777)
                 except:
-                    raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
-            
+                    raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
+
             try:
                 # generate floppy config
             try:
                 # generate floppy config
-                floppy_text = self.floppy_contents(node,True)
+                floppy_text = self.floppy_contents(node, True)
                 # store it
                 # store it
-                floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
+                floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
                 try:
                     file(floppy_file,"w").write(floppy_text)
                 except:
                 try:
                     file(floppy_file,"w").write(floppy_text)
                 except:
-                    raise PLCPermissionDenied, "Could not write into %s"%floppy_file
+                    raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
 
                 self.trash.append(floppy_file)
 
 
                 self.trash.append(floppy_file)
 
-                node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
-
-                # make build's arguments
-                build_sh_options=""
-                if "cramfs" in build_sh_spec: 
-                    type += "_cramfs"
-                if "serial" in build_sh_spec: 
-                    build_sh_options += " -s %s"%build_sh_spec['serial']
-                
-                for karg in build_sh_spec['kargs']:
-                    build_sh_options += ' -k "%s"'%karg
-
-                log_file="%s.log"%node_image
-                # invoke build.sh
-                build_command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
-                                                                         floppy_file,
-                                                                         node_image,
-                                                                         type,
-                                                                         build_sh_options,
-                                                                         log_file)
-                if self.DEBUG:
-                    print 'build command:',build_command
-                ret=os.system(build_command)
+                node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
+
+                command, log_file = self.build_command(nodename, node_type, build_sh_spec,
+                                                       node_image, type, floppy_file)
+
+                # invoke the image build script
+                if command != "":
+                    ret = os.system(command)
+
                 if ret != 0:
                 if ret != 0:
-                    raise PLCAPIError,"bootcd/build.sh failed\n%s\n%s"%(
-                        build_command,file(log_file).read())
+                    raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
+                                      .format(self.BOOTCDBUILD, command, log_file))
 
 
-                self.trash.append(log_file)
                 if not os.path.isfile (node_image):
                 if not os.path.isfile (node_image):
-                    raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
-            
+                    raise PLCAPIError("Unexpected location of build.sh output - {}".format(node_image))
+
                 # handle result
                 if filename:
                 # handle result
                 if filename:
-                    ret=os.system("mv %s %s"%(node_image,filename))
+                    ret = os.system('mv "{}" "{}"'.format(node_image, filename))
                     if ret != 0:
                         self.trash.append(node_image)
                         self.cleantrash()
                     if ret != 0:
                         self.trash.append(node_image)
                         self.cleantrash()
-                        raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
+                        raise PLCAPIError("Could not move node image {} into {}"\
+                                          .format(node_image, filename))
                     self.cleantrash()
                     return filename
                 else:
                     result = file(node_image).read()
                     self.trash.append(node_image)
                     self.cleantrash()
                     self.cleantrash()
                     return filename
                 else:
                     result = file(node_image).read()
                     self.trash.append(node_image)
                     self.cleantrash()
-                    return base64.b64encode(result)
+                    print >> log, "GetBootMedium - done with build.sh"
+                    encoded_result = base64.b64encode(result)
+                    print >> log, "GetBootMedium - done with base64 encoding - lengths: raw={} - b64={}"\
+                        .format(len(result), len(encoded_result))
+                    return encoded_result
             except:
                 self.cleantrash()
                 raise
             except:
                 self.cleantrash()
                 raise
-                
-        # we're done here, or we missed something
-        raise PLCAPIError,'Unhandled action %s'%action
 
 
+        # we're done here, or we missed something
+        raise PLCAPIError('Unhandled action {}'.format(action))