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