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.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:%S +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('hangcheck_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         ### generic media
364         if action == 'generic-iso' or action == 'generic-usb':
365             if options:
366                 raise PLCInvalidArgument, "Options are not supported for generic images"
367             # this raises an exception if bootcd is missing
368             version = self.bootcd_version()
369             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
370                                              version,
371                                              suffix)
372             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
373
374             if filename:
375                 ret=os.system ("cp %s %s"%(generic_path,filename))
376                 if ret==0:
377                     return filename
378                 else:
379                     raise PLCPermissionDenied, "Could not copy %s into"%(generic_path,filename)
380             else:
381                 ### return the generic medium content as-is, just base64 encoded
382                 return base64.b64encode(file(generic_path).read())
383
384         ### config file preview or regenerated
385         if action == 'node-preview' or action == 'node-floppy':
386             renew_key = (action == 'node-floppy')
387             floppy = self.floppy_contents (node,renew_key)
388             if filename:
389                 try:
390                     file(filename,'w').write(floppy)
391                 except:
392                     raise PLCPermissionDenied, "Could not write into %s"%filename
393                 return filename
394             else:
395                 return floppy
396
397         ### we're left with node-iso and node-usb
398         if action == 'node-iso' or action == 'node-usb':
399
400             ### check we've got required material
401             version = self.bootcd_version()
402             
403             if not os.path.isfile(self.BOOTCDBUILD):
404                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
405
406             # create the workdir if needed
407             if not os.path.isdir(self.WORKDIR):
408                 try:
409                     os.makedirs(self.WORKDIR,0777)
410                     os.chmod(self.WORKDIR,0777)
411                 except:
412                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
413             
414             try:
415                 # generate floppy config
416                 floppy_text = self.floppy_contents(node,True)
417                 # store it
418                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
419                 try:
420                     file(floppy_file,"w").write(floppy_text)
421                 except:
422                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
423
424                 self.trash.append(floppy_file)
425
426                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
427
428                 # make build's arguments
429                 build_sh_options=""
430                 if "cramfs" in build_sh_spec: 
431                     type += "_cramfs"
432                 if "serial" in build_sh_spec: 
433                     build_sh_options += " -s %s"%build_sh_spec['serial']
434                 
435                 for k_option in build_sh_spec['-k']:
436                     build_sh_options += " -k %s"%k_option
437
438                 log_file="%s.log"%node_image
439                 # invoke build.sh
440                 build_command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
441                                                                          floppy_file,
442                                                                          node_image,
443                                                                          type,
444                                                                          serial_arg,
445                                                                          log_file)
446                 if self.DEBUG:
447                     print 'build command:',build_command
448                 ret=os.system(build_command)
449                 if ret != 0:
450                     raise PLCAPIError,"bootcd/build.sh failed\n%s\n%s"%(
451                         build_command,file(log_file).read())
452
453                 self.trash.append(log_file)
454                 if not os.path.isfile (node_image):
455                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
456             
457                 # handle result
458                 if filename:
459                     ret=os.system("mv %s %s"%(node_image,filename))
460                     if ret != 0:
461                         self.trash.append(node_image)
462                         self.cleantrash()
463                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
464                     self.cleantrash()
465                     return filename
466                 else:
467                     result = file(node_image).read()
468                     self.trash.append(node_image)
469                     self.cleantrash()
470                     return base64.b64encode(result)
471             except:
472                 self.cleantrash()
473                 raise
474                 
475         # we're done here, or we missed something
476         raise PLCAPIError,'Unhandled action %s'%action
477