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