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