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