trashing existing tmp file if older than 5 minutes
[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     def bootcd_version (self):
277         try:
278             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
279         except:
280             raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
281
282     def cleantrash (self):
283         for file in self.trash:
284             if self.DEBUG:
285                 print 'DEBUG -- preserving',file
286             else:
287                 os.unlink(file)
288
289     ### handle filename
290     # build the filename string
291     # check for permissions and concurrency
292     # returns the filename
293     def handle_filename (self, filename, nodename, suffix, arch):
294         # allow to set filename to None or any other empty value
295         if not filename: filename=''
296         filename = filename.replace ("%d",self.WORKDIR)
297         filename = filename.replace ("%n",nodename)
298         filename = filename.replace ("%s",suffix)
299         filename = filename.replace ("%p",self.api.config.PLC_NAME)
300         # let's be cautious
301         try: filename = filename.replace ("%f", self.nodefamily)
302         except: pass
303         try: filename = filename.replace ("%a", arch)
304         except: pass
305         try: filename = filename.replace ("%v",self.bootcd_version())
306         except: pass
307
308         ### Check filename location
309         if filename != '':
310             if 'admin' not in self.caller['roles']:
311                 if ( filename.index(self.WORKDIR) != 0):
312                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
313
314             ### output should not exist (concurrent runs ..)
315             # numerous reports of issues with this policy
316             # looks like people sometime suspend/cancel their download
317             # and this leads to the old file sitting in there forever
318             # so, if the file is older than 5 minutes, we just trash
319             grace=5
320             if os.path.exists(filename) and os.path.getmtime(filename)-time.time() >= (grace*60):
321                 os.unlink(filename)
322             if os.path.exists(filename):
323                 raise PLCInvalidArgument, "Resulting file %s already exists - please try again in %d minutes"%\
324                     (filename,grace)
325
326             ### we can now safely create the file,
327             ### either we are admin or under a controlled location
328             filedir=os.path.dirname(filename)
329             # dirname does not return "." for a local filename like its shell counterpart
330             if filedir:
331                 if not os.path.exists(filedir):
332                     try:
333                         os.makedirs (filedir,0777)
334                     except:
335                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
336
337         return filename
338
339     # Build the command line to be executed
340     # according the node type
341     def build_command(self, node_type, build_sh_spec, node_image, type, floppy_file, log_file):
342
343         command = ""
344
345         # regular node, make build's arguments
346         # and build the full command line to be called
347         if node_type in [ 'regular', 'reservable' ]:
348
349             build_sh_options=""
350             if "cramfs" in build_sh_spec:
351                 type += "_cramfs"
352             if "serial" in build_sh_spec:
353                 build_sh_options += " -s %s"%build_sh_spec['serial']
354             if "variant" in build_sh_spec:
355                 build_sh_options += " -V %s"%build_sh_spec['variant']
356
357             for karg in build_sh_spec['kargs']:
358                 build_sh_options += ' -k "%s"'%karg
359
360             log_file="%s.log"%node_image
361
362             command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
363                                                                  floppy_file,
364                                                                  node_image,
365                                                                  type,
366                                                                  build_sh_options,
367                                                                  log_file)
368
369         if self.DEBUG:
370             print "The build command line is %s" % command
371
372         return command
373
374     def call(self, auth, node_id_or_hostname, action, filename, options = []):
375
376         self.trash=[]
377
378         ### compute file suffix and type
379         if action.find("-iso") >= 0 :
380             suffix=".iso"
381             type = "iso"
382         elif action.find("-usb") >= 0:
383             suffix=".usb"
384             type = "usb"
385         else:
386             suffix=".txt"
387             type = "txt"
388
389         # check for node existence and get node_type
390         nodes = Nodes(self.api, [node_id_or_hostname])
391         if not nodes:
392             raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
393         node = nodes[0]
394
395         if self.DEBUG: print "%s required on node %s. Node type is: %s" \
396                 % (action, node['node_id'], node['node_type'])
397
398         # check the required action against the node type
399         node_type = node['node_type']
400         if action not in allowed_actions[node_type]:
401             raise PLCInvalidArgument, "Action %s not valid for %s nodes, valid actions are %s" \
402                                    % (action, node_type, "|".join(allowed_actions[node_type]))
403
404         # handle / canonicalize options
405         if type == "txt":
406             if options:
407                 raise PLCInvalidArgument, "Options are not supported for node configs"
408         else:
409             # create a dict for build.sh
410             build_sh_spec={'kargs':[]}
411             # use node tags as defaults
412             # check for node tag equivalents
413             tags = NodeTags(self.api,
414                             {'node_id': node['node_id'],
415                              'tagname': ['serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck']},
416                             ['tagname', 'value'])
417             if tags:
418                 for tag in tags:
419                     if tag['tagname'] == 'serial':
420                         build_sh_spec['serial'] = tag['value']
421                     if tag['tagname'] == 'cramfs':
422                         build_sh_spec['cramfs'] = True
423                     if tag['tagname'] == 'kvariant':
424                         build_sh_spec['variant'] = tag['value']
425                     if tag['tagname'] == 'kargs':
426                         build_sh_spec['kargs'] += tag['value'].split()
427                     if tag['tagname'] == 'no-hangcheck':
428                         build_sh_spec['kargs'].append('hcheck_reboot0')
429             # then options can override tags
430             for option in options:
431                 if option == "cramfs":
432                     build_sh_spec['cramfs']=True
433                 elif option == 'partition':
434                     if type != "usb":
435                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
436                     else:
437                         type="usb_partition"
438                 elif option == "serial":
439                     build_sh_spec['serial']='default'
440                 elif option.find("serial:") == 0:
441                     build_sh_spec['serial']=option.replace("serial:","")
442                 elif option.find("variant:") == 0:
443                     build_sh_spec['variant']=option.replace("variant:","")
444                 elif option == "no-hangcheck":
445                     build_sh_spec['kargs'].append('hcheck_reboot0')
446                 else:
447                     raise PLCInvalidArgument, "unknown option %s"%option
448
449         # compute nodename according the action
450         if action.find("node-") == 0:
451             nodename = node['hostname']
452         else:
453             node = None
454             # compute a 8 bytes random number
455             tempbytes = random.sample (xrange(0,256), 8);
456             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
457             nodename = "".join(map(hexa2,tempbytes))
458
459         # get nodefamily
460         (pldistro,fcdistro,arch) = self.get_nodefamily(node,auth)
461         self.nodefamily="%s-%s-%s"%(pldistro,fcdistro,arch)
462
463         # apply on globals
464         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
465             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
466
467         filename = self.handle_filename(filename, nodename, suffix, arch)
468
469         # log call
470         if node:
471             self.message='GetBootMedium on node %s - action=%s'%(nodename,action)
472             self.event_objects={'Node': [ node ['node_id'] ]}
473         else:
474             self.message='GetBootMedium - generic - action=%s'%action
475
476         ### generic media
477         if action == 'generic-iso' or action == 'generic-usb':
478             if options:
479                 raise PLCInvalidArgument, "Options are not supported for generic images"
480             # this raises an exception if bootcd is missing
481             version = self.bootcd_version()
482             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
483                                              version,
484                                              suffix)
485             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
486
487             if filename:
488                 ret=os.system ('cp "%s" "%s"'%(generic_path,filename))
489                 if ret==0:
490                     return filename
491                 else:
492                     raise PLCPermissionDenied, "Could not copy %s into %s"%(generic_path,filename)
493             else:
494                 ### return the generic medium content as-is, just base64 encoded
495                 return base64.b64encode(file(generic_path).read())
496
497         ### config file preview or regenerated
498         if action == 'node-preview' or action == 'node-floppy':
499             renew_key = (action == 'node-floppy')
500             floppy = self.floppy_contents (node,renew_key)
501             if filename:
502                 try:
503                     file(filename,'w').write(floppy)
504                 except:
505                     raise PLCPermissionDenied, "Could not write into %s"%filename
506                 return filename
507             else:
508                 return floppy
509
510         ### we're left with node-iso and node-usb
511         # the steps involved in the image creation are:
512         # - create and test the working environment
513         # - generate the configuration file
514         # - build and invoke the build command
515         # - delivery the resulting image file
516
517         if action == 'node-iso' or action == 'node-usb':
518
519             ### check we've got required material
520             version = self.bootcd_version()
521
522             if not os.path.isfile(self.BOOTCDBUILD):
523                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
524
525             # create the workdir if needed
526             if not os.path.isdir(self.WORKDIR):
527                 try:
528                     os.makedirs(self.WORKDIR,0777)
529                     os.chmod(self.WORKDIR,0777)
530                 except:
531                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
532
533             try:
534                 # generate floppy config
535                 floppy_text = self.floppy_contents(node,True)
536                 # store it
537                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
538                 try:
539                     file(floppy_file,"w").write(floppy_text)
540                 except:
541                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
542
543                 self.trash.append(floppy_file)
544
545                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
546                 log_file="%s.log"%node_image
547
548                 command = self.build_command(node_type, build_sh_spec, node_image, type, floppy_file, log_file)
549
550                 # invoke the image build script
551                 if command != "":
552                     ret=os.system(command)
553
554                 if ret != 0:
555                     raise PLCAPIError, "%s failed Command line was: %s Error logs: %s" % \
556                               (self.BOOTCDBUILD,  command, file(log_file).read())
557
558                 self.trash.append(log_file)
559
560                 if not os.path.isfile (node_image):
561                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
562
563                 # handle result
564                 if filename:
565                     ret=os.system('mv "%s" "%s"'%(node_image,filename))
566                     if ret != 0:
567                         self.trash.append(node_image)
568                         self.cleantrash()
569                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
570                     self.cleantrash()
571                     return filename
572                 else:
573                     result = file(node_image).read()
574                     self.trash.append(node_image)
575                     self.cleantrash()
576                     return base64.b64encode(result)
577             except:
578                 self.cleantrash()
579                 raise
580
581         # we're done here, or we missed something
582         raise PLCAPIError,'Unhandled action %s'%action