ebfaebff8e1380c8f603298e733a4aa34825ed7e
[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             # then options can override tags
456             for option in options:
457                 if option == "cramfs":
458                     build_sh_spec['cramfs']=True
459                 elif option == 'partition':
460                     if type != "usb":
461                         raise PLCInvalidArgument("option 'partition' is for USB images only")
462                     else:
463                         type="usb_partition"
464                 elif option == "serial":
465                     build_sh_spec['serial']='default'
466                 elif option.find("serial:") == 0:
467                     build_sh_spec['serial']=option.replace("serial:","")
468                 elif option.find("variant:") == 0:
469                     build_sh_spec['variant']=option.replace("variant:","")
470                 elif option == "no-hangcheck":
471                     build_sh_spec['kargs'].append('hcheck_reboot0')
472                 elif option == "systemd-debug":
473                     build_sh_spec['kargs'].append('systemd.log_level=debug')
474                 else:
475                     raise PLCInvalidArgument("unknown option {}".format(option))
476
477         # compute nodename according the action
478         if action.find("node-") == 0:
479             nodename = node['hostname']
480         else:
481             node = None
482             # compute a 8 bytes random number
483             tempbytes = random.sample (xrange(0,256), 8);
484             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
485             nodename = "".join(map(hexa2,tempbytes))
486
487         # get nodefamily
488         (pldistro,fcdistro,arch) = self.get_nodefamily(node, auth)
489         self.nodefamily="{}-{}-{}".format(pldistro, fcdistro, arch)
490
491         # apply on globals
492         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
493             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
494
495         filename = self.handle_filename(filename, nodename, suffix, arch)
496
497         # log call
498         if node:
499             self.message='GetBootMedium on node {} - action={}'.format(nodename, action)
500             self.event_objects={'Node': [ node ['node_id'] ]}
501         else:
502             self.message='GetBootMedium - generic - action={}'.format(action)
503
504         ### generic media
505         if action == 'generic-iso' or action == 'generic-usb':
506             if options:
507                 raise PLCInvalidArgument("Options are not supported for generic images")
508             # this raises an exception if bootcd is missing
509             version = self.bootcd_version()
510             generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
511             generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
512
513             if filename:
514                 ret=os.system ('cp "{}" "{}"'.format(generic_path, filename))
515                 if ret==0:
516                     return filename
517                 else:
518                     raise PLCPermissionDenied("Could not copy {} into {}"\
519                                               .format(generic_path, filename))
520             else:
521                 ### return the generic medium content as-is, just base64 encoded
522                 return base64.b64encode(file(generic_path).read())
523
524         ### config file preview or regenerated
525         if action == 'node-preview' or action == 'node-floppy':
526             renew_key = (action == 'node-floppy')
527             floppy = self.floppy_contents (node,renew_key)
528             if filename:
529                 try:
530                     file(filename,'w').write(floppy)
531                 except:
532                     raise PLCPermissionDenied("Could not write into {}".format(filename))
533                 return filename
534             else:
535                 return floppy
536
537         ### we're left with node-iso and node-usb
538         # the steps involved in the image creation are:
539         # - create and test the working environment
540         # - generate the configuration file
541         # - build and invoke the build command
542         # - delivery the resulting image file
543
544         if action == 'node-iso' or action == 'node-usb':
545
546             ### check we've got required material
547             version = self.bootcd_version()
548
549             if not os.path.isfile(self.BOOTCDBUILD):
550                 raise PLCAPIError("Cannot locate bootcd/build.sh script {}".format(self.BOOTCDBUILD))
551
552             # create the workdir if needed
553             if not os.path.isdir(self.WORKDIR):
554                 try:
555                     os.makedirs(self.WORKDIR,0777)
556                     os.chmod(self.WORKDIR,0777)
557                 except:
558                     raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
559
560             try:
561                 # generate floppy config
562                 floppy_text = self.floppy_contents(node, True)
563                 # store it
564                 floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
565                 try:
566                     file(floppy_file,"w").write(floppy_text)
567                 except:
568                     raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
569
570                 self.trash.append(floppy_file)
571
572                 node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
573
574                 command, log_file = self.build_command(nodename, node_type, build_sh_spec,
575                                                        node_image, type, floppy_file)
576
577                 # invoke the image build script
578                 if command != "":
579                     ret = os.system(command)
580
581                 if ret != 0:
582                     raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
583                                       .format(self.BOOTCDBUILD, command, log_file))
584
585                 if not os.path.isfile (node_image):
586                     raise PLCAPIError("Unexpected location of build.sh output - {}".format(node_image))
587
588                 # handle result
589                 if filename:
590                     ret = os.system('mv "{}" "{}"'.format(node_image, filename))
591                     if ret != 0:
592                         self.trash.append(node_image)
593                         self.cleantrash()
594                         raise PLCAPIError("Could not move node image {} into {}"\
595                                           .format(node_image, filename))
596                     self.cleantrash()
597                     return filename
598                 else:
599                     result = file(node_image).read()
600                     self.trash.append(node_image)
601                     self.cleantrash()
602                     print >> log, "GetBootMedium - done with build.sh"
603                     encoded_result = base64.b64encode(result)
604                     print >> log, "GetBootMedium - done with base64 encoding - lengths: raw={} - b64={}"\
605                         .format(len(result), len(encoded_result))
606                     return encoded_result
607             except:
608                 self.cleantrash()
609                 raise
610
611         # we're done here, or we missed something
612         raise PLCAPIError('Unhandled action {}'.format(action))