GetBootMedium accepts the 'systemd-debug' option to turn on verbosity for the bootCD...
[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         - 'systemd-debug' - turn on systemd debug in bootcd
123
124     Tags: the following tags are taken into account when attached to the node:
125         'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck', 'systemd-debug'
126
127     Security:
128         - Non-admins can only generate files for nodes at their sites.
129         - Non-admins, when they provide a filename, *must* specify it in the %d area
130
131    Housekeeping:
132         Whenever needed, the method stores intermediate files in a
133         private area, typically not located under the web server's
134         accessible area, and are cleaned up by the method.
135
136     """
137
138     roles = ['admin', 'pi', 'tech']
139
140     accepts = [
141         Auth(),
142         Mixed(Node.fields['node_id'],
143               Node.fields['hostname']),
144         Parameter (str, "Action mode, expected value depends of the type of node"),
145         Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
146         Parameter ([str], "Options"),
147         ]
148
149     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
150
151     # define globals for regular nodes, override later for other types
152     BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
153     BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
154     GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
155     WORKDIR = "/var/tmp/bootmedium"
156     DEBUG = False
157     # uncomment this to preserve temporary area and bootcustom logs
158     #DEBUG = True
159
160     ### returns (host, domain) :
161     # 'host' : host part of the hostname
162     # 'domain' : domain part of the hostname
163     def split_hostname (self, node):
164         # Split hostname into host and domain parts
165         parts = node['hostname'].split(".", 1)
166         if len(parts) < 2:
167             raise PLCInvalidArgument, "Node hostname %s is invalid"%node['hostname']
168         return parts
169
170     # Generate the node (plnode.txt) configuration content.
171     #
172     # This function will create the configuration file a node
173     # composed by:
174     #  - a common part, regardless of the 'node_type' tag
175     #  - XXX a special part, depending on the 'node_type' tag value.
176     def floppy_contents (self, node, renew_key):
177
178         # Do basic checks
179         if node['peer_id'] is not None:
180             raise PLCInvalidArgument, "Not a local node"
181
182         # If we are not an admin, make sure that the caller is a
183         # member of the site at which the node is located.
184         if 'admin' not in self.caller['roles']:
185             if node['site_id'] not in self.caller['site_ids']:
186                 raise PLCPermissionDenied, "Not allowed to generate a configuration file for %s"%node['hostname']
187
188         # Get interface for this node
189         primary = None
190         interfaces = Interfaces(self.api, node['interface_ids'])
191         for interface in interfaces:
192             if interface['is_primary']:
193                 primary = interface
194                 break
195         if primary is None:
196             raise PLCInvalidArgument, "No primary network configured on %s"%node['hostname']
197
198         ( host, domain ) = self.split_hostname (node)
199
200         # renew the key and save it on the database
201         if renew_key:
202             node['key'] = compute_key()
203             node.update_last_download(commit=False)
204             node.sync()
205
206         # Generate node configuration file suitable for BootCD
207         file = ""
208
209         if renew_key:
210             file += 'NODE_ID="%d"\n' % node['node_id']
211             file += 'NODE_KEY="%s"\n' % node['key']
212             # not used anywhere, just a note for operations people
213             file += 'KEY_RENEWAL_DATE="%s"\n' % time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime())
214
215         if primary['mac']:
216             file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
217
218         file += 'IP_METHOD="%s"\n' % primary['method']
219
220         if primary['method'] == 'static':
221             file += 'IP_ADDRESS="%s"\n' % primary['ip']
222             file += 'IP_GATEWAY="%s"\n' % primary['gateway']
223             file += 'IP_NETMASK="%s"\n' % primary['netmask']
224             file += 'IP_NETADDR="%s"\n' % primary['network']
225             file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
226             file += 'IP_DNS1="%s"\n' % primary['dns1']
227             file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
228
229         file += 'HOST_NAME="%s"\n' % host
230         file += 'DOMAIN_NAME="%s"\n' % domain
231
232         # define various interface settings attached to the primary interface
233         settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
234
235         categories = set()
236         for setting in settings:
237             if setting['category'] is not None:
238                 categories.add(setting['category'])
239
240         for category in categories:
241             category_settings = InterfaceTags(self.api,{'interface_id':interface['interface_id'],
242                                                               'category':category})
243             if category_settings:
244                 file += '### Category : %s\n'%category
245                 for setting in category_settings:
246                     file += '%s_%s="%s"\n'%(category.upper(),setting['tagname'].upper(),setting['value'])
247
248         for interface in interfaces:
249             if interface['method'] == 'ipmi':
250                 file += 'IPMI_ADDRESS="%s"\n' % interface['ip']
251                 if interface['mac']:
252                     file += 'IPMI_MAC="%s"\n' % interface['mac'].lower()
253                 break
254
255         return file
256
257     # see also GetNodeFlavour that does similar things
258     def get_nodefamily (self, node, auth):
259         pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
260         fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
261         arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
262         if not node:
263             return (pldistro,fcdistro,arch)
264
265         node_id=node['node_id']
266
267         # no support for deployment-based BootCD's, use kvariants instead
268         node_pldistro = GetNodePldistro (self.api,self.caller).call(auth, node_id)
269         if node_pldistro: pldistro = node_pldistro
270
271         node_fcdistro = GetNodeFcdistro (self.api,self.caller).call(auth, node_id)
272         if node_fcdistro: fcdistro = node_fcdistro
273
274         node_arch = GetNodeArch (self.api,self.caller).call(auth,node_id)
275         if node_arch: arch = node_arch
276
277         return (pldistro,fcdistro,arch)
278
279     def bootcd_version (self):
280         try:
281             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
282         except:
283             raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
284
285     def cleantrash (self):
286         for file in self.trash:
287             if self.DEBUG:
288                 print >> log, 'DEBUG -- preserving',file
289             else:
290                 os.unlink(file)
291
292     ### handle filename
293     # build the filename string
294     # check for permissions and concurrency
295     # returns the filename
296     def handle_filename (self, filename, nodename, suffix, arch):
297         # allow to set filename to None or any other empty value
298         if not filename: filename=''
299         filename = filename.replace ("%d",self.WORKDIR)
300         filename = filename.replace ("%n",nodename)
301         filename = filename.replace ("%s",suffix)
302         filename = filename.replace ("%p",self.api.config.PLC_NAME)
303         # let's be cautious
304         try: filename = filename.replace ("%f", self.nodefamily)
305         except: pass
306         try: filename = filename.replace ("%a", arch)
307         except: pass
308         try: filename = filename.replace ("%v",self.bootcd_version())
309         except: pass
310
311         ### Check filename location
312         if filename != '':
313             if 'admin' not in self.caller['roles']:
314                 if ( filename.index(self.WORKDIR) != 0):
315                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
316
317             ### output should not exist (concurrent runs ..)
318             # numerous reports of issues with this policy
319             # looks like people sometime suspend/cancel their download
320             # and this leads to the old file sitting in there forever
321             # so, if the file is older than 5 minutes, we just trash
322             grace=5
323             if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
324                 os.unlink(filename)
325             if os.path.exists(filename):
326                 raise PLCInvalidArgument, "Resulting file %s already exists - please try again in %d minutes"%\
327                     (filename,grace)
328
329             ### we can now safely create the file,
330             ### either we are admin or under a controlled location
331             filedir=os.path.dirname(filename)
332             # dirname does not return "." for a local filename like its shell counterpart
333             if filedir:
334                 if not os.path.exists(filedir):
335                     try:
336                         os.makedirs (filedir,0777)
337                     except:
338                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
339
340         return filename
341
342     # Build the command line to be executed
343     # according the node type
344     def build_command(self, node_type, build_sh_spec, node_image, type, floppy_file, log_file):
345
346         command = ""
347
348         # regular node, make build's arguments
349         # and build the full command line to be called
350         if node_type in [ 'regular', 'reservable' ]:
351
352             build_sh_options=""
353             if "cramfs" in build_sh_spec:
354                 type += "_cramfs"
355             if "serial" in build_sh_spec:
356                 build_sh_options += " -s %s"%build_sh_spec['serial']
357             if "variant" in build_sh_spec:
358                 build_sh_options += " -V %s"%build_sh_spec['variant']
359
360             for karg in build_sh_spec['kargs']:
361                 build_sh_options += ' -k "%s"'%karg
362
363             log_file="%s.log"%node_image
364
365             command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
366                                                                  floppy_file,
367                                                                  node_image,
368                                                                  type,
369                                                                  build_sh_options,
370                                                                  log_file)
371
372         if self.DEBUG:
373             print >> log, "The build command line is %s" % command
374
375         return command
376
377     def call(self, auth, node_id_or_hostname, action, filename, options = []):
378
379         self.trash=[]
380
381         ### compute file suffix and type
382         if action.find("-iso") >= 0 :
383             suffix=".iso"
384             type = "iso"
385         elif action.find("-usb") >= 0:
386             suffix=".usb"
387             type = "usb"
388         else:
389             suffix=".txt"
390             type = "txt"
391
392         # check for node existence and get node_type
393         nodes = Nodes(self.api, [node_id_or_hostname])
394         if not nodes:
395             raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
396         node = nodes[0]
397
398         if self.DEBUG:
399             print >> log, "%s requested on node %s. Node type is: %s" \
400                 % (action, node['node_id'], node['node_type'])
401
402         # check the required action against the node type
403         node_type = node['node_type']
404         if action not in allowed_actions[node_type]:
405             raise PLCInvalidArgument, "Action %s not valid for %s nodes, valid actions are %s" \
406                                    % (action, node_type, "|".join(allowed_actions[node_type]))
407
408         # handle / canonicalize options
409         if type == "txt":
410             if options:
411                 raise PLCInvalidArgument, "Options are not supported for node configs"
412         else:
413             # create a dict for build.sh
414             build_sh_spec={'kargs':[]}
415             # use node tags as defaults
416             # check for node tag equivalents
417             tags = NodeTags(self.api,
418                             {'node_id': node['node_id'],
419                              'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
420                                          'no-hangcheck', 'systemd-debug' ]},
421                             ['tagname', 'value'])
422             if tags:
423                 for tag in tags:
424                     if tag['tagname'] == 'serial':
425                         build_sh_spec['serial'] = tag['value']
426                     elif tag['tagname'] == 'cramfs':
427                         build_sh_spec['cramfs'] = True
428                     elif tag['tagname'] == 'kvariant':
429                         build_sh_spec['variant'] = tag['value']
430                     elif tag['tagname'] == 'kargs':
431                         build_sh_spec['kargs'] += tag['value'].split()
432                     elif tag['tagname'] == 'no-hangcheck':
433                         build_sh_spec['kargs'].append('hcheck_reboot0')
434                     elif tag['tagname'] == 'systemd-debug':
435                         build_sh_spec['kargs'].append('systemd.log_level=debug')
436             # then options can override tags
437             for option in options:
438                 if option == "cramfs":
439                     build_sh_spec['cramfs']=True
440                 elif option == 'partition':
441                     if type != "usb":
442                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
443                     else:
444                         type="usb_partition"
445                 elif option == "serial":
446                     build_sh_spec['serial']='default'
447                 elif option.find("serial:") == 0:
448                     build_sh_spec['serial']=option.replace("serial:","")
449                 elif option.find("variant:") == 0:
450                     build_sh_spec['variant']=option.replace("variant:","")
451                 elif option == "no-hangcheck":
452                     build_sh_spec['kargs'].append('hcheck_reboot0')
453                 elif option == "systemd-debug":
454                     build_sh_spec['kargs'].append('systemd.log_level=debug')
455                 else:
456                     raise PLCInvalidArgument, "unknown option %s"%option
457
458         # compute nodename according the action
459         if action.find("node-") == 0:
460             nodename = node['hostname']
461         else:
462             node = None
463             # compute a 8 bytes random number
464             tempbytes = random.sample (xrange(0,256), 8);
465             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
466             nodename = "".join(map(hexa2,tempbytes))
467
468         # get nodefamily
469         (pldistro,fcdistro,arch) = self.get_nodefamily(node,auth)
470         self.nodefamily="%s-%s-%s"%(pldistro,fcdistro,arch)
471
472         # apply on globals
473         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
474             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
475
476         filename = self.handle_filename(filename, nodename, suffix, arch)
477
478         # log call
479         if node:
480             self.message='GetBootMedium on node %s - action=%s'%(nodename,action)
481             self.event_objects={'Node': [ node ['node_id'] ]}
482         else:
483             self.message='GetBootMedium - generic - action=%s'%action
484
485         ### generic media
486         if action == 'generic-iso' or action == 'generic-usb':
487             if options:
488                 raise PLCInvalidArgument, "Options are not supported for generic images"
489             # this raises an exception if bootcd is missing
490             version = self.bootcd_version()
491             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
492                                              version,
493                                              suffix)
494             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
495
496             if filename:
497                 ret=os.system ('cp "%s" "%s"'%(generic_path,filename))
498                 if ret==0:
499                     return filename
500                 else:
501                     raise PLCPermissionDenied, "Could not copy %s into %s"%(generic_path,filename)
502             else:
503                 ### return the generic medium content as-is, just base64 encoded
504                 return base64.b64encode(file(generic_path).read())
505
506         ### config file preview or regenerated
507         if action == 'node-preview' or action == 'node-floppy':
508             renew_key = (action == 'node-floppy')
509             floppy = self.floppy_contents (node,renew_key)
510             if filename:
511                 try:
512                     file(filename,'w').write(floppy)
513                 except:
514                     raise PLCPermissionDenied, "Could not write into %s"%filename
515                 return filename
516             else:
517                 return floppy
518
519         ### we're left with node-iso and node-usb
520         # the steps involved in the image creation are:
521         # - create and test the working environment
522         # - generate the configuration file
523         # - build and invoke the build command
524         # - delivery the resulting image file
525
526         if action == 'node-iso' or action == 'node-usb':
527
528             ### check we've got required material
529             version = self.bootcd_version()
530
531             if not os.path.isfile(self.BOOTCDBUILD):
532                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
533
534             # create the workdir if needed
535             if not os.path.isdir(self.WORKDIR):
536                 try:
537                     os.makedirs(self.WORKDIR,0777)
538                     os.chmod(self.WORKDIR,0777)
539                 except:
540                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
541
542             try:
543                 # generate floppy config
544                 floppy_text = self.floppy_contents(node,True)
545                 # store it
546                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
547                 try:
548                     file(floppy_file,"w").write(floppy_text)
549                 except:
550                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
551
552                 self.trash.append(floppy_file)
553
554                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
555                 log_file="%s.log"%node_image
556
557                 command = self.build_command(node_type, build_sh_spec, node_image, type, floppy_file, log_file)
558
559                 # invoke the image build script
560                 if command != "":
561                     ret=os.system(command)
562
563                 if ret != 0:
564                     raise PLCAPIError, "%s failed Command line was: %s Error logs: %s" % \
565                               (self.BOOTCDBUILD,  command, file(log_file).read())
566
567                 self.trash.append(log_file)
568
569                 if not os.path.isfile (node_image):
570                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
571
572                 # handle result
573                 if filename:
574                     ret=os.system('mv "%s" "%s"'%(node_image,filename))
575                     if ret != 0:
576                         self.trash.append(node_image)
577                         self.cleantrash()
578                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
579                     self.cleantrash()
580                     return filename
581                 else:
582                     result = file(node_image).read()
583                     self.trash.append(node_image)
584                     self.cleantrash()
585                     print >> log, "GetBootMedium - done with build.sh"
586                     encoded_result = base64.b64encode(result)
587                     print >> log, "GetBootMedium - done with base64 encoding - lengths=%s - %s"\
588                         %(len(result),len(encoded_result))
589                     return encoded_result
590             except:
591                 self.cleantrash()
592                 raise
593
594         # we're done here, or we missed something
595         raise PLCAPIError,'Unhandled action %s'%action