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