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