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