Fix name -> tagname. Thanks to Elliot Jaffe.
[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.InterfaceTags import InterfaceTag, InterfaceTags
16
17 # could not define this in the class..
18 # create a dict with the allowed actions for each type of node
19 allowed_actions = {
20                 'regular' : [ 'node-preview',
21                               'node-floppy',
22                               'node-iso',
23                               'node-usb',
24                               'generic-iso',
25                               'generic-usb',
26                                ],
27                 'dummynet' : [ 'node-preview',
28                                'dummynet-iso',
29                                'dummynet-usb',
30                              ],
31                 }
32
33 # compute a new key
34 def compute_key():
35     # Generate 32 random bytes
36     bytes = random.sample(xrange(0, 256), 32)
37     # Base64 encode their string representation
38     key = base64.b64encode("".join(map(chr, bytes)))
39     # Boot Manager cannot handle = in the key
40     # XXX this sounds wrong, as it might prevent proper decoding
41     key = key.replace("=", "")
42     return key
43
44 class GetBootMedium(Method):
45     """
46     This method is a redesign based on former, supposedly dedicated, 
47     AdmGenerateNodeConfFile
48
49     As compared with its ancestor, this method provides a much more detailed
50     detailed interface, that allows to
51     (*) either just preview the node config file -- in which case 
52         the node key is NOT recomputed, and NOT provided in the output
53     (*) or regenerate the node config file for storage on a floppy 
54         that is, exactly what the ancestor method used todo, 
55         including renewing the node's key
56     (*) or regenerate the config file and bundle it inside an ISO or USB image
57     (*) or just provide the generic ISO or USB boot images 
58         in which case of course the node_id_or_hostname parameter is not used
59
60     action is expected among the following string constants according the
61     node type value:
62
63     for a 'regular' node:
64     (*) node-preview
65     (*) node-floppy
66     (*) node-iso
67     (*) node-usb
68     (*) generic-iso
69     (*) generic-usb
70
71     for a 'dummynet' node:
72     (*) node-preview
73     (*) dummynet-iso
74     (*) dummynet-usb
75
76     Apart for the preview mode, this method generates a new node key for the
77     specified node, effectively invalidating any old boot medium.
78
79     In addition, two return mechanisms are supported.
80     (*) The default behaviour is that the file's content is returned as a 
81         base64-encoded string. This is how the ancestor method used to work.
82         To use this method, pass an empty string as the file parameter.
83
84     (*) Or, for efficiency -- this makes sense only when the API is used 
85         by the web pages that run on the same host -- the caller may provide 
86         a filename, in which case the resulting file is stored in that location instead. 
87         The filename argument can use the following markers, that are expanded 
88         within the method
89         - %d : default root dir (some builtin dedicated area under /var/tmp/)
90                Using this is recommended, and enforced for non-admin users
91         - %n : the node's name when this makes sense, or a mktemp-like name when 
92                generic media is requested
93         - %s : a file suffix appropriate in the context (.txt, .iso or the like)
94         - %v : the bootcd version string (e.g. 4.0)
95         - %p : the PLC name
96         - %f : the nodefamily
97         - %a : arch
98         With the file-based return mechanism, the method returns the full pathname 
99         of the result file; 
100         ** WARNING **
101         It is the caller's responsability to remove this file after use.
102
103     Options: an optional array of keywords. 
104         options are not supported for generic images
105         options are not supported for dummynet boxes
106     Currently supported are
107         - 'partition' - for USB actions only
108         - 'cramfs'
109         - 'serial' or 'serial:<console_spec>'
110         console_spec (or 'default') is passed as-is to bootcd/build.sh
111         it is expected to be a colon separated string denoting
112         tty - baudrate - parity - bits
113         e.g. ttyS0:115200:n:8
114         - 'variant:<variantname>'
115         passed to build.sh as -V <variant> 
116         variants are used to run a different kernel on the bootCD
117         see kvariant.sh for how to create a variant
118         - 'no-hangcheck'
119
120     Security:
121         - Non-admins can only generate files for nodes at their sites.
122         - Non-admins, when they provide a filename, *must* specify it in the %d area
123
124    Housekeeping: 
125         Whenever needed, the method stores intermediate files in a
126         private area, typically not located under the web server's
127         accessible area, and are cleaned up by the method.
128
129     """
130
131     roles = ['admin', 'pi', 'tech']
132
133     accepts = [
134         Auth(),
135         Mixed(Node.fields['node_id'],
136               Node.fields['hostname']),
137         Parameter (str, "Action mode, expected value depends of the type of node"),
138         Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
139         Parameter ([str], "Options"),
140         ]
141
142     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
143
144     # define globals for regular nodes, override later for other types
145     BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
146     BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
147     GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
148     WORKDIR = "/var/tmp/bootmedium"
149     DEBUG = False
150     # uncomment this to preserve temporary area and bootcustom logs
151     #DEBUG = True
152
153     ### returns (host, domain) :
154     # 'host' : host part of the hostname
155     # 'domain' : domain part of the hostname
156     def split_hostname (self, node):
157         # Split hostname into host and domain parts
158         parts = node['hostname'].split(".", 1)
159         if len(parts) < 2:
160             raise PLCInvalidArgument, "Node hostname %s is invalid"%node['hostname']
161         return parts
162         
163     # Generate the node (plnode.txt) configuration content.
164     #
165     # This function will create the configuration file a node
166     # composed by:
167     #  - a common part, regardless of the 'node_type' tag
168     #  - XXX a special part, depending on the 'node_type' tag value.
169     def floppy_contents (self, node, renew_key):
170
171         # Do basic checks
172         if node['peer_id'] is not None:
173             raise PLCInvalidArgument, "Not a local node"
174
175         # If we are not an admin, make sure that the caller is a
176         # member of the site at which the node is located.
177         if 'admin' not in self.caller['roles']:
178             if node['site_id'] not in self.caller['site_ids']:
179                 raise PLCPermissionDenied, "Not allowed to generate a configuration file for %s"%node['hostname']
180
181         # Get interface for this node
182         primary = None
183         interfaces = Interfaces(self.api, node['interface_ids'])
184         for interface in interfaces:
185             if interface['is_primary']:
186                 primary = interface
187                 break
188         if primary is None:
189             raise PLCInvalidArgument, "No primary network configured on %s"%node['hostname']
190
191         ( host, domain ) = self.split_hostname (node)
192
193         # renew the key and save it on the database
194         if renew_key:
195             node['key'] = compute_key()
196             node.sync()
197
198         # Generate node configuration file suitable for BootCD
199         file = ""
200
201         if renew_key:
202             file += 'NODE_ID="%d"\n' % node['node_id']
203             file += 'NODE_KEY="%s"\n' % node['key']
204             # not used anywhere, just a note for operations people
205             file += 'KEY_RENEWAL_DATE="%s"\n' % time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime())
206
207         if primary['mac']:
208             file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
209
210         file += 'IP_METHOD="%s"\n' % primary['method']
211
212         if primary['method'] == 'static':
213             file += 'IP_ADDRESS="%s"\n' % primary['ip']
214             file += 'IP_GATEWAY="%s"\n' % primary['gateway']
215             file += 'IP_NETMASK="%s"\n' % primary['netmask']
216             file += 'IP_NETADDR="%s"\n' % primary['network']
217             file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
218             file += 'IP_DNS1="%s"\n' % primary['dns1']
219             file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
220
221         file += 'HOST_NAME="%s"\n' % host
222         file += 'DOMAIN_NAME="%s"\n' % domain
223
224         # define various interface settings attached to the primary interface
225         settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
226
227         categories = set()
228         for setting in settings:
229             if setting['category'] is not None:
230                 categories.add(setting['category'])
231         
232         for category in categories:
233             category_settings = InterfaceTags(self.api,{'interface_id':interface['interface_id'],
234                                                               'category':category})
235             if category_settings:
236                 file += '### Category : %s\n'%category
237                 for setting in category_settings:
238                     file += '%s_%s="%s"\n'%(category.upper(),setting['tagname'].upper(),setting['value'])
239
240         for interface in interfaces:
241             if interface['method'] == 'ipmi':
242                 file += 'IPMI_ADDRESS="%s"\n' % interface['ip']
243                 if interface['mac']:
244                     file += 'IPMI_MAC="%s"\n' % interface['mac'].lower()
245                 break
246
247         return file
248
249     # see also InstallBootstrapFS in bootmanager that does similar things
250     def get_nodefamily (self, node):
251         # get defaults from the myplc build
252         try:
253             (pldistro,arch) = file("/etc/planetlab/nodefamily").read().strip().split("-")
254         except:
255             (pldistro,arch) = ("planetlab","i386")
256             
257         # with no valid argument, return system-wide defaults
258         if not node:
259             return (pldistro,arch)
260
261         node_id=node['node_id']
262
263         tag=Nodes(self.api,[node_id],['arch'])[0]['arch']
264         if tag: arch=tag
265         tag=Nodes(self.api,[node_id],['pldistro'])[0]['pldistro']
266         if tag: pldistro=tag
267
268         return (pldistro,arch)
269
270     def bootcd_version (self):
271         try:
272             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
273         except:
274             raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
275     
276     def cleantrash (self):
277         for file in self.trash:
278             if self.DEBUG:
279                 print 'DEBUG -- preserving',file
280             else:
281                 os.unlink(file)
282
283     ### handle filename
284     # build the filename string 
285     # check for permissions and concurrency
286     # returns the filename
287     def handle_filename (self, filename, nodename, suffix, arch):
288         # allow to set filename to None or any other empty value
289         if not filename: filename=''
290         filename = filename.replace ("%d",self.WORKDIR)
291         filename = filename.replace ("%n",nodename)
292         filename = filename.replace ("%s",suffix)
293         filename = filename.replace ("%p",self.api.config.PLC_NAME)
294         # let's be cautious
295         try: filename = filename.replace ("%f", self.nodefamily)
296         except: pass
297         try: filename = filename.replace ("%a", arch)
298         except: pass
299         try: filename = filename.replace ("%v",self.bootcd_version())
300         except: pass
301
302         ### Check filename location
303         if filename != '':
304             if 'admin' not in self.caller['roles']:
305                 if ( filename.index(self.WORKDIR) != 0):
306                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
307
308             ### output should not exist (concurrent runs ..)
309             if os.path.exists(filename):
310                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
311
312             ### we can now safely create the file, 
313             ### either we are admin or under a controlled location
314             filedir=os.path.dirname(filename)
315             # dirname does not return "." for a local filename like its shell counterpart
316             if filedir:
317                 if not os.path.exists(filedir):
318                     try:
319                         os.makedirs (filedir,0777)
320                     except:
321                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
322
323         return filename
324
325     # Build the command line to be executed
326     # according the node type
327     def build_command(self, node_type, build_sh_spec, node_image, type, floppy_file, log_file):
328
329         command = ""
330
331         # regular node, make build's arguments
332         # and build the full command line to be called
333         if node_type == 'regular':
334
335             build_sh_options=""
336             if "cramfs" in build_sh_spec: 
337                 type += "_cramfs"
338             if "serial" in build_sh_spec: 
339                 build_sh_options += " -s %s"%build_sh_spec['serial']
340             if "variant" in build_sh_spec:
341                 build_sh_options += " -V %s"%build_sh_spec['variant']
342             
343             for karg in build_sh_spec['kargs']:
344                 build_sh_options += ' -k "%s"'%karg
345
346             log_file="%s.log"%node_image
347
348             command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
349                                                                  floppy_file,
350                                                                  node_image,
351                                                                  type,
352                                                                  build_sh_options,
353                                                                  log_file)
354         # dummynet node
355         elif node_type == 'dummynet':
356             # the build script expect the following parameters:
357             # the package base directory
358             # the working directory
359             # the full path of the configuration file
360             # the name of the resulting image file
361             # the type of the generated image
362             # the name of the log file
363             command = "%s -b %s -w %s -f %s -o %s -t %s -l %s" \
364                         % (self.BOOTCDBUILD, self.BOOTCDDIR, self.WORKDIR,
365                            floppy_file, node_image, type, log_file)
366             command = "touch %s %s; echo 'dummynet build script not yet supported'" \
367                         % (log_file, node_image)
368
369         if self.DEBUG:
370             print "The build command line is %s" % command
371
372         return command 
373
374     def call(self, auth, node_id_or_hostname, action, filename, options = []):
375
376         self.trash=[]
377
378         ### compute file suffix and type
379         if action.find("-iso") >= 0 :
380             suffix=".iso"
381             type = "iso"
382         elif action.find("-usb") >= 0:
383             suffix=".usb"
384             type = "usb"
385         else:
386             suffix=".txt"
387             type = "txt"
388
389         # check for node existence and get node_type
390         nodes = Nodes(self.api, [node_id_or_hostname])
391         if not nodes:
392             raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
393         node = nodes[0]
394
395         if self.DEBUG: print "%s required on node %s. Node type is: %s" \
396                 % (action, node['node_id'], node['node_type'])
397
398         # check the required action against the node type
399         node_type = node['node_type']
400         if action not in allowed_actions[node_type]:
401             raise PLCInvalidArgument, "Action %s not valid for %s nodes, valid actions are %s" \
402                                    % (action, node_type, "|".join(allowed_actions[node_type]))
403
404         # handle / canonicalize options
405         if type == "txt":
406             if options:
407                 raise PLCInvalidArgument, "Options are not supported for node configs"
408         else:
409             # create a dict for build.sh 
410             build_sh_spec={'kargs':[]}
411             for option in options:
412                 if option == "cramfs":
413                     build_sh_spec['cramfs']=True
414                 elif option == 'partition':
415                     if type != "usb":
416                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
417                     else:
418                         type="usb_partition"
419                 elif option == "serial":
420                     build_sh_spec['serial']='default'
421                 elif option.find("serial:") == 0:
422                     build_sh_spec['serial']=option.replace("serial:","")
423                 elif option.find("variant:") == 0:
424                     build_sh_spec['variant']=option.replace("variant:","")
425                 elif option == "no-hangcheck":
426                     build_sh_spec['kargs'].append('hcheck_reboot0')
427                 else:
428                     raise PLCInvalidArgument, "unknown option %s"%option
429
430         # compute nodename according the action
431         if action.find("node-") == 0 or action.find("dummynet-") == 0:
432             nodename = node['hostname']
433         else:
434             node = None
435             # compute a 8 bytes random number
436             tempbytes = random.sample (xrange(0,256), 8);
437             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
438             nodename = "".join(map(hexa2,tempbytes))
439
440         # override some global definition, according node_type
441         if node_type == 'dummynet':
442             self.BOOTCDDIR = "/usr/share/dummynet"              # the base installation dir
443             self.BOOTCDBUILD = "/usr/share/dummynet/build.sh"   # dummynet build script
444             self.WORKDIR = "/var/tmp/DummynetBoxMedium"         # temporary working dir
445
446         # get nodefamily
447         (pldistro,arch) = self.get_nodefamily(node)
448         self.nodefamily="%s-%s"%(pldistro,arch)
449
450         # apply on globals
451         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
452             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
453             
454         filename = self.handle_filename(filename, nodename, suffix, arch)
455         
456         # log call
457         if node:
458             self.message='GetBootMedium on node %s - action=%s'%(nodename,action)
459             self.event_objects={'Node': [ node ['node_id'] ]}
460         else:
461             self.message='GetBootMedium - generic - action=%s'%action
462
463         ### generic media
464         if action == 'generic-iso' or action == 'generic-usb':
465             if options:
466                 raise PLCInvalidArgument, "Options are not supported for generic images"
467             # this raises an exception if bootcd is missing
468             version = self.bootcd_version()
469             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
470                                              version,
471                                              suffix)
472             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
473
474             if filename:
475                 ret=os.system ('cp "%s" "%s"'%(generic_path,filename))
476                 if ret==0:
477                     return filename
478                 else:
479                     raise PLCPermissionDenied, "Could not copy %s into %s"%(generic_path,filename)
480             else:
481                 ### return the generic medium content as-is, just base64 encoded
482                 return base64.b64encode(file(generic_path).read())
483
484         ### config file preview or regenerated
485         if action == 'node-preview' or action == 'node-floppy':
486             renew_key = (action == 'node-floppy')
487             floppy = self.floppy_contents (node,renew_key)
488             if filename:
489                 try:
490                     file(filename,'w').write(floppy)
491                 except:
492                     raise PLCPermissionDenied, "Could not write into %s"%filename
493                 return filename
494             else:
495                 return floppy
496
497         ### we're left with node-iso and node-usb
498         # the steps involved in the image creation are:
499         # - create and test the working environment
500         # - generate the configuration file
501         # - build and invoke the build command
502         # - delivery the resulting image file
503
504         if action == 'node-iso' or action == 'node-usb' \
505                  or action == 'dummynet-iso' or action == 'dummynet-usb':
506
507             ### check we've got required material
508             version = self.bootcd_version()
509             
510             if not os.path.isfile(self.BOOTCDBUILD):
511                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
512
513             # create the workdir if needed
514             if not os.path.isdir(self.WORKDIR):
515                 try:
516                     os.makedirs(self.WORKDIR,0777)
517                     os.chmod(self.WORKDIR,0777)
518                 except:
519                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
520             
521             try:
522                 # generate floppy config
523                 floppy_text = self.floppy_contents(node,True)
524                 # store it
525                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
526                 try:
527                     file(floppy_file,"w").write(floppy_text)
528                 except:
529                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
530
531                 self.trash.append(floppy_file)
532
533                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
534                 log_file="%s.log"%node_image
535
536                 command = self.build_command(node_type, build_sh_spec, node_image, type, floppy_file, log_file)
537
538                 # invoke the image build script
539                 if command != "":
540                     ret=os.system(command)
541
542                 if ret != 0:
543                     raise PLCAPIError, "%s failed Command line was: %s Error logs: %s" % \
544                               (self.BOOTCDBUILD,  command, file(log_file).read())
545
546                 self.trash.append(log_file)
547
548                 if not os.path.isfile (node_image):
549                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
550             
551                 # handle result
552                 if filename:
553                     ret=os.system('mv "%s" "%s"'%(node_image,filename))
554                     if ret != 0:
555                         self.trash.append(node_image)
556                         self.cleantrash()
557                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
558                     self.cleantrash()
559                     return filename
560                 else:
561                     result = file(node_image).read()
562                     self.trash.append(node_image)
563                     self.cleantrash()
564                     return base64.b64encode(result)
565             except:
566                 self.cleantrash()
567                 raise
568                 
569         # we're done here, or we missed something
570         raise PLCAPIError,'Unhandled action %s'%action
571