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