add debug to kargs for the no-hangcheck option
[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={'kargs':[]}
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['kargs'].append('hcheck_reboot=0')
301                     build_sh_spec['kargs'].append('debug')
302                 else:
303                     raise PLCInvalidArgument, "unknown option %s"%option
304
305         ### check node if needed
306         if action.find("node-") == 0:
307             nodes = Nodes(self.api, [node_id_or_hostname])
308             if not nodes:
309                 raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
310             node = nodes[0]
311             nodename = node['hostname']
312
313         else:
314             node = None
315             # compute a 8 bytes random number
316             tempbytes = random.sample (xrange(0,256), 8);
317             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
318             nodename = "".join(map(hexa2,tempbytes))
319
320         # get nodefamily
321         (pldistro,arch) = self.get_nodefamily(node)
322         self.nodefamily="%s-%s"%(pldistro,arch)
323         # apply on globals
324         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
325             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
326             
327         ### handle filename
328         # allow to set filename to None or any other empty value
329         if not filename: filename=''
330         filename = filename.replace ("%d",self.WORKDIR)
331         filename = filename.replace ("%n",nodename)
332         filename = filename.replace ("%s",suffix)
333         filename = filename.replace ("%p",self.api.config.PLC_NAME)
334         # let's be cautious
335         try: filename = filename.replace ("%f", self.nodefamily)
336         except: pass
337         try: filename = filename.replace ("%a", arch)
338         except: pass
339         try: filename = filename.replace ("%v",self.bootcd_version())
340         except: pass
341
342         ### Check filename location
343         if filename != '':
344             if 'admin' not in self.caller['roles']:
345                 if ( filename.index(self.WORKDIR) != 0):
346                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
347
348             ### output should not exist (concurrent runs ..)
349             if os.path.exists(filename):
350                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
351
352             ### we can now safely create the file, 
353             ### either we are admin or under a controlled location
354             filedir=os.path.dirname(filename)
355             # dirname does not return "." for a local filename like its shell counterpart
356             if filedir:
357                 if not os.path.exists(filedir):
358                     try:
359                         os.makedirs (filedir,0777)
360                     except:
361                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
362
363         
364         # log call
365         if node:
366             self.message='GetBootMedium on node %s - action=%s'%(nodename,action)
367             self.event_objects={'Node': [ node ['node_id'] ]}
368         else:
369             self.message='GetBootMedium - generic - action=%s'%action
370
371         ### generic media
372         if action == 'generic-iso' or action == 'generic-usb':
373             if options:
374                 raise PLCInvalidArgument, "Options are not supported for generic images"
375             # this raises an exception if bootcd is missing
376             version = self.bootcd_version()
377             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
378                                              version,
379                                              suffix)
380             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
381
382             if filename:
383                 ret=os.system ("cp %s %s"%(generic_path,filename))
384                 if ret==0:
385                     return filename
386                 else:
387                     raise PLCPermissionDenied, "Could not copy %s into"%(generic_path,filename)
388             else:
389                 ### return the generic medium content as-is, just base64 encoded
390                 return base64.b64encode(file(generic_path).read())
391
392         ### config file preview or regenerated
393         if action == 'node-preview' or action == 'node-floppy':
394             renew_key = (action == 'node-floppy')
395             floppy = self.floppy_contents (node,renew_key)
396             if filename:
397                 try:
398                     file(filename,'w').write(floppy)
399                 except:
400                     raise PLCPermissionDenied, "Could not write into %s"%filename
401                 return filename
402             else:
403                 return floppy
404
405         ### we're left with node-iso and node-usb
406         if action == 'node-iso' or action == 'node-usb':
407
408             ### check we've got required material
409             version = self.bootcd_version()
410             
411             if not os.path.isfile(self.BOOTCDBUILD):
412                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
413
414             # create the workdir if needed
415             if not os.path.isdir(self.WORKDIR):
416                 try:
417                     os.makedirs(self.WORKDIR,0777)
418                     os.chmod(self.WORKDIR,0777)
419                 except:
420                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
421             
422             try:
423                 # generate floppy config
424                 floppy_text = self.floppy_contents(node,True)
425                 # store it
426                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
427                 try:
428                     file(floppy_file,"w").write(floppy_text)
429                 except:
430                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
431
432                 self.trash.append(floppy_file)
433
434                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
435
436                 # make build's arguments
437                 build_sh_options=""
438                 if "cramfs" in build_sh_spec: 
439                     type += "_cramfs"
440                 if "serial" in build_sh_spec: 
441                     build_sh_options += " -s %s"%build_sh_spec['serial']
442                 
443                 for karg in build_sh_spec['kargs']:
444                     build_sh_options += ' -k "%s"'%karg
445
446                 log_file="%s.log"%node_image
447                 # invoke build.sh
448                 build_command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
449                                                                          floppy_file,
450                                                                          node_image,
451                                                                          type,
452                                                                          build_sh_options,
453                                                                          log_file)
454                 if self.DEBUG:
455                     print 'build command:',build_command
456                 ret=os.system(build_command)
457                 if ret != 0:
458                     raise PLCAPIError,"bootcd/build.sh failed\n%s\n%s"%(
459                         build_command,file(log_file).read())
460
461                 self.trash.append(log_file)
462                 if not os.path.isfile (node_image):
463                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
464             
465                 # handle result
466                 if filename:
467                     ret=os.system("mv %s %s"%(node_image,filename))
468                     if ret != 0:
469                         self.trash.append(node_image)
470                         self.cleantrash()
471                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
472                     self.cleantrash()
473                     return filename
474                 else:
475                     result = file(node_image).read()
476                     self.trash.append(node_image)
477                     self.cleantrash()
478                     return base64.b64encode(result)
479             except:
480                 self.cleantrash()
481                 raise
482                 
483         # we're done here, or we missed something
484         raise PLCAPIError,'Unhandled action %s'%action
485