stores KEY_RENEWAL_DATE for information in plnode.txt
[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             file += 'KEY_RENEWAL_DATE="%s"\n' % time.strftime('%Y-%m-%d at %H:%M:%S +0000',time.gmtime())
179
180         if primary['mac']:
181             file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
182
183         file += 'IP_METHOD="%s"\n' % primary['method']
184
185         if primary['method'] == 'static':
186             file += 'IP_ADDRESS="%s"\n' % primary['ip']
187             file += 'IP_GATEWAY="%s"\n' % primary['gateway']
188             file += 'IP_NETMASK="%s"\n' % primary['netmask']
189             file += 'IP_NETADDR="%s"\n' % primary['network']
190             file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
191             file += 'IP_DNS1="%s"\n' % primary['dns1']
192             file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
193
194         file += 'HOST_NAME="%s"\n' % host
195         file += 'DOMAIN_NAME="%s"\n' % domain
196
197         # define various interface settings attached to the primary interface
198         settings = InterfaceSettings (self.api, {'interface_id':interface['interface_id']})
199
200         categories = set()
201         for setting in settings:
202             if setting['category'] is not None:
203                 categories.add(setting['category'])
204         
205         for category in categories:
206             category_settings = InterfaceSettings(self.api,{'interface_id':interface['interface_id'],
207                                                               'category':category})
208             if category_settings:
209                 file += '### Category : %s\n'%category
210                 for setting in category_settings:
211                     file += '%s_%s="%s"\n'%(category.upper(),setting['name'].upper(),setting['value'])
212
213         for interface in interfaces:
214             if interface['method'] == 'ipmi':
215                 file += 'IPMI_ADDRESS="%s"\n' % interface['ip']
216                 if interface['mac']:
217                     file += 'IPMI_MAC="%s"\n' % interface['mac'].lower()
218                 break
219
220         return file
221
222     # see also InstallBootstrapFS in bootmanager that does similar things
223     def get_nodefamily (self, node):
224         # get defaults from the myplc build
225         try:
226             (pldistro,arch) = file("/etc/planetlab/nodefamily").read().strip().split("-")
227         except:
228             (pldistro,arch) = ("planetlab","i386")
229             
230         # with no valid argument, return system-wide defaults
231         if not node:
232             return (pldistro,arch)
233
234         node_id=node['node_id']
235         # cannot use accessors in the API itself
236         # the 'arch' tag type is assumed to exist, see db-config
237         arch_tags = NodeTags (self.api, {'tagname':'arch','node_id':node_id},['tagvalue'])
238         if arch_tags:
239             arch=arch_tags[0]['tagvalue']
240         # ditto
241         pldistro_tags = NodeTags (self.api, {'tagname':'pldistro','node_id':node_id},['tagvalue'])
242         if pldistro_tags:
243             pldistro=pldistro_tags[0]['tagvalue']
244
245         return (pldistro,arch)
246
247     def bootcd_version (self):
248         try:
249             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
250         except:
251             raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
252     
253     def cleantrash (self):
254         for file in self.trash:
255             if self.DEBUG:
256                 print 'DEBUG -- preserving',file
257             else:
258                 os.unlink(file)
259
260     def call(self, auth, node_id_or_hostname, action, filename, options = []):
261
262         self.trash=[]
263         ### check action
264         if action not in boot_medium_actions:
265             raise PLCInvalidArgument, "Unknown action %s"%action
266
267         ### compute file suffix and type
268         if action.find("-iso") >= 0 :
269             suffix=".iso"
270             type = "iso"
271         elif action.find("-usb") >= 0:
272             suffix=".usb"
273             type = "usb"
274         else:
275             suffix=".txt"
276             type = "txt"
277
278         # handle / caconicalize options
279         if type == "txt":
280             if options:
281                 raise PLCInvalidArgument, "Options are not supported for node configs"
282         else:
283             # create a dict for build.sh 
284             optdict={}
285             for option in options:
286                 if option == "cramfs":
287                     optdict['cramfs']=True
288                 elif option == 'partition':
289                     if type != "usb":
290                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
291                     else:
292                         type="usb_partition"
293                 elif option == "serial":
294                     optdict['serial']='default'
295                 elif option.find("serial:") == 0:
296                     optdict['serial']=option.replace("serial:","")
297                 else:
298                     raise PLCInvalidArgument, "unknown option %s"%option
299
300         ### check node if needed
301         if action.find("node-") == 0:
302             nodes = Nodes(self.api, [node_id_or_hostname])
303             if not nodes:
304                 raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
305             node = nodes[0]
306             nodename = node['hostname']
307
308         else:
309             node = None
310             # compute a 8 bytes random number
311             tempbytes = random.sample (xrange(0,256), 8);
312             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
313             nodename = "".join(map(hexa2,tempbytes))
314
315         # get nodefamily
316         (pldistro,arch) = self.get_nodefamily(node)
317         self.nodefamily="%s-%s"%(pldistro,arch)
318         # apply on globals
319         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
320             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
321             
322         ### handle filename
323         # allow to set filename to None or any other empty value
324         if not filename: filename=''
325         filename = filename.replace ("%d",self.WORKDIR)
326         filename = filename.replace ("%n",nodename)
327         filename = filename.replace ("%s",suffix)
328         filename = filename.replace ("%p",self.api.config.PLC_NAME)
329         # let's be cautious
330         try: filename = filename.replace ("%f", self.nodefamily)
331         except: pass
332         try: filename = filename.replace ("%a", arch)
333         except: pass
334         try: filename = filename.replace ("%v",self.bootcd_version())
335         except: pass
336
337         ### Check filename location
338         if filename != '':
339             if 'admin' not in self.caller['roles']:
340                 if ( filename.index(self.WORKDIR) != 0):
341                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
342
343             ### output should not exist (concurrent runs ..)
344             if os.path.exists(filename):
345                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
346
347             ### we can now safely create the file, 
348             ### either we are admin or under a controlled location
349             filedir=os.path.dirname(filename)
350             # dirname does not return "." for a local filename like its shell counterpart
351             if filedir:
352                 if not os.path.exists(filedir):
353                     try:
354                         os.makedirs (filedir,0777)
355                     except:
356                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
357
358         
359         ### generic media
360         if action == 'generic-iso' or action == 'generic-usb':
361             if options:
362                 raise PLCInvalidArgument, "Options are not supported for generic images"
363             # this raises an exception if bootcd is missing
364             version = self.bootcd_version()
365             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
366                                              version,
367                                              suffix)
368             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
369
370             if filename:
371                 ret=os.system ("cp %s %s"%(generic_path,filename))
372                 if ret==0:
373                     return filename
374                 else:
375                     raise PLCPermissionDenied, "Could not copy %s into"%(generic_path,filename)
376             else:
377                 ### return the generic medium content as-is, just base64 encoded
378                 return base64.b64encode(file(generic_path).read())
379
380         ### config file preview or regenerated
381         if action == 'node-preview' or action == 'node-floppy':
382             renew_key = (action == 'node-floppy')
383             floppy = self.floppy_contents (node,renew_key)
384             if filename:
385                 try:
386                     file(filename,'w').write(floppy)
387                 except:
388                     raise PLCPermissionDenied, "Could not write into %s"%filename
389                 return filename
390             else:
391                 return floppy
392
393         ### we're left with node-iso and node-usb
394         if action == 'node-iso' or action == 'node-usb':
395
396             ### check we've got required material
397             version = self.bootcd_version()
398             
399             if not os.path.isfile(self.BOOTCDBUILD):
400                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
401
402             # create the workdir if needed
403             if not os.path.isdir(self.WORKDIR):
404                 try:
405                     os.makedirs(self.WORKDIR,0777)
406                     os.chmod(self.WORKDIR,0777)
407                 except:
408                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
409             
410             try:
411                 # generate floppy config
412                 floppy_text = self.floppy_contents(node,True)
413                 # store it
414                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
415                 try:
416                     file(floppy_file,"w").write(floppy_text)
417                 except:
418                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
419
420                 self.trash.append(floppy_file)
421
422                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
423
424                 # make build's arguments
425                 serial_arg=""
426                 if "cramfs" in optdict: type += "_cramfs"
427                 if "serial" in optdict: serial_arg = "-s %s"%optdict['serial']
428                 log_file="%s.log"%node_image
429                 # invoke build.sh
430                 build_command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
431                                                                          floppy_file,
432                                                                          node_image,
433                                                                          type,
434                                                                          serial_arg,
435                                                                          log_file)
436                 if self.DEBUG:
437                     print 'build command:',build_command
438                 ret=os.system(build_command)
439                 if ret != 0:
440                     raise PLCAPIError,"bootcd/build.sh failed\n%s\n%s"%(
441                         build_command,file(log_file).read())
442
443                 self.trash.append(log_file)
444                 if not os.path.isfile (node_image):
445                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
446             
447                 # handle result
448                 if filename:
449                     ret=os.system("mv %s %s"%(node_image,filename))
450                     if ret != 0:
451                         self.trash.append(node_image)
452                         self.cleantrash()
453                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
454                     self.cleantrash()
455                     return filename
456                 else:
457                     result = file(node_image).read()
458                     self.trash.append(node_image)
459                     self.cleantrash()
460                     return base64.b64encode(result)
461             except:
462                 self.cleantrash()
463                 raise
464                 
465         # we're done here, or we missed something
466         raise PLCAPIError,'Unhandled action %s'%action
467