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