- now that bootcustom.sh has gone, uses a much simpler tree structure
[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. Currently supported are
75         - 'serial'
76         - 'cramfs'
77         - 'console:<console_spec>'
78         console_spec is passed as-is to bootcd/build.sh
79         it is expected to be a colon separated string denoting
80         tty - baudrate - parity - bits
81         e.g. ttyS0:115200:n:8
82
83     Security:
84         - Non-admins can only generate files for nodes at their sites.
85         - Non-admins, when they provide a filename, *must* specify it in the %d area
86
87    Housekeeping: 
88         Whenever needed, the method stores intermediate files in a
89         private area, typically not located under the web server's
90         accessible area, and are cleaned up by the method.
91
92     """
93
94     roles = ['admin', 'pi', 'tech']
95
96     accepts = [
97         Auth(),
98         Mixed(Node.fields['node_id'],
99               Node.fields['hostname']),
100         Parameter (str, "Action mode, expected in " + "|".join(boot_medium_actions)),
101         Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
102         Parameter ([str], "Options"),
103         ]
104
105     returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
106
107     BOOTCDDIR = "/usr/share/bootcd/"
108     BOOTCDBUILD = "/usr/share/bootcd/build.sh"
109     GENERICDIR = "/var/www/html/download/"
110     WORKDIR = "/var/tmp/bootmedium"
111     DEBUG = False
112     # uncomment this to preserve temporary area and bootcustom logs
113     #DEBUG = True
114
115     ### returns (host, domain) :
116     # 'host' : host part of the hostname
117     # 'domain' : domain part of the hostname
118     def split_hostname (self, node):
119         # Split hostname into host and domain parts
120         parts = node['hostname'].split(".", 1)
121         if len(parts) < 2:
122             raise PLCInvalidArgument, "Node hostname %s is invalid"%node['hostname']
123         return parts
124         
125     # plnode.txt content
126     def floppy_contents (self, node, renew_key):
127
128         if node['peer_id'] is not None:
129             raise PLCInvalidArgument, "Not a local node"
130
131         # If we are not an admin, make sure that the caller is a
132         # member of the site at which the node is located.
133         if 'admin' not in self.caller['roles']:
134             if node['site_id'] not in self.caller['site_ids']:
135                 raise PLCPermissionDenied, "Not allowed to generate a configuration file for %s"%node['hostname']
136
137         # Get node networks for this node
138         primary = None
139         nodenetworks = NodeNetworks(self.api, node['nodenetwork_ids'])
140         for nodenetwork in nodenetworks:
141             if nodenetwork['is_primary']:
142                 primary = nodenetwork
143                 break
144         if primary is None:
145             raise PLCInvalidArgument, "No primary network configured on %s"%node['hostname']
146
147         ( host, domain ) = self.split_hostname (node)
148
149         if renew_key:
150             # Generate 32 random bytes
151             bytes = random.sample(xrange(0, 256), 32)
152             # Base64 encode their string representation
153             node['key'] = base64.b64encode("".join(map(chr, bytes)))
154             # XXX Boot Manager cannot handle = in the key
155             node['key'] = node['key'].replace("=", "")
156             # Save it
157             node.sync()
158
159         # Generate node configuration file suitable for BootCD
160         file = ""
161
162         if renew_key:
163             file += 'NODE_ID="%d"\n' % node['node_id']
164             file += 'NODE_KEY="%s"\n' % node['key']
165
166         if primary['mac']:
167             file += 'NET_DEVICE="%s"\n' % primary['mac'].lower()
168
169         file += 'IP_METHOD="%s"\n' % primary['method']
170
171         if primary['method'] == 'static':
172             file += 'IP_ADDRESS="%s"\n' % primary['ip']
173             file += 'IP_GATEWAY="%s"\n' % primary['gateway']
174             file += 'IP_NETMASK="%s"\n' % primary['netmask']
175             file += 'IP_NETADDR="%s"\n' % primary['network']
176             file += 'IP_BROADCASTADDR="%s"\n' % primary['broadcast']
177             file += 'IP_DNS1="%s"\n' % primary['dns1']
178             file += 'IP_DNS2="%s"\n' % (primary['dns2'] or "")
179
180         file += 'HOST_NAME="%s"\n' % host
181         file += 'DOMAIN_NAME="%s"\n' % domain
182
183         # define various nodenetwork settings attached to the primary nodenetwork
184         settings = NodeNetworkSettings (self.api, {'nodenetwork_id':nodenetwork['nodenetwork_id']})
185
186         categories = set()
187         for setting in settings:
188             if setting['category'] is not None:
189                 categories.add(setting['category'])
190         
191         for category in categories:
192             category_settings = NodeNetworkSettings(self.api,{'nodenetwork_id':nodenetwork['nodenetwork_id'],
193                                                               'category':category})
194             if category_settings:
195                 file += '### Category : %s\n'%category
196                 for setting in category_settings:
197                     file += '%s_%s="%s"\n'%(category.upper(),setting['name'].upper(),setting['value'])
198
199         for nodenetwork in nodenetworks:
200             if nodenetwork['method'] == 'ipmi':
201                 file += 'IPMI_ADDRESS="%s"\n' % nodenetwork['ip']
202                 if nodenetwork['mac']:
203                     file += 'IPMI_MAC="%s"\n' % nodenetwork['mac'].lower()
204                 break
205
206         return file
207
208     def bootcd_version (self):
209         try:
210             f = open (self.BOOTCDDIR + "/build/version.txt")
211             version=f.readline().strip()
212         finally:
213             f.close()
214         return version
215     
216     def cleantrash (self):
217         for file in self.trash:
218             if self.DEBUG:
219                 print 'DEBUG -- preserving',file
220             else:
221                 os.unlink(file)
222
223     def call(self, auth, node_id_or_hostname, action, filename, options = []):
224
225         self.trash=[]
226         ### check action
227         if action not in boot_medium_actions:
228             raise PLCInvalidArgument, "Unknown action %s"%action
229
230         ### compute file suffix and type
231         if action.find("-iso") >= 0 :
232             suffix=".iso"
233             type = ["iso"]
234         elif action.find("-usb") >= 0:
235             suffix=".usb"
236             type = ["usb"]
237         else:
238             suffix=".txt"
239             type = ["txt"]
240
241         if "txt" not in type:
242             if 'serial' in options:
243                 suffix = "-serial" + suffix
244                 type.insert(1, "serial")
245             if 'cramfs' in options:
246                 suffix = "-cramfs" + suffix
247                 # XXX must be the same index as above
248                 type.insert(1, "cramfs")
249         type = "_".join(type)
250
251         ### compute a 8 bytes random number
252         tempbytes = random.sample (xrange(0,256), 8);
253         def hexa2 (c):
254             return chr((c>>4)+65) + chr ((c&16)+65)
255         temp = "".join(map(hexa2,tempbytes))
256
257         ### check node if needed
258         if action.find("node-") == 0:
259             nodes = Nodes(self.api, [node_id_or_hostname])
260             if not nodes:
261                 raise PLCInvalidArgument, "No such node %r"%node_id_or_hostname
262             node = nodes[0]
263             nodename = node['hostname']
264             
265         else:
266             node = None
267             nodename = temp
268             
269         ### handle filename
270         filename = filename.replace ("%d",self.WORKDIR)
271         filename = filename.replace ("%n",nodename)
272         filename = filename.replace ("%s",suffix)
273         filename = filename.replace ("%p",self.api.config.PLC_NAME)
274         # only if filename contains "%v", bootcd is maybe not avail ?
275         if filename.find("%v") >=0:
276             filename = filename.replace ("%v",self.bootcd_version())
277
278         ### Check filename location
279         if filename != '':
280             if 'admin' not in self.caller['roles']:
281                 if ( filename.index(self.WORKDIR) != 0):
282                     raise PLCInvalidArgument, "File %s not under %s"%(filename,self.WORKDIR)
283
284             ### output should not exist (concurrent runs ..)
285             if os.path.exists(filename):
286                 raise PLCInvalidArgument, "Resulting file %s already exists"%filename
287
288             ### we can now safely create the file, 
289             ### either we are admin or under a controlled location
290             if not os.path.exists(os.path.dirname(filename)):
291                 try:
292                     os.makedirs (os.path.dirname(filename),0777)
293                 except:
294                     raise PLCPermissionDenied, "Could not create dir %s"%os.path.dirname(filename)
295
296         
297         ### generic media
298         if action == 'generic-iso' or action == 'generic-usb':
299             # this raises an exception if bootcd is missing
300             version = self.bootcd_version()
301             generic_name = "%s-BootCD-%s%s"%(self.api.config.PLC_NAME,
302                                              version,
303                                              suffix)
304             generic_path = "%s/%s" % (self.GENERICDIR,generic_name)
305
306             if filename:
307                 ret=os.system ("cp %s %s"%(generic_path,filename))
308                 if ret==0:
309                     return filename
310                 else:
311                     raise PLCPermissionDenied, "Could not copy %s into"%(generic_path,filename)
312             else:
313                 ### return the generic medium content as-is, just base64 encoded
314                 return base64.b64encode(file(generic_path).read())
315
316         ### config file preview or regenerated
317         if action == 'node-preview' or action == 'node-floppy':
318             renew_key = (action == 'node-floppy')
319             floppy = self.floppy_contents (node,renew_key)
320             if filename:
321                 try:
322                     file(filename,'w').write(floppy)
323                 except:
324                     raise PLCPermissionDenied, "Could not write into %s"%filename
325                 return filename
326             else:
327                 return floppy
328
329         ### we're left with node-iso and node-usb
330         if action == 'node-iso' or action == 'node-usb':
331
332             ### check we've got required material
333             version = self.bootcd_version()
334             
335             if not os.path.isfile(self.BOOTCDBUILD):
336                 raise PLCAPIError, "Cannot locate bootcd/build.sh script %s"%self.BOOTCDBUILD
337
338             # create the workdir if needed
339             if not os.path.isdir(self.WORKDIR):
340                 try:
341                     os.makedirs(self.WORKDIR,0777)
342                     os.chmod(self.WORKDIR,0777)
343                 except:
344                     raise PLCPermissionDenied, "Could not create dir %s"%self.WORKDIR
345             
346             try:
347                 # generate floppy config
348                 floppy_text = self.floppy_contents(node,True)
349                 # store it
350                 floppy_file = "%s/%s.txt"%(self.WORKDIR,nodename)
351                 try:
352                     file(floppy_file,"w").write(floppy_text)
353                 except:
354                     raise PLCPermissionDenied, "Could not write into %s"%floppy_file
355
356                 self.trash.append(floppy_file)
357
358                 node_image = "%s/%s"%(self.WORKDIR,nodename)
359
360                 # handle console
361                 serial_arg=""
362                 for option in options:
363                     console=option.replace("console:","")
364                     if option != console:
365                         serial_arg="-s %s"%console
366                 # invoke build.sh
367                 build_command = '%s -f "%s" -O "%s" -t "%s" %s &> %s.log' % (self.BOOTCDBUILD,
368                                                                              floppy_file,
369                                                                              node_image,
370                                                                              type,
371                                                                              serial_arg,
372                                                                              node_image)
373                 if self.DEBUG:
374                     print 'build command:',build_command
375                 ret=os.system(build_command)
376                 if ret != 0:
377                     raise PLCPermissionDenied,"build.sh failed to create node-specific medium"
378
379                 self.trash.append("%s.log"%node_image)
380                 node_image += suffix
381                 if not os.path.isfile (node_image):
382                     raise PLCAPIError,"Unexpected location of build.sh output - %s"%node_image
383             
384                 # handle result
385                 if filename:
386                     ret=os.system("mv %s %s"%(node_image,filename))
387                     if ret != 0:
388                         self.trash.append(node_image)
389                         self.cleantrash()
390                         raise PLCAPIError, "Could not move node image %s into %s"%(node_image,filename)
391                     self.cleantrash()
392                     return filename
393                 else:
394                     result = file(node_image).read()
395                     self.trash.append(node_image)
396                     self.cleantrash()
397                     return base64.b64encode(result)
398             except:
399                 self.cleantrash()
400                 raise
401                 
402         # we're done here, or we missed something
403         raise PLCAPIError,'Unhandled action %s'%action
404