comment
[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         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:%S +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 = InterfaceSettings (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 = InterfaceSettings(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         # cannot use accessors in the API itself
237         # the 'arch' tag type is assumed to exist, see db-config
238         arch_tags = NodeTags (self.api, {'tagname':'arch','node_id':node_id},['tagvalue'])
239         if arch_tags:
240             arch=arch_tags[0]['tagvalue']
241         # ditto
242         pldistro_tags = NodeTags (self.api, {'tagname':'pldistro','node_id':node_id},['tagvalue'])
243         if pldistro_tags:
244             pldistro=pldistro_tags[0]['tagvalue']
245
246         return (pldistro,arch)
247
248     def bootcd_version (self):
249         try:
250             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
251         except:
252             raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
253     
254     def cleantrash (self):
255         for file in self.trash:
256             if self.DEBUG:
257                 print 'DEBUG -- preserving',file
258             else:
259                 os.unlink(file)
260
261     def call(self, auth, node_id_or_hostname, action, filename, options = []):
262
263         self.trash=[]
264         ### check action
265         if action not in boot_medium_actions:
266             raise PLCInvalidArgument, "Unknown action %s"%action
267
268         ### compute file suffix and type
269         if action.find("-iso") >= 0 :
270             suffix=".iso"
271             type = "iso"
272         elif action.find("-usb") >= 0:
273             suffix=".usb"
274             type = "usb"
275         else:
276             suffix=".txt"
277             type = "txt"
278
279         # handle / caconicalize options
280         if type == "txt":
281             if options:
282                 raise PLCInvalidArgument, "Options are not supported for node configs"
283         else:
284             # create a dict for build.sh 
285             optdict={}
286             for option in options:
287                 if option == "cramfs":
288                     optdict['cramfs']=True
289                 elif option == 'partition':
290                     if type != "usb":
291                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
292                     else:
293                         type="usb_partition"
294                 elif option == "serial":
295                     optdict['serial']='default'
296                 elif option.find("serial:") == 0:
297                     optdict['serial']=option.replace("serial:","")
298                 else:
299                     raise PLCInvalidArgument, "unknown option %s"%option
300
301         ### check node if needed
302         if action.find("node-") == 0:
303             nodes = Nodes(self.api, [node_id_or_hostname])
304             if not nodes:
305                 raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
306             node = nodes[0]
307             nodename = node['hostname']
308
309         else:
310             node = None
311             # compute a 8 bytes random number
312             tempbytes = random.sample (xrange(0,256), 8);
313             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
314             nodename = "".join(map(hexa2,tempbytes))
315
316         # get nodefamily
317         (pldistro,arch) = self.get_nodefamily(node)
318         self.nodefamily="%s-%s"%(pldistro,arch)
319         # apply on globals
320         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
321             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
322             
323         ### handle filename
324         # allow to set filename to None or any other empty value
325         if not filename: filename=''
326         filename = filename.replace ("%d",self.WORKDIR)
327         filename = filename.replace ("%n",nodename)
328         filename = filename.replace ("%s",suffix)
329         filename = filename.replace ("%p",self.api.config.PLC_NAME)
330         # let's be cautious
331         try: filename = filename.replace ("%f", self.nodefamily)
332         except: pass
333         try: filename = filename.replace ("%a", arch)
334         except: pass
335         try: filename = filename.replace ("%v",self.bootcd_version())
336         except: pass
337
338         ### Check filename location
339         if filename != '':
340             if 'admin' not in self.caller['roles']:
341                 if ( filename.index(self.WORKDIR) != 0):
342                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
343
344             ### output should not exist (concurrent runs ..)
345             if os.path.exists(filename):
346                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
347
348             ### we can now safely create the file, 
349             ### either we are admin or under a controlled location
350             filedir=os.path.dirname(filename)
351             # dirname does not return "." for a local filename like its shell counterpart
352             if filedir:
353                 if not os.path.exists(filedir):
354                     try:
355                         os.makedirs (filedir,0777)
356                     except:
357                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
358
359         
360         ### generic media
361         if action == 'generic-iso' or action == 'generic-usb':
362             if options:
363                 raise PLCInvalidArgument, "Options are not supported for generic images"
364             # this raises an exception if bootcd is missing
365             version = self.bootcd_version()
366             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
367                                              version,
368                                              suffix)
369             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
370
371             if filename:
372                 ret=os.system ("cp %s %s"%(generic_path,filename))
373                 if ret==0:
374                     return filename
375                 else:
376                     raise PLCPermissionDenied, "Could not copy %s into"%(generic_path,filename)
377             else:
378                 ### return the generic medium content as-is, just base64 encoded
379                 return base64.b64encode(file(generic_path).read())
380
381         ### config file preview or regenerated
382         if action == 'node-preview' or action == 'node-floppy':
383             renew_key = (action == 'node-floppy')
384             floppy = self.floppy_contents (node,renew_key)
385             if filename:
386                 try:
387                     file(filename,'w').write(floppy)
388                 except:
389                     raise PLCPermissionDenied, "Could not write into %s"%filename
390                 return filename
391             else:
392                 return floppy
393
394         ### we're left with node-iso and node-usb
395         if action == 'node-iso' or action == 'node-usb':
396
397             ### check we've got required material
398             version = self.bootcd_version()
399             
400             if not os.path.isfile(self.BOOTCDBUILD):
401                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
402
403             # create the workdir if needed
404             if not os.path.isdir(self.WORKDIR):
405                 try:
406                     os.makedirs(self.WORKDIR,0777)
407                     os.chmod(self.WORKDIR,0777)
408                 except:
409                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
410             
411             try:
412                 # generate floppy config
413                 floppy_text = self.floppy_contents(node,True)
414                 # store it
415                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
416                 try:
417                     file(floppy_file,"w").write(floppy_text)
418                 except:
419                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
420
421                 self.trash.append(floppy_file)
422
423                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
424
425                 # make build's arguments
426                 serial_arg=""
427                 if "cramfs" in optdict: type += "_cramfs"
428                 if "serial" in optdict: serial_arg = "-s %s"%optdict['serial']
429                 log_file="%s.log"%node_image
430                 # invoke build.sh
431                 build_command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
432                                                                          floppy_file,
433                                                                          node_image,
434                                                                          type,
435                                                                          serial_arg,
436                                                                          log_file)
437                 if self.DEBUG:
438                     print 'build command:',build_command
439                 ret=os.system(build_command)
440                 if ret != 0:
441                     raise PLCAPIError,"bootcd/build.sh failed\n%s\n%s"%(
442                         build_command,file(log_file).read())
443
444                 self.trash.append(log_file)
445                 if not os.path.isfile (node_image):
446                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
447             
448                 # handle result
449                 if filename:
450                     ret=os.system("mv %s %s"%(node_image,filename))
451                     if ret != 0:
452                         self.trash.append(node_image)
453                         self.cleantrash()
454                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
455                     self.cleantrash()
456                     return filename
457                 else:
458                     result = file(node_image).read()
459                     self.trash.append(node_image)
460                     self.cleantrash()
461                     return base64.b64encode(result)
462             except:
463                 self.cleantrash()
464                 raise
465                 
466         # we're done here, or we missed something
467         raise PLCAPIError,'Unhandled action %s'%action
468