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