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