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