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