an attempt to get more feedback from a node that fails to boot
[plcapi.git] / PLC / Methods / GetBootMedium.py
1 # pylint: disable=c0111, c0103
2
3 import random
4 import base64
5 import os
6 import os.path
7 import time
8
9 from PLC.Faults import *
10 from PLC.Method import Method
11 from PLC.Parameter import Parameter, Mixed
12 from PLC.Auth import Auth
13
14 from PLC.Nodes import Node, Nodes
15 from PLC.Interfaces import Interface, Interfaces
16 from PLC.InterfaceTags import InterfaceTag, InterfaceTags
17 from PLC.NodeTags import NodeTag, NodeTags
18
19 from PLC.Logger import logger
20
21 from PLC.Accessors.Accessors_standard import *              # node accessors
22
23 # could not define this in the class..
24 # create a dict with the allowed actions for each type of node
25 # reservable nodes being more recent, we do not support the floppy stuff anymore
26 allowed_actions = {
27     'regular' : [
28         'node-preview',
29         'node-floppy',
30         'node-iso',
31         'node-usb',
32         'generic-iso',
33         'generic-usb',
34     ],
35     'reservable': [
36         'node-preview',
37         'node-iso',
38         'node-usb',
39     ],
40 }
41
42 # compute a new key
43 def compute_key():
44     # Generate 32 random bytes
45     int8s = random.sample(range(0, 256), 32)
46     # Base64 encode their string representation
47     key = base64.b64encode(bytes(int8s))
48     # Boot Manager cannot handle = in the key
49     key = key.replace(b"=", b"")
50     return key
51
52 class GetBootMedium(Method):
53     """
54     This method is a redesign based on former, supposedly dedicated,
55     AdmGenerateNodeConfFile
56
57     As compared with its ancestor, this method provides a much more
58     detailed interface, that allows to
59     (*) either just preview the node config file -- in which case
60         the node key is NOT recomputed, and NOT provided in the output
61     (*) or regenerate the node config file for storage on a floppy
62         that is, exactly what the ancestor method used todo,
63         including renewing the node's key
64     (*) or regenerate the config file and bundle it inside an ISO or USB image
65     (*) or just provide the generic ISO or USB boot images
66         in which case of course the node_id_or_hostname parameter is not used
67
68     action is expected among the following string constants according the
69     node type value:
70
71     for a 'regular' node:
72     (*) node-preview
73     (*) node-floppy
74     (*) node-iso
75     (*) node-usb
76     (*) generic-iso
77     (*) generic-usb
78
79     Apart for the preview mode, this method generates a new node key for the
80     specified node, effectively invalidating any old boot medium.
81     Note that 'reservable' nodes do not support 'node-floppy',
82     'generic-iso' nor 'generic-usb'.
83
84     In addition, two return mechanisms are supported.
85     (*) The default behaviour is that the file's content is returned as a
86         base64-encoded string. This is how the ancestor method used to work.
87         To use this method, pass an empty string as the file parameter.
88
89     (*) Or, for efficiency -- this makes sense only when the API is used
90         by the web pages that run on the same host -- the caller may provide
91         a filename, in which case the resulting file is stored in that location instead.
92         The filename argument can use the following markers, that are expanded
93         within the method
94         - %d : default root dir (some builtin dedicated area under /var/tmp/)
95                Using this is recommended, and enforced for non-admin users
96         - %n : the node's name when this makes sense, or a mktemp-like name when
97                generic media is requested
98         - %s : a file suffix appropriate in the context (.txt, .iso or the like)
99         - %v : the bootcd version string (e.g. 4.0)
100         - %p : the PLC name
101         - %f : the nodefamily
102         - %a : arch
103         With the file-based return mechanism, the method returns the full pathname
104         of the result file;
105         ** WARNING **
106         It is the caller's responsability to remove this file after use.
107
108     Options: an optional array of keywords.
109         options are not supported for generic images
110       Currently supported are
111         - 'partition' - for USB actions only
112         - 'cramfs'
113         - 'serial' or 'serial:<console_spec>'
114         console_spec (or 'default') is passed as-is to bootcd/build.sh
115         it is expected to be a colon separated string denoting
116         tty - baudrate - parity - bits
117         e.g. ttyS0:115200:n:8
118         - 'variant:<variantname>'
119         passed to build.sh as -V <variant>
120         variants are used to run a different kernel on the bootCD
121         see kvariant.sh for how to create a variant
122         - 'no-hangcheck' - disable hangcheck
123         - 'systemd-debug' - turn on systemd debug in bootcd
124
125     Tags: the following tags are taken into account when attached to the node:
126         'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck', 'systemd-debug'
127
128     Security:
129         - Non-admins can only generate files for nodes at their sites.
130         - Non-admins, when they provide a filename, *must* specify it in the %d area
131
132    Housekeeping:
133         Whenever needed, the method stores intermediate files in a
134         private area, typically not located under the web server's
135         accessible area, and are cleaned up by the method.
136
137     """
138
139     roles = ['admin', 'pi', 'tech']
140
141     accepts = [
142         Auth(),
143         Mixed(Node.fields['node_id'],
144               Node.fields['hostname']),
145         Parameter(str, "Action mode, expected value depends of the type of node"),
146         Parameter(str, "Empty string for verbatim result, resulting file full path otherwise"),
147         Parameter([str], "Options"),
148         ]
149
150     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
151
152     # define globals for regular nodes, override later for other types
153     BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
154     BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
155     GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
156     WORKDIR = "/var/tmp/bootmedium"
157     LOGDIR = "/var/tmp/bootmedium/logs/"
158     DEBUG = False
159     # uncomment this to preserve temporary area and bootcustom logs
160     #DEBUG = True
161
162     ### returns (host, domain) :
163     # 'host' : host part of the hostname
164     # 'domain' : domain part of the hostname
165     def split_hostname(self, node):
166         # Split hostname into host and domain parts
167         parts = node['hostname'].split(".", 1)
168         if len(parts) < 2:
169             raise PLCInvalidArgument("Node hostname {} is invalid".format(node['hostname']))
170         return parts
171
172     # Generate the node (plnode.txt) configuration content.
173     #
174     # This function will create the configuration file a node
175     # composed by:
176     #  - a common part, regardless of the 'node_type' tag
177     #  - XXX a special part, depending on the 'node_type' tag value.
178     def floppy_contents(self, node, renew_key):
179
180         # Do basic checks
181         if node['peer_id'] is not None:
182             raise PLCInvalidArgument("Not a local node {}".format(node['hostname']))
183
184         # If we are not an admin, make sure that the caller is a
185         # member of the site at which the node is located.
186         if 'admin' not in self.caller['roles']:
187             if node['site_id'] not in self.caller['site_ids']:
188                 raise PLCPermissionDenied(
189                     "Not allowed to generate a configuration file for {}"\
190                     .format(node['hostname']))
191
192         # Get interface for this node
193         primary = None
194         interfaces = Interfaces(self.api, node['interface_ids'])
195         for interface in interfaces:
196             if interface['is_primary']:
197                 primary = interface
198                 break
199         if primary is None:
200             raise PLCInvalidArgument(
201                 "No primary network configured on {}".format(node['hostname']))
202
203         host, domain = self.split_hostname(node)
204
205         # renew the key and save it on the database
206         if renew_key:
207             node['key'] = compute_key()
208             node.update_last_download(commit=False)
209             node.sync()
210
211         # Generate node configuration file suitable for BootCD
212         file = ""
213
214         if renew_key:
215             file += 'NODE_ID="{}"\n'.format(node['node_id'])
216             file += 'NODE_KEY="{}"\n'.format(node['key'])
217             # not used anywhere, just a note for operations people
218             file += 'KEY_RENEWAL_DATE="{}"\n'\
219                 .format(time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime()))
220
221         if primary['mac']:
222             file += 'NET_DEVICE="{}"\n'.format(primary['mac'].lower())
223
224         file += 'IP_METHOD="{}"\n'.format(primary['method'])
225
226         if primary['method'] == 'static':
227             file += 'IP_ADDRESS="{}"\n'.format(primary['ip'])
228             file += 'IP_GATEWAY="{}"\n'.format(primary['gateway'])
229             file += 'IP_NETMASK="{}"\n'.format(primary['netmask'])
230             file += 'IP_NETADDR="{}"\n'.format(primary['network'])
231             file += 'IP_BROADCASTADDR="{}"\n'.format(primary['broadcast'])
232             file += 'IP_DNS1="{}"\n'.format(primary['dns1'])
233             file += 'IP_DNS2="{}"\n'.format(primary['dns2'] or "")
234
235         file += 'HOST_NAME="{}"\n'.format(host)
236         file += 'DOMAIN_NAME="{}"\n'.format(domain)
237
238         # define various interface settings attached to the primary interface
239         settings = InterfaceTags(self.api, {'interface_id':interface['interface_id']})
240
241         categories = set()
242         for setting in settings:
243             if setting['category'] is not None:
244                 categories.add(setting['category'])
245
246         for category in categories:
247             category_settings = InterfaceTags(self.api,{'interface_id' : interface['interface_id'],
248                                                         'category' : category})
249             if category_settings:
250                 file += '### Category : {}\n'.format(category)
251                 for setting in category_settings:
252                     file += '{}_{}="{}"\n'\
253                         .format(category.upper(), setting['tagname'].upper(), setting['value'])
254
255         for interface in interfaces:
256             if interface['method'] == 'ipmi':
257                 file += 'IPMI_ADDRESS="{}"\n'.format(interface['ip'])
258                 if interface['mac']:
259                     file += 'IPMI_MAC="{}"\n'.format(interface['mac'].lower())
260                 break
261
262         return file
263
264     # see also GetNodeFlavour that does similar things
265     def get_nodefamily(self, node, auth):
266         pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
267         fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
268         arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
269         if not node:
270             return pldistro, fcdistro, arch
271
272         node_id = node['node_id']
273
274         # no support for deployment-based BootCD's, use kvariants instead
275         node_pldistro = GetNodePldistro(self.api,self.caller).call(auth, node_id)
276         if node_pldistro:
277             pldistro = node_pldistro
278
279         node_fcdistro = GetNodeFcdistro(self.api,self.caller).call(auth, node_id)
280         if node_fcdistro:
281             fcdistro = node_fcdistro
282
283         node_arch = GetNodeArch(self.api,self.caller).call(auth,node_id)
284         if node_arch:
285             arch = node_arch
286
287         return pldistro, fcdistro, arch
288
289     def bootcd_version(self):
290         try:
291             with open(self.BOOTCDDIR + "/build/version.txt") as feed:
292                 return feed.readline().strip()
293         except:
294             raise Exception("Unknown boot cd version - probably wrong bootcd dir : {}"\
295                             .format(self.BOOTCDDIR))
296
297     def cleantrash(self):
298         for file in self.trash:
299             if self.DEBUG:
300                 logger.debug('DEBUG -- preserving trash file {}'.format(file))
301             else:
302                 os.unlink(file)
303
304     ### handle filename
305     # build the filename string
306     # check for permissions and concurrency
307     # returns the filename
308     def handle_filename(self, filename, nodename, suffix, arch):
309         # allow to set filename to None or any other empty value
310         if not filename: filename=''
311         filename = filename.replace("%d", self.WORKDIR)
312         filename = filename.replace("%n", nodename)
313         filename = filename.replace("%s", suffix)
314         filename = filename.replace("%p", self.api.config.PLC_NAME)
315         # let's be cautious
316         try: filename = filename.replace("%f", self.nodefamily)
317         except: pass
318         try: filename = filename.replace("%a", arch)
319         except: pass
320         try: filename = filename.replace("%v", self.bootcd_version())
321         except: pass
322
323         ### Check filename location
324         if filename != '':
325             if 'admin' not in self.caller['roles']:
326                 if filename.index(self.WORKDIR) != 0:
327                     raise PLCInvalidArgument("File {} not under {}".format(filename, self.WORKDIR))
328
329             ### output should not exist (concurrent runs ..)
330             # numerous reports of issues with this policy
331             # looks like people sometime suspend/cancel their download
332             # and this leads to the old file sitting in there forever
333             # so, if the file is older than 5 minutes, we just trash
334             grace = 5
335             if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
336                 os.unlink(filename)
337             if os.path.exists(filename):
338                 raise PLCInvalidArgument(
339                     "Resulting file {} already exists - please try again in {} minutes"\
340                     .format(filename, grace))
341
342             ### we can now safely create the file,
343             ### either we are admin or under a controlled location
344             filedir = os.path.dirname(filename)
345             # dirname does not return "." for a local filename like its shell counterpart
346             if filedir:
347                 if not os.path.exists(filedir):
348                     try:
349                         os.makedirs(filedir, 0o777)
350                     except:
351                         raise PLCPermissionDenied("Could not create dir {}".format(filedir))
352
353         return filename
354
355     def build_command(self, nodename, node_type, build_sh_spec, node_image, type, floppy_file):
356         """
357         returns a tuple
358         (*) build command to be run
359         (*) location of the log_file
360         """
361
362         command = ""
363
364         # regular node, make build's arguments
365         # and build the full command line to be called
366         if node_type not in [ 'regular', 'reservable' ]:
367             logger.error("GetBootMedium.build_command: unexpected node_type {}".format(node_type))
368             return command, None
369
370         build_sh_options = ""
371         if "cramfs" in build_sh_spec:
372             type += "_cramfs"
373         if "serial" in build_sh_spec:
374             build_sh_options += " -s {}".format(build_sh_spec['serial'])
375         if "variant" in build_sh_spec:
376             build_sh_options += " -V {}".format(build_sh_spec['variant'])
377
378         for karg in build_sh_spec['kargs']:
379             build_sh_options += ' -k "{}"'.format(karg)
380
381         import time
382         date = time.strftime('%Y-%m-%d-%H-%M', time.gmtime())
383         if not os.path.isdir(self.LOGDIR):
384             os.makedirs(self.LOGDIR)
385         log_file = "{}/{}-{}.log".format(self.LOGDIR, date, nodename)
386
387         command = '{} -f "{}" -o "{}" -t "{}" {} > {} 2>&1'\
388                   .format(self.BOOTCDBUILD,
389                           floppy_file,
390                           node_image,
391                           type,
392                           build_sh_options,
393                           log_file)
394
395         logger.info("The build command line is {}".format(command))
396
397         return command, log_file
398
399     def call(self, auth, node_id_or_hostname, action, filename, options=None):
400
401         if options is None:
402             options = []
403
404         self.trash = []
405
406         ### compute file suffix and type
407         if action.find("-iso") >= 0 :
408             suffix = ".iso"
409             type   = "iso"
410         elif action.find("-usb") >= 0:
411             suffix = ".usb"
412             type   = "usb"
413         else:
414             suffix = ".txt"
415             type   = "txt"
416
417         # check for node existence and get node_type
418         nodes = Nodes(self.api, [node_id_or_hostname])
419         if not nodes:
420             raise PLCInvalidArgument("No such node {}".format(node_id_or_hostname))
421         node = nodes[0]
422
423         logger.info("GetBootMedium: {} requested on node {}. Node type is: {}"
424             .format(action, node['node_id'], node['node_type']))
425
426         # check the required action against the node type
427         node_type = node['node_type']
428         if action not in allowed_actions[node_type]:
429             raise PLCInvalidArgument(
430                 "Action {} not valid for {} nodes, valid actions are {}"
431                 .format(action, node_type, "|".join(allowed_actions[node_type])))
432
433         # handle / canonicalize options
434         if type == "txt":
435             if options:
436                 raise PLCInvalidArgument("Options are not supported for node configs")
437         else:
438             # create a dict for build.sh
439             build_sh_spec = {'kargs':[]}
440             # use node tags as defaults
441             # check for node tag equivalents
442             tags = NodeTags(self.api,
443                             {'node_id': node['node_id'],
444                              'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
445                                          'no-hangcheck', 'systemd-debug']},
446                             ['tagname', 'value'])
447             if tags:
448                 for tag in tags:
449                     if tag['tagname'] == 'serial':
450                         build_sh_spec['serial'] = tag['value']
451                     elif tag['tagname'] == 'cramfs':
452                         build_sh_spec['cramfs'] = True
453                     elif tag['tagname'] == 'kvariant':
454                         build_sh_spec['variant'] = tag['value']
455                     elif tag['tagname'] == 'kargs':
456                         build_sh_spec['kargs'] += tag['value'].split()
457                     elif tag['tagname'] == 'no-hangcheck':
458                         build_sh_spec['kargs'].append('hcheck_reboot0')
459                     elif tag['tagname'] == 'systemd-debug':
460                         #
461                         build_sh_spec['kargs'].append('systemd.log_level=debug')
462                         build_sh_spec['kargs'].append('systemd.log_target=console')
463                         build_sh_spec['kargs'].append(
464                             'systemd.default_standard_output=journal+console')
465                         build_sh_spec['kargs'].append(
466                             'systemd.default_standard_error=journal+console')
467             # then options can override tags
468             for option in options:
469                 if option == "cramfs":
470                     build_sh_spec['cramfs'] = True
471                 elif option == 'partition':
472                     if type != "usb":
473                         raise PLCInvalidArgument("option 'partition' is for USB images only")
474                     else:
475                         type = "usb_partition"
476                 elif option == "serial":
477                     build_sh_spec['serial'] = 'default'
478                 elif option.find("serial:") == 0:
479                     build_sh_spec['serial'] = option.replace("serial:", "")
480                 elif option.find("variant:") == 0:
481                     build_sh_spec['variant'] = option.replace("variant:", "")
482                 elif option == "no-hangcheck":
483                     build_sh_spec['kargs'].append('hcheck_reboot0')
484                 elif option == "systemd-debug":
485                     build_sh_spec['kargs'].append('systemd.log_level=debug')
486                     build_sh_spec['kargs'].append('systemd.log_target=console')
487                     build_sh_spec['kargs'].append(
488                         'systemd.default_standard_output=journal+console')
489                     build_sh_spec['kargs'].append(
490                         'systemd.default_standard_error=journal+console')
491                 else:
492                     raise PLCInvalidArgument("unknown option {}".format(option))
493
494         # compute nodename according the action
495         if action.find("node-") == 0:
496             nodename = node['hostname']
497         else:
498             node = None
499             # compute a 8 bytes random number
500             tempbytes = random.sample(range(0, 256), 8);
501             def hexa2(c):
502                 return chr((c>>4)+65) + chr((c&16)+65)
503             nodename = "".join(map(hexa2, tempbytes))
504
505         # get nodefamily
506         pldistro, fcdistro, arch = self.get_nodefamily(node, auth)
507         self.nodefamily = "{}-{}-{}".format(pldistro, fcdistro, arch)
508
509         # apply on globals
510         for attr in ["BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR"]:
511             setattr(self, attr,
512                     getattr(self, attr).replace("@NODEFAMILY@", self.nodefamily))
513
514         filename = self.handle_filename(filename, nodename, suffix, arch)
515
516         # log call
517         if node:
518             self.message = 'GetBootMedium on node {} - action={}'.format(nodename, action)
519             self.event_objects = {'Node': [node ['node_id']]}
520         else:
521             self.message = 'GetBootMedium - generic - action={}'.format(action)
522
523         ### generic media
524         if action in ('generic-iso', 'generic-usb'):
525             if options:
526                 raise PLCInvalidArgument("Options are not supported for generic images")
527             # this raises an exception if bootcd is missing
528             version = self.bootcd_version()
529             generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
530             generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
531
532             if filename:
533                 ret = os.system('cp "{}" "{}"'.format(generic_path, filename))
534                 if ret == 0:
535                     return filename
536                 else:
537                     raise PLCPermissionDenied("Could not copy {} into {}"\
538                                               .format(generic_path, filename))
539             else:
540                 ### return the generic medium content as-is, just base64 encoded
541                 with open(generic_path) as feed:
542                     return base64.b64encode(feed.read())
543
544         ### config file preview or regenerated
545         if action == 'node-preview' or action == 'node-floppy':
546             renew_key = (action == 'node-floppy')
547             floppy = self.floppy_contents(node, renew_key)
548             if filename:
549                 try:
550                     with open(filename, 'w') as writer:
551                         writer.write(floppy)
552                 except:
553                     raise PLCPermissionDenied("Could not write into {}".format(filename))
554                 return filename
555             else:
556                 return floppy
557
558         ### we're left with node-iso and node-usb
559         # the steps involved in the image creation are:
560         # - create and test the working environment
561         # - generate the configuration file
562         # - build and invoke the build command
563         # - delivery the resulting image file
564
565         if action in ('node-iso', 'node-usb'):
566
567             ### check we've got required material
568             version = self.bootcd_version()
569
570             if not os.path.isfile(self.BOOTCDBUILD):
571                 raise PLCAPIError("Cannot locate bootcd/build.sh script {}"
572                                   .format(self.BOOTCDBUILD))
573
574             # create the workdir if needed
575             if not os.path.isdir(self.WORKDIR):
576                 try:
577                     os.makedirs(self.WORKDIR, 0o777)
578                     os.chmod(self.WORKDIR, 0o777)
579                 except:
580                     raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
581
582             try:
583                 # generate floppy config
584                 floppy_text = self.floppy_contents(node, True)
585                 # store it
586                 floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
587                 try:
588                     with open(floppy_file, "w") as writer:
589                         writer.write(floppy_text)
590                 except:
591                     raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
592
593                 self.trash.append(floppy_file)
594
595                 node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
596
597                 command, log_file = self.build_command(nodename, node_type, build_sh_spec,
598                                                        node_image, type, floppy_file)
599
600                 # invoke the image build script
601                 if command != "":
602                     ret = os.system(command)
603
604                 if ret != 0:
605                     raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
606                                       .format(self.BOOTCDBUILD, command, log_file))
607
608                 if not os.path.isfile(node_image):
609                     raise PLCAPIError("Unexpected location of build.sh output - {}"
610                                       .format(node_image))
611
612                 # handle result
613                 if filename:
614                     ret = os.system('mv "{}" "{}"'.format(node_image, filename))
615                     if ret != 0:
616                         self.trash.append(node_image)
617                         self.cleantrash()
618                         raise PLCAPIError("Could not move node image {} into {}"\
619                                           .format(node_image, filename))
620                     self.cleantrash()
621                     return filename
622                 else:
623                     with open(node_image, "rb") as feed:
624                         result = feed.read()
625                     self.trash.append(node_image)
626                     self.cleantrash()
627                     logger.info("GetBootMedium - done with build.sh")
628                     encoded_result = base64.b64encode(result)
629                     # stupidly enough, we need to decode this as str now
630                     # so that we remain compatible with former python2 PLCAPI
631                     encoded_result = encoded_result.decode()
632                     logger.info("GetBootMedium - done with base64 encoding -"
633                                 " lengths: raw={} - b64={}"
634                                 .format(len(result), len(encoded_result)))
635                     return encoded_result
636             except:
637                 self.cleantrash()
638                 raise
639
640         # we're done here, or we missed something
641         raise PLCAPIError('Unhandled action {}'.format(action))