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