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