fix IndexError: list index out of range
[plcapi.git] / PLC / Methods / GetBootMedium.py
1 # $Id$
2 # $URL$
3 import random
4 import base64
5 import os
6 import os.path
7 import time
8
9 from PLC.Faults import *
10 from PLC.Method import Method
11 from PLC.Parameter import Parameter, Mixed
12 from PLC.Auth import Auth
13
14 from PLC.Nodes import Node, Nodes
15 from PLC.Interfaces import Interface, Interfaces
16 from PLC.InterfaceTags import InterfaceTag, InterfaceTags
17 from PLC.NodeTags import NodeTag, NodeTags
18
19 from PLC.Accessors.Accessors_standard import *                  # import node accessors
20
21 # could not define this in the class..
22 # create a dict with the allowed actions for each type of node
23 allowed_actions = {
24                 'regular' : [ 'node-preview',
25                               'node-floppy',
26                               'node-iso',
27                               'node-usb',
28                               'generic-iso',
29                               'generic-usb',
30                                ],
31                 }
32
33 # compute a new key
34 def compute_key():
35     # Generate 32 random bytes
36     bytes = random.sample(xrange(0, 256), 32)
37     # Base64 encode their string representation
38     key = base64.b64encode("".join(map(chr, bytes)))
39     # Boot Manager cannot handle = in the key
40     # XXX this sounds wrong, as it might prevent proper decoding
41     key = key.replace("=", "")
42     return key
43
44 class GetBootMedium(Method):
45     """
46     This method is a redesign based on former, supposedly dedicated, 
47     AdmGenerateNodeConfFile
48
49     As compared with its ancestor, this method provides a much more 
50     detailed interface, that allows to
51     (*) either just preview the node config file -- in which case 
52         the node key is NOT recomputed, and NOT provided in the output
53     (*) or regenerate the node config file for storage on a floppy 
54         that is, exactly what the ancestor method used todo, 
55         including renewing the node's key
56     (*) or regenerate the config file and bundle it inside an ISO or USB image
57     (*) or just provide the generic ISO or USB boot images 
58         in which case of course the node_id_or_hostname parameter is not used
59
60     action is expected among the following string constants according the
61     node type value:
62
63     for a 'regular' node:
64     (*) node-preview
65     (*) node-floppy
66     (*) node-iso
67     (*) node-usb
68     (*) generic-iso
69     (*) generic-usb
70
71     Apart for the preview mode, this method generates a new node key for the
72     specified node, effectively invalidating any old boot medium.
73
74     In addition, two return mechanisms are supported.
75     (*) The default behaviour is that the file's content is returned as a 
76         base64-encoded string. This is how the ancestor method used to work.
77         To use this method, pass an empty string as the file parameter.
78
79     (*) Or, for efficiency -- this makes sense only when the API is used 
80         by the web pages that run on the same host -- the caller may provide 
81         a filename, in which case the resulting file is stored in that location instead. 
82         The filename argument can use the following markers, that are expanded 
83         within the method
84         - %d : default root dir (some builtin dedicated area under /var/tmp/)
85                Using this is recommended, and enforced for non-admin users
86         - %n : the node's name when this makes sense, or a mktemp-like name when 
87                generic media is requested
88         - %s : a file suffix appropriate in the context (.txt, .iso or the like)
89         - %v : the bootcd version string (e.g. 4.0)
90         - %p : the PLC name
91         - %f : the nodefamily
92         - %a : arch
93         With the file-based return mechanism, the method returns the full pathname 
94         of the result file; 
95         ** WARNING **
96         It is the caller's responsability to remove this file after use.
97
98     Options: an optional array of keywords. 
99         options are not supported for generic images
100       Currently supported are
101         - 'partition' - for USB actions only
102         - 'cramfs'
103         - 'serial' or 'serial:<console_spec>'
104         console_spec (or 'default') is passed as-is to bootcd/build.sh
105         it is expected to be a colon separated string denoting
106         tty - baudrate - parity - bits
107         e.g. ttyS0:115200:n:8
108         - 'variant:<variantname>'
109         passed to build.sh as -V <variant> 
110         variants are used to run a different kernel on the bootCD
111         see kvariant.sh for how to create a variant
112         - 'no-hangcheck' - disable hangcheck
113
114     Tags: the following tags are taken into account when attached to the node:
115         'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck'
116         
117     Security:
118         - Non-admins can only generate files for nodes at their sites.
119         - Non-admins, when they provide a filename, *must* specify it in the %d area
120
121    Housekeeping: 
122         Whenever needed, the method stores intermediate files in a
123         private area, typically not located under the web server's
124         accessible area, and are cleaned up by the method.
125
126     """
127
128     roles = ['admin', 'pi', 'tech']
129
130     accepts = [
131         Auth(),
132         Mixed(Node.fields['node_id'],
133               Node.fields['hostname']),
134         Parameter (str, "Action mode, expected value depends of the type of node"),
135         Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
136         Parameter ([str], "Options"),
137         ]
138
139     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
140
141     # define globals for regular nodes, override later for other types
142     BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
143     BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
144     GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
145     WORKDIR = "/var/tmp/bootmedium"
146     DEBUG = False
147     # uncomment this to preserve temporary area and bootcustom logs
148     #DEBUG = True
149
150     ### returns (host, domain) :
151     # 'host' : host part of the hostname
152     # 'domain' : domain part of the hostname
153     def split_hostname (self, node):
154         # Split hostname into host and domain parts
155         parts = node['hostname'].split(".", 1)
156         if len(parts) < 2:
157             raise PLCInvalidArgument, "Node hostname %s is invalid"%node['hostname']
158         return parts
159         
160     # Generate the node (plnode.txt) configuration content.
161     #
162     # This function will create the configuration file a node
163     # composed by:
164     #  - a common part, regardless of the 'node_type' tag
165     #  - XXX a special part, depending on the 'node_type' tag value.
166     def floppy_contents (self, node, renew_key):
167
168         # Do basic checks
169         if node['peer_id'] is not None:
170             raise PLCInvalidArgument, "Not a local node"
171
172         # If we are not an admin, make sure that the caller is a
173         # member of the site at which the node is located.
174         if 'admin' not in self.caller['roles']:
175             if node['site_id'] not in self.caller['site_ids']:
176                 raise PLCPermissionDenied, "Not allowed to generate a configuration file for %s"%node['hostname']
177
178         # Get interface for this node
179         primary = None
180         interfaces = Interfaces(self.api, node['interface_ids'])
181         for interface in interfaces:
182             if interface['is_primary']:
183                 primary = interface
184                 break
185         if primary is None:
186             raise PLCInvalidArgument, "No primary network configured on %s"%node['hostname']
187
188         ( host, domain ) = self.split_hostname (node)
189
190         # renew the key and save it on the database
191         if renew_key:
192             node['key'] = compute_key()
193             node.sync()
194
195         # Generate node configuration file suitable for BootCD
196         file = ""
197
198         if renew_key:
199             file += 'NODE_ID="%d"\n' % node['node_id']
200             file += 'NODE_KEY="%s"\n' % node['key']
201             # not used anywhere, just a note for operations people
202             file += 'KEY_RENEWAL_DATE="%s"\n' % time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime())
203
204         if primary['mac']:
205             file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
206
207         file += 'IP_METHOD="%s"\n' % primary['method']
208
209         if primary['method'] == 'static':
210             file += 'IP_ADDRESS="%s"\n' % primary['ip']
211             file += 'IP_GATEWAY="%s"\n' % primary['gateway']
212             file += 'IP_NETMASK="%s"\n' % primary['netmask']
213             file += 'IP_NETADDR="%s"\n' % primary['network']
214             file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
215             file += 'IP_DNS1="%s"\n' % primary['dns1']
216             file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
217
218         file += 'HOST_NAME="%s"\n' % host
219         file += 'DOMAIN_NAME="%s"\n' % domain
220
221         # define various interface settings attached to the primary interface
222         settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
223
224         categories = set()
225         for setting in settings:
226             if setting['category'] is not None:
227                 categories.add(setting['category'])
228         
229         for category in categories:
230             category_settings = InterfaceTags(self.api,{'interface_id':interface['interface_id'],
231                                                               'category':category})
232             if category_settings:
233                 file += '### Category : %s\n'%category
234                 for setting in category_settings:
235                     file += '%s_%s="%s"\n'%(category.upper(),setting['tagname'].upper(),setting['value'])
236
237         for interface in interfaces:
238             if interface['method'] == 'ipmi':
239                 file += 'IPMI_ADDRESS="%s"\n' % interface['ip']
240                 if interface['mac']:
241                     file += 'IPMI_MAC="%s"\n' % interface['mac'].lower()
242                 break
243
244         return file
245
246     # see also GetNodeFlavour that does similar things
247     def get_nodefamily (self, node, auth):
248         pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
249         fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
250         arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
251         if not node:
252             return (pldistro,fcdistro,arch)
253         
254         node_id=node['node_id']
255         
256         # no support for deployment-based BootCD's, use kvariants instead
257         node_pldistro = GetNodePldistro (self.api).call(auth, node_id)
258         if node_pldistro: pldistro = node_pldistro
259
260         node_fcdistro = GetNodeFcdistro (self.api).call(auth, node_id)
261         if node_fcdistro: fcdistro = node_fcdistro
262
263         node_arch = GetNodeArch (self.api).call(auth,node_id)
264         if node_arch: arch = node_arch
265         
266         return (pldistro,fcdistro,arch)
267
268     def bootcd_version (self):
269         try:
270             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
271         except:
272             raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
273     
274     def cleantrash (self):
275         for file in self.trash:
276             if self.DEBUG:
277                 print 'DEBUG -- preserving',file
278             else:
279                 os.unlink(file)
280
281     ### handle filename
282     # build the filename string 
283     # check for permissions and concurrency
284     # returns the filename
285     def handle_filename (self, filename, nodename, suffix, arch):
286         # allow to set filename to None or any other empty value
287         if not filename: filename=''
288         filename = filename.replace ("%d",self.WORKDIR)
289         filename = filename.replace ("%n",nodename)
290         filename = filename.replace ("%s",suffix)
291         filename = filename.replace ("%p",self.api.config.PLC_NAME)
292         # let's be cautious
293         try: filename = filename.replace ("%f", self.nodefamily)
294         except: pass
295         try: filename = filename.replace ("%a", arch)
296         except: pass
297         try: filename = filename.replace ("%v",self.bootcd_version())
298         except: pass
299
300         ### Check filename location
301         if filename != '':
302             if 'admin' not in self.caller['roles']:
303                 if ( filename.index(self.WORKDIR) != 0):
304                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
305
306             ### output should not exist (concurrent runs ..)
307             if os.path.exists(filename):
308                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
309
310             ### we can now safely create the file, 
311             ### either we are admin or under a controlled location
312             filedir=os.path.dirname(filename)
313             # dirname does not return "." for a local filename like its shell counterpart
314             if filedir:
315                 if not os.path.exists(filedir):
316                     try:
317                         os.makedirs (filedir,0777)
318                     except:
319                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
320
321         return filename
322
323     # Build the command line to be executed
324     # according the node type
325     def build_command(self, node_type, build_sh_spec, node_image, type, floppy_file, log_file):
326
327         command = ""
328
329         # regular node, make build's arguments
330         # and build the full command line to be called
331         if node_type == 'regular':
332
333             build_sh_options=""
334             if "cramfs" in build_sh_spec: 
335                 type += "_cramfs"
336             if "serial" in build_sh_spec: 
337                 build_sh_options += " -s %s"%build_sh_spec['serial']
338             if "variant" in build_sh_spec:
339                 build_sh_options += " -V %s"%build_sh_spec['variant']
340             
341             if len(build_sh_spec['kargs']) > 0:
342                 for karg in build_sh_spec['kargs'][0]:
343                     build_sh_options += ' -k "%s"'%karg
344
345             log_file="%s.log"%node_image
346
347             command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
348                                                                  floppy_file,
349                                                                  node_image,
350                                                                  type,
351                                                                  build_sh_options,
352                                                                  log_file)
353
354         if self.DEBUG:
355             print "The build command line is %s" % command
356
357         return command 
358
359     def call(self, auth, node_id_or_hostname, action, filename, options = []):
360
361         self.trash=[]
362
363         ### compute file suffix and type
364         if action.find("-iso") >= 0 :
365             suffix=".iso"
366             type = "iso"
367         elif action.find("-usb") >= 0:
368             suffix=".usb"
369             type = "usb"
370         else:
371             suffix=".txt"
372             type = "txt"
373
374         # check for node existence and get node_type
375         nodes = Nodes(self.api, [node_id_or_hostname])
376         if not nodes:
377             raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
378         node = nodes[0]
379
380         if self.DEBUG: print "%s required on node %s. Node type is: %s" \
381                 % (action, node['node_id'], node['node_type'])
382
383         # check the required action against the node type
384         node_type = node['node_type']
385         if action not in allowed_actions[node_type]:
386             raise PLCInvalidArgument, "Action %s not valid for %s nodes, valid actions are %s" \
387                                    % (action, node_type, "|".join(allowed_actions[node_type]))
388
389         # handle / canonicalize options
390         if type == "txt":
391             if options:
392                 raise PLCInvalidArgument, "Options are not supported for node configs"
393         else:
394             # create a dict for build.sh 
395             build_sh_spec={'kargs':[]}
396             # use node tags as defaults
397             # check for node tag equivalents
398             tags = NodeTags(self.api, 
399                             {'node_id': node['node_id'], 
400                              'tagname': ['serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck']},
401                             ['tagname', 'value'])
402             if tags:
403                 for tag in tags:
404                     if tag['tagname'] == 'serial':
405                         build_sh_spec['serial'] = tag['value']
406                     if tag['tagname'] == 'cramfs':
407                         build_sh_spec['cramfs'] = True
408                     if tag['tagname'] == 'kvariant':
409                         build_sh_spec['variant'] = tag['value']
410                     if tag['tagname'] == 'kargs':
411                         build_sh_spec['kargs'].append(tag['value'].split())
412                     if tag['tagname'] == 'no-hangcheck':
413                         build_sh_spec['kargs'].append('hcheck_reboot0')
414             # then options can override tags
415             for option in options:
416                 if option == "cramfs":
417                     build_sh_spec['cramfs']=True
418                 elif option == 'partition':
419                     if type != "usb":
420                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
421                     else:
422                         type="usb_partition"
423                 elif option == "serial":
424                     build_sh_spec['serial']='default'
425                 elif option.find("serial:") == 0:
426                     build_sh_spec['serial']=option.replace("serial:","")
427                 elif option.find("variant:") == 0:
428                     build_sh_spec['variant']=option.replace("variant:","")
429                 elif option == "no-hangcheck":
430                     build_sh_spec['kargs'].append('hcheck_reboot0')
431                 else:
432                     raise PLCInvalidArgument, "unknown option %s"%option
433
434         # compute nodename according the action
435         if action.find("node-") == 0:
436             nodename = node['hostname']
437         else:
438             node = None
439             # compute a 8 bytes random number
440             tempbytes = random.sample (xrange(0,256), 8);
441             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
442             nodename = "".join(map(hexa2,tempbytes))
443
444         # get nodefamily
445         (pldistro,fcdistro,arch) = self.get_nodefamily(node,auth)
446         self.nodefamily="%s-%s-%s"%(pldistro,fcdistro,arch)
447
448         # apply on globals
449         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
450             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
451             
452         filename = self.handle_filename(filename, nodename, suffix, arch)
453         
454         # log call
455         if node:
456             self.message='GetBootMedium on node %s - action=%s'%(nodename,action)
457             self.event_objects={'Node': [ node ['node_id'] ]}
458         else:
459             self.message='GetBootMedium - generic - action=%s'%action
460
461         ### generic media
462         if action == 'generic-iso' or action == 'generic-usb':
463             if options:
464                 raise PLCInvalidArgument, "Options are not supported for generic images"
465             # this raises an exception if bootcd is missing
466             version = self.bootcd_version()
467             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
468                                              version,
469                                              suffix)
470             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
471
472             if filename:
473                 ret=os.system ('cp "%s" "%s"'%(generic_path,filename))
474                 if ret==0:
475                     return filename
476                 else:
477                     raise PLCPermissionDenied, "Could not copy %s into %s"%(generic_path,filename)
478             else:
479                 ### return the generic medium content as-is, just base64 encoded
480                 return base64.b64encode(file(generic_path).read())
481
482         ### config file preview or regenerated
483         if action == 'node-preview' or action == 'node-floppy':
484             renew_key = (action == 'node-floppy')
485             floppy = self.floppy_contents (node,renew_key)
486             if filename:
487                 try:
488                     file(filename,'w').write(floppy)
489                 except:
490                     raise PLCPermissionDenied, "Could not write into %s"%filename
491                 return filename
492             else:
493                 return floppy
494
495         ### we're left with node-iso and node-usb
496         # the steps involved in the image creation are:
497         # - create and test the working environment
498         # - generate the configuration file
499         # - build and invoke the build command
500         # - delivery the resulting image file
501
502         if action == 'node-iso' or action == 'node-usb':
503
504             ### check we've got required material
505             version = self.bootcd_version()
506             
507             if not os.path.isfile(self.BOOTCDBUILD):
508                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
509
510             # create the workdir if needed
511             if not os.path.isdir(self.WORKDIR):
512                 try:
513                     os.makedirs(self.WORKDIR,0777)
514                     os.chmod(self.WORKDIR,0777)
515                 except:
516                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
517             
518             try:
519                 # generate floppy config
520                 floppy_text = self.floppy_contents(node,True)
521                 # store it
522                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
523                 try:
524                     file(floppy_file,"w").write(floppy_text)
525                 except:
526                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
527
528                 self.trash.append(floppy_file)
529
530                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
531                 log_file="%s.log"%node_image
532
533                 command = self.build_command(node_type, build_sh_spec, node_image, type, floppy_file, log_file)
534
535                 # invoke the image build script
536                 if command != "":
537                     ret=os.system(command)
538
539                 if ret != 0:
540                     raise PLCAPIError, "%s failed Command line was: %s Error logs: %s" % \
541                               (self.BOOTCDBUILD,  command, file(log_file).read())
542
543                 self.trash.append(log_file)
544
545                 if not os.path.isfile (node_image):
546                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
547             
548                 # handle result
549                 if filename:
550                     ret=os.system('mv "%s" "%s"'%(node_image,filename))
551                     if ret != 0:
552                         self.trash.append(node_image)
553                         self.cleantrash()
554                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
555                     self.cleantrash()
556                     return filename
557                 else:
558                     result = file(node_image).read()
559                     self.trash.append(node_image)
560                     self.cleantrash()
561                     return base64.b64encode(result)
562             except:
563                 self.cleantrash()
564                 raise
565                 
566         # we're done here, or we missed something
567         raise PLCAPIError,'Unhandled action %s'%action
568