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