using 'is' rather than '==' is wrong
[plcapi.git] / PLC / Methods / GetBootMedium.py
1 # $Id$
2 import random
3 import base64
4 import os
5 import os.path
6
7 from PLC.Faults import *
8 from PLC.Method import Method
9 from PLC.Parameter import Parameter, Mixed
10 from PLC.Auth import Auth
11
12 from PLC.Nodes import Node, Nodes
13 from PLC.NodeNetworks import NodeNetwork, NodeNetworks
14 from PLC.NodeNetworkSettings import NodeNetworkSetting, NodeNetworkSettings
15
16 # could not define this in the class..
17 boot_medium_actions = [ 'node-preview',
18                         'node-floppy',
19                         'node-iso',
20                         'node-usb',
21                         'generic-iso',
22                         'generic-usb',
23                         ]
24
25 class GetBootMedium(Method):
26     """
27     This method is a redesign based on former, supposedly dedicated, 
28     AdmGenerateNodeConfFile
29
30     As compared with its ancestor, this method provides a much more detailed
31     detailed interface, that allows to
32     (*) either just preview the node config file -- in which case 
33         the node key is NOT recomputed, and NOT provided in the output
34     (*) or regenerate the node config file for storage on a floppy 
35         that is, exactly what the ancestor method used todo, 
36         including renewing the node's key
37     (*) or regenerate the config file and bundle it inside an ISO or USB image
38     (*) or just provide the generic ISO or USB boot images 
39         in which case of course the node_id_or_hostname parameter is not used
40
41     action is expected among the following string constants
42     (*) node-preview
43     (*) node-floppy
44     (*) node-iso
45     (*) node-usb
46     (*) generic-iso
47     (*) generic-usb
48
49     Apart for the preview mode, this method generates a new node key for the
50     specified node, effectively invalidating any old boot medium.
51
52     In addition, two return mechanisms are supported.
53     (*) The default behaviour is that the file's content is returned as a 
54         base64-encoded string. This is how the ancestor method used to work.
55         To use this method, pass an empty string as the file parameter.
56
57     (*) Or, for efficiency -- this makes sense only when the API is used 
58         by the web pages that run on the same host -- the caller may provide 
59         a filename, in which case the resulting file is stored in that location instead. 
60         The filename argument can use the following markers, that are expanded 
61         within the method
62         - %d : default root dir (some builtin dedicated area under /var/tmp/)
63                Using this is recommended, and enforced for non-admin users
64         - %n : the node's name when this makes sense, or a mktemp-like name when 
65                generic media is requested
66         - %s : a file suffix appropriate in the context (.txt, .iso or the like)
67         - %v : the bootcd version string (e.g. 4.0)
68         - %p : the PLC name
69         With the file-based return mechanism, the method returns the full pathname 
70         of the result file; 
71         ** WARNING **
72         It is the caller's responsability to remove this file after use.
73
74     Options: an optional array of keywords. 
75         options are not supported for generic images
76     Currently supported are
77         - 'partition' - for USB actions only
78         - 'cramfs'
79         - 'serial' or 'serial:<console_spec>'
80         console_spec (or 'default') is passed as-is to bootcd/build.sh
81         it is expected to be a colon separated string denoting
82         tty - baudrate - parity - bits
83         e.g. ttyS0:115200:n:8
84
85     Security:
86         - Non-admins can only generate files for nodes at their sites.
87         - Non-admins, when they provide a filename, *must* specify it in the %d area
88
89    Housekeeping: 
90         Whenever needed, the method stores intermediate files in a
91         private area, typically not located under the web server's
92         accessible area, and are cleaned up by the method.
93
94     """
95
96     roles = ['admin', 'pi', 'tech']
97
98     accepts = [
99         Auth(),
100         Mixed(Node.fields['node_id'],
101               Node.fields['hostname']),
102         Parameter (str, "Action mode, expected in " + "|".join(boot_medium_actions)),
103         Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
104         Parameter ([str], "Options"),
105         ]
106
107     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
108
109     BOOTCDDIR = "/usr/share/bootcd/"
110     BOOTCDBUILD = "/usr/share/bootcd/build.sh"
111     GENERICDIR = "/var/www/html/download/"
112     WORKDIR = "/var/tmp/bootmedium"
113     DEBUG = False
114     # uncomment this to preserve temporary area and bootcustom logs
115     #DEBUG = True
116
117     ### returns (host, domain) :
118     # 'host' : host part of the hostname
119     # 'domain' : domain part of the hostname
120     def split_hostname (self, node):
121         # Split hostname into host and domain parts
122         parts = node['hostname'].split(".", 1)
123         if len(parts) < 2:
124             raise PLCInvalidArgument, "Node hostname %s is invalid"%node['hostname']
125         return parts
126         
127     # plnode.txt content
128     def floppy_contents (self, node, renew_key):
129
130         if node['peer_id'] is not None:
131             raise PLCInvalidArgument, "Not a local node"
132
133         # If we are not an admin, make sure that the caller is a
134         # member of the site at which the node is located.
135         if 'admin' not in self.caller['roles']:
136             if node['site_id'] not in self.caller['site_ids']:
137                 raise PLCPermissionDenied, "Not allowed to generate a configuration file for %s"%node['hostname']
138
139         # Get node networks for this node
140         primary = None
141         nodenetworks = NodeNetworks(self.api, node['nodenetwork_ids'])
142         for nodenetwork in nodenetworks:
143             if nodenetwork['is_primary']:
144                 primary = nodenetwork
145                 break
146         if primary is None:
147             raise PLCInvalidArgument, "No primary network configured on %s"%node['hostname']
148
149         ( host, domain ) = self.split_hostname (node)
150
151         if renew_key:
152             # Generate 32 random bytes
153             bytes = random.sample(xrange(0, 256), 32)
154             # Base64 encode their string representation
155             node['key'] = base64.b64encode("".join(map(chr, bytes)))
156             # XXX Boot Manager cannot handle = in the key
157             node['key'] = node['key'].replace("=", "")
158             # Save it
159             node.sync()
160
161         # Generate node configuration file suitable for BootCD
162         file = ""
163
164         if renew_key:
165             file += 'NODE_ID="%d"\n' % node['node_id']
166             file += 'NODE_KEY="%s"\n' % node['key']
167
168         if primary['mac']:
169             file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
170
171         file += 'IP_METHOD="%s"\n' % primary['method']
172
173         if primary['method'] == 'static':
174             file += 'IP_ADDRESS="%s"\n' % primary['ip']
175             file += 'IP_GATEWAY="%s"\n' % primary['gateway']
176             file += 'IP_NETMASK="%s"\n' % primary['netmask']
177             file += 'IP_NETADDR="%s"\n' % primary['network']
178             file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
179             file += 'IP_DNS1="%s"\n' % primary['dns1']
180             file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
181
182         file += 'HOST_NAME="%s"\n' % host
183         file += 'DOMAIN_NAME="%s"\n' % domain
184
185         # define various nodenetwork settings attached to the primary nodenetwork
186         settings = NodeNetworkSettings (self.api, {'nodenetwork_id':nodenetwork['nodenetwork_id']})
187
188         categories = set()
189         for setting in settings:
190             if setting['category'] is not None:
191                 categories.add(setting['category'])
192         
193         for category in categories:
194             category_settings = NodeNetworkSettings(self.api,{'nodenetwork_id':nodenetwork['nodenetwork_id'],
195                                                               'category':category})
196             if category_settings:
197                 file += '### Category : %s\n'%category
198                 for setting in category_settings:
199                     file += '%s_%s="%s"\n'%(category.upper(),setting['name'].upper(),setting['value'])
200
201         for nodenetwork in nodenetworks:
202             if nodenetwork['method'] == 'ipmi':
203                 file += 'IPMI_ADDRESS="%s"\n' % nodenetwork['ip']
204                 if nodenetwork['mac']:
205                     file += 'IPMI_MAC="%s"\n' % nodenetwork['mac'].lower()
206                 break
207
208         return file
209
210     def bootcd_version (self):
211         try:
212             f = open (self.BOOTCDDIR + "/build/version.txt")
213             version=f.readline().strip()
214         finally:
215             f.close()
216         return version
217     
218     def cleantrash (self):
219         for file in self.trash:
220             if self.DEBUG:
221                 print 'DEBUG -- preserving',file
222             else:
223                 os.unlink(file)
224
225     def call(self, auth, node_id_or_hostname, action, filename, options = []):
226
227         self.trash=[]
228         ### check action
229         if action not in boot_medium_actions:
230             raise PLCInvalidArgument, "Unknown action %s"%action
231
232         ### compute file suffix and type
233         if action.find("-iso") >= 0 :
234             suffix=".iso"
235             type = "iso"
236         elif action.find("-usb") >= 0:
237             suffix=".usb"
238             type = "usb"
239         else:
240             suffix=".txt"
241             type = "txt"
242
243         # handle / caconicalize options
244         if type == "txt":
245             if options:
246                 raise PLCInvalidArgument, "Options are not supported for node configs"
247         else:
248             # create a dict for build.sh 
249             optdict={}
250             for option in options:
251                 if option == "cramfs":
252                     optdict['cramfs']=True
253                 elif option == 'partition':
254                     if type != "usb":
255                         raise PLCInvalidArgument, "option 'partition' is for USB images only"
256                     else:
257                         type="usb_partition"
258                 elif option == "serial":
259                     optdict['serial']='default'
260                 elif option.find("serial:") == 0:
261                     optdict['serial']=option.replace("serial:","")
262                 else:
263                     raise PLCInvalidArgument, "unknown option %s"%option
264
265         ### compute a 8 bytes random number
266         tempbytes = random.sample (xrange(0,256), 8);
267         def hexa2 (c):
268             return chr((c>>4)+65) + chr ((c&16)+65)
269         temp = "".join(map(hexa2,tempbytes))
270
271         ### check node if needed
272         if action.find("node-") == 0:
273             nodes = Nodes(self.api, [node_id_or_hostname])
274             if not nodes:
275                 raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
276             node = nodes[0]
277             nodename = node['hostname']
278             
279         else:
280             node = None
281             nodename = temp
282             
283         ### handle filename
284         filename = filename.replace ("%d",self.WORKDIR)
285         filename = filename.replace ("%n",nodename)
286         filename = filename.replace ("%s",suffix)
287         filename = filename.replace ("%p",self.api.config.PLC_NAME)
288         # only if filename contains "%v", bootcd is maybe not avail ?
289         if filename.find("%v") >=0:
290             filename = filename.replace ("%v",self.bootcd_version())
291
292         ### Check filename location
293         if filename != '':
294             if 'admin' not in self.caller['roles']:
295                 if ( filename.index(self.WORKDIR) != 0):
296                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
297
298             ### output should not exist (concurrent runs ..)
299             if os.path.exists(filename):
300                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
301
302             ### we can now safely create the file, 
303             ### either we are admin or under a controlled location
304             if not os.path.exists(os.path.dirname(filename)):
305                 try:
306                     os.makedirs (os.path.dirname(filename),0777)
307                 except:
308                     raise PLCPermissionDenied, "Could not create dir %s"%os.path.dirname(filename)
309
310         
311         ### generic media
312         if action == 'generic-iso' or action == 'generic-usb':
313             if options:
314                 raise PLCInvalidArgument, "Options are not supported for generic images"
315             # this raises an exception if bootcd is missing
316             version = self.bootcd_version()
317             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
318                                              version,
319                                              suffix)
320             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
321
322             if filename:
323                 ret=os.system ("cp %s %s"%(generic_path,filename))
324                 if ret==0:
325                     return filename
326                 else:
327                     raise PLCPermissionDenied, "Could not copy %s into"%(generic_path,filename)
328             else:
329                 ### return the generic medium content as-is, just base64 encoded
330                 return base64.b64encode(file(generic_path).read())
331
332         ### config file preview or regenerated
333         if action == 'node-preview' or action == 'node-floppy':
334             renew_key = (action == 'node-floppy')
335             floppy = self.floppy_contents (node,renew_key)
336             if filename:
337                 try:
338                     file(filename,'w').write(floppy)
339                 except:
340                     raise PLCPermissionDenied, "Could not write into %s"%filename
341                 return filename
342             else:
343                 return floppy
344
345         ### we're left with node-iso and node-usb
346         if action == 'node-iso' or action == 'node-usb':
347
348             ### check we've got required material
349             version = self.bootcd_version()
350             
351             if not os.path.isfile(self.BOOTCDBUILD):
352                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
353
354             # create the workdir if needed
355             if not os.path.isdir(self.WORKDIR):
356                 try:
357                     os.makedirs(self.WORKDIR,0777)
358                     os.chmod(self.WORKDIR,0777)
359                 except:
360                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
361             
362             try:
363                 # generate floppy config
364                 floppy_text = self.floppy_contents(node,True)
365                 # store it
366                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
367                 try:
368                     file(floppy_file,"w").write(floppy_text)
369                 except:
370                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
371
372                 self.trash.append(floppy_file)
373
374                 node_image = "%s/%s%s"%(self.WORKDIR,nodename,suffix)
375
376                 # make build's arguments
377                 serial_arg=""
378                 if "cramfs" in optdict: type += "_cramfs"
379                 if "serial" in optdict: serial_arg = "-s %s"%optdict['serial']
380                 log_file="%s.log"%node_image
381                 # invoke build.sh
382                 build_command = '%s -f "%s" -o "%s" -t "%s" %s &> %s' % (self.BOOTCDBUILD,
383                                                                          floppy_file,
384                                                                          node_image,
385                                                                          type,
386                                                                          serial_arg,
387                                                                          log_file)
388                 if self.DEBUG:
389                     print 'build command:',build_command
390                 ret=os.system(build_command)
391                 if ret != 0:
392                     raise PLCAPIError,"bootcd/build.sh failed\n%s\n%s"%(
393                         build_command,file(log_file).read())
394
395                 self.trash.append(log_file)
396                 if not os.path.isfile (node_image):
397                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
398             
399                 # handle result
400                 if filename:
401                     ret=os.system("mv %s %s"%(node_image,filename))
402                     if ret != 0:
403                         self.trash.append(node_image)
404                         self.cleantrash()
405                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
406                     self.cleantrash()
407                     return filename
408                 else:
409                     result = file(node_image).read()
410                     self.trash.append(node_image)
411                     self.cleantrash()
412                     return base64.b64encode(result)
413             except:
414                 self.cleantrash()
415                 raise
416                 
417         # we're done here, or we missed something
418         raise PLCAPIError,'Unhandled action %s'%action
419