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