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