Add timestamps to Nodes, PCUs and Interfaces to make concrete
[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.update_last_download(commit=False)
203             node.sync()
204
205         # Generate node configuration file suitable for BootCD
206         file = ""
207
208         if renew_key:
209             file += 'NODE_ID="%d"\n' % node['node_id']
210             file += 'NODE_KEY="%s"\n' % node['key']
211             # not used anywhere, just a note for operations people
212             file += 'KEY_RENEWAL_DATE="%s"\n' % time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime())
213
214         if primary['mac']:
215             file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
216
217         file += 'IP_METHOD="%s"\n' % primary['method']
218
219         if primary['method'] == 'static':
220             file += 'IP_ADDRESS="%s"\n' % primary['ip']
221             file += 'IP_GATEWAY="%s"\n' % primary['gateway']
222             file += 'IP_NETMASK="%s"\n' % primary['netmask']
223             file += 'IP_NETADDR="%s"\n' % primary['network']
224             file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
225             file += 'IP_DNS1="%s"\n' % primary['dns1']
226             file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
227
228         file += 'HOST_NAME="%s"\n' % host
229         file += 'DOMAIN_NAME="%s"\n' % domain
230
231         # define various interface settings attached to the primary interface
232         settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
233
234         categories = set()
235         for setting in settings:
236             if setting['category'] is not None:
237                 categories.add(setting['category'])
238
239         for category in categories:
240             category_settings = InterfaceTags(self.api,{'interface_id':interface['interface_id'],
241                                                               'category':category})
242             if category_settings:
243                 file += '### Category : %s\n'%category
244                 for setting in category_settings:
245                     file += '%s_%s="%s"\n'%(category.upper(),setting['tagname'].upper(),setting['value'])
246
247         for interface in interfaces:
248             if interface['method'] == 'ipmi':
249                 file += 'IPMI_ADDRESS="%s"\n' % interface['ip']
250                 if interface['mac']:
251                     file += 'IPMI_MAC="%s"\n' % interface['mac'].lower()
252                 break
253
254         return file
255
256     # see also GetNodeFlavour that does similar things
257     def get_nodefamily (self, node, auth):
258         pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
259         fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
260         arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
261         if not node:
262             return (pldistro,fcdistro,arch)
263
264         node_id=node['node_id']
265
266         # no support for deployment-based BootCD's, use kvariants instead
267         node_pldistro = GetNodePldistro (self.api).call(auth, node_id)
268         if node_pldistro: pldistro = node_pldistro
269
270         node_fcdistro = GetNodeFcdistro (self.api).call(auth, node_id)
271         if node_fcdistro: fcdistro = node_fcdistro
272
273         node_arch = GetNodeArch (self.api).call(auth,node_id)
274         if node_arch: arch = node_arch
275
276         return (pldistro,fcdistro,arch)
277
278     def bootcd_version (self):
279         try:
280             return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
281         except:
282             raise Exception,"Unknown boot cd version - probably wrong bootcd dir : %s"%self.BOOTCDDIR
283
284     def cleantrash (self):
285         for file in self.trash:
286             if self.DEBUG:
287                 print 'DEBUG -- preserving',file
288             else:
289                 os.unlink(file)
290
291     ### handle filename
292     # build the filename string
293     # check for permissions and concurrency
294     # returns the filename
295     def handle_filename (self, filename, nodename, suffix, arch):
296         # allow to set filename to None or any other empty value
297         if not filename: filename=''
298         filename = filename.replace ("%d",self.WORKDIR)
299         filename = filename.replace ("%n",nodename)
300         filename = filename.replace ("%s",suffix)
301         filename = filename.replace ("%p",self.api.config.PLC_NAME)
302         # let's be cautious
303         try: filename = filename.replace ("%f", self.nodefamily)
304         except: pass
305         try: filename = filename.replace ("%a", arch)
306         except: pass
307         try: filename = filename.replace ("%v",self.bootcd_version())
308         except: pass
309
310         ### Check filename location
311         if filename != '':
312             if 'admin' not in self.caller['roles']:
313                 if ( filename.index(self.WORKDIR) != 0):
314                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
315
316             ### output should not exist (concurrent runs ..)
317             if os.path.exists(filename):
318                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
319
320             ### we can now safely create the file,
321             ### either we are admin or under a controlled location
322             filedir=os.path.dirname(filename)
323             # dirname does not return "." for a local filename like its shell counterpart
324             if filedir:
325                 if not os.path.exists(filedir):
326                     try:
327                         os.makedirs (filedir,0777)
328                     except:
329                         raise PLCPermissionDenied, "Could not create dir %s"%filedir
330
331         return filename
332
333     # Build the command line to be executed
334     # according the node type
335     def build_command(self, node_type, build_sh_spec, node_image, type, floppy_file, log_file):
336
337         command = ""
338
339         # regular node, make build's arguments
340         # and build the full command line to be called
341         if node_type in [ 'regular', 'reservable' ]:
342
343             build_sh_options=""
344             if "cramfs" in build_sh_spec:
345                 type += "_cramfs"
346             if "serial" in build_sh_spec:
347                 build_sh_options += " -s %s"%build_sh_spec['serial']
348             if "variant" in build_sh_spec:
349                 build_sh_options += " -V %s"%build_sh_spec['variant']
350
351             for karg in build_sh_spec['kargs']:
352                 build_sh_options += ' -k "%s"'%karg
353
354             log_file="%s.log"%node_image
355
356             command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
357                                                                  floppy_file,
358                                                                  node_image,
359                                                                  type,
360                                                                  build_sh_options,
361                                                                  log_file)
362
363         if self.DEBUG:
364             print "The build command line is %s" % command
365
366         return command
367
368     def call(self, auth, node_id_or_hostname, action, filename, options = []):
369
370         self.trash=[]
371
372         ### compute file suffix and type
373         if action.find("-iso") >= 0 :
374             suffix=".iso"
375             type = "iso"
376         elif action.find("-usb") >= 0:
377             suffix=".usb"
378             type = "usb"
379         else:
380             suffix=".txt"
381             type = "txt"
382
383         # check for node existence and get node_type
384         nodes = Nodes(self.api, [node_id_or_hostname])
385         if not nodes:
386             raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
387         node = nodes[0]
388
389         if self.DEBUG: print "%s required on node %s. Node type is: %s" \
390                 % (action, node['node_id'], node['node_type'])
391
392         # check the required action against the node type
393         node_type = node['node_type']
394         if action not in allowed_actions[node_type]:
395             raise PLCInvalidArgument, "Action %s not valid for %s nodes, valid actions are %s" \
396                                    % (action, node_type, "|".join(allowed_actions[node_type]))
397
398         # handle / canonicalize options
399         if type == "txt":
400             if options:
401                 raise PLCInvalidArgument, "Options are not supported for node configs"
402         else:
403             # create a dict for build.sh
404             build_sh_spec={'kargs':[]}
405             # use node tags as defaults
406             # check for node tag equivalents
407             tags = NodeTags(self.api,
408                             {'node_id': node['node_id'],
409                              'tagname': ['serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck']},
410                             ['tagname', 'value'])
411             if tags:
412                 for tag in tags:
413                     if tag['tagname'] == 'serial':
414                         build_sh_spec['serial'] = tag['value']
415                     if tag['tagname'] == 'cramfs':
416                         build_sh_spec['cramfs'] = True
417                     if tag['tagname'] == 'kvariant':
418                         build_sh_spec['variant'] = tag['value']
419                     if tag['tagname'] == 'kargs':
420                         build_sh_spec['kargs'] += tag['value'].split()
421                     if tag['tagname'] == 'no-hangcheck':
422                         build_sh_spec['kargs'].append('hcheck_reboot0')
423             # then options can override tags
424             for option in options:
425                 if option == "cramfs":
426                     build_sh_spec['cramfs']=True
427                 elif option == 'partition':
428                     if type != "usb":
429                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
430                     else:
431                         type="usb_partition"
432                 elif option == "serial":
433                     build_sh_spec['serial']='default'
434                 elif option.find("serial:") == 0:
435                     build_sh_spec['serial']=option.replace("serial:","")
436                 elif option.find("variant:") == 0:
437                     build_sh_spec['variant']=option.replace("variant:","")
438                 elif option == "no-hangcheck":
439                     build_sh_spec['kargs'].append('hcheck_reboot0')
440                 else:
441                     raise PLCInvalidArgument, "unknown option %s"%option
442
443         # compute nodename according the action
444         if action.find("node-") == 0:
445             nodename = node['hostname']
446         else:
447             node = None
448             # compute a 8 bytes random number
449             tempbytes = random.sample (xrange(0,256), 8);
450             def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
451             nodename = "".join(map(hexa2,tempbytes))
452
453         # get nodefamily
454         (pldistro,fcdistro,arch) = self.get_nodefamily(node,auth)
455         self.nodefamily="%s-%s-%s"%(pldistro,fcdistro,arch)
456
457         # apply on globals
458         for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
459             setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
460
461         filename = self.handle_filename(filename, nodename, suffix, arch)
462
463         # log call
464         if node:
465             self.message='GetBootMedium on node %s - action=%s'%(nodename,action)
466             self.event_objects={'Node': [ node ['node_id'] ]}
467         else:
468             self.message='GetBootMedium - generic - action=%s'%action
469
470         ### generic media
471         if action == 'generic-iso' or action == 'generic-usb':
472             if options:
473                 raise PLCInvalidArgument, "Options are not supported for generic images"
474             # this raises an exception if bootcd is missing
475             version = self.bootcd_version()
476             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
477                                              version,
478                                              suffix)
479             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
480
481             if filename:
482                 ret=os.system ('cp "%s" "%s"'%(generic_path,filename))
483                 if ret==0:
484                     return filename
485                 else:
486                     raise PLCPermissionDenied, "Could not copy %s into %s"%(generic_path,filename)
487             else:
488                 ### return the generic medium content as-is, just base64 encoded
489                 return base64.b64encode(file(generic_path).read())
490
491         ### config file preview or regenerated
492         if action == 'node-preview' or action == 'node-floppy':
493             renew_key = (action == 'node-floppy')
494             floppy = self.floppy_contents (node,renew_key)
495             if filename:
496                 try:
497                     file(filename,'w').write(floppy)
498                 except:
499                     raise PLCPermissionDenied, "Could not write into %s"%filename
500                 return filename
501             else:
502                 return floppy
503
504         ### we're left with node-iso and node-usb
505         # the steps involved in the image creation are:
506         # - create and test the working environment
507         # - generate the configuration file
508         # - build and invoke the build command
509         # - delivery the resulting image file
510
511         if action == 'node-iso' or action == 'node-usb':
512
513             ### check we've got required material
514             version = self.bootcd_version()
515
516             if not os.path.isfile(self.BOOTCDBUILD):
517                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
518
519             # create the workdir if needed
520             if not os.path.isdir(self.WORKDIR):
521                 try:
522                     os.makedirs(self.WORKDIR,0777)
523                     os.chmod(self.WORKDIR,0777)
524                 except:
525                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
526
527             try:
528                 # generate floppy config
529                 floppy_text = self.floppy_contents(node,True)
530                 # store it
531                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
532                 try:
533                     file(floppy_file,"w").write(floppy_text)
534                 except:
535                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
536
537                 self.trash.append(floppy_file)
538
539                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
540                 log_file="%s.log"%node_image
541
542                 command = self.build_command(node_type, build_sh_spec, node_image, type, floppy_file, log_file)
543
544                 # invoke the image build script
545                 if command != "":
546                     ret=os.system(command)
547
548                 if ret != 0:
549                     raise PLCAPIError, "%s failed Command line was: %s Error logs: %s" % \
550                               (self.BOOTCDBUILD,  command, file(log_file).read())
551
552                 self.trash.append(log_file)
553
554                 if not os.path.isfile (node_image):
555                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
556
557                 # handle result
558                 if filename:
559                     ret=os.system('mv "%s" "%s"'%(node_image,filename))
560                     if ret != 0:
561                         self.trash.append(node_image)
562                         self.cleantrash()
563                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
564                     self.cleantrash()
565                     return filename
566                 else:
567                     result = file(node_image).read()
568                     self.trash.append(node_image)
569                     self.cleantrash()
570                     return base64.b64encode(result)
571             except:
572                 self.cleantrash()
573                 raise
574
575         # we're done here, or we missed something
576         raise PLCAPIError,'Unhandled action %s'%action