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