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