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