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