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