7 from PLC.Faults import *
8 from PLC.Method import Method
9 from PLC.Parameter import Parameter, Mixed
10 from PLC.Auth import Auth
12 from PLC.Nodes import Node, Nodes
13 from PLC.Interfaces import Interface, Interfaces
14 from PLC.InterfaceTags import InterfaceTag, InterfaceTags
15 from PLC.NodeTags import NodeTag, NodeTags
17 from PLC.Logger import logger
19 from PLC.Accessors.Accessors_standard import * # import node accessors
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
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("=", "")
51 class GetBootMedium(Method):
53 This method is a redesign based on former, supposedly dedicated,
54 AdmGenerateNodeConfFile
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
67 action is expected among the following string constants according the
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'.
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.
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
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)
100 - %f : the nodefamily
102 With the file-based return mechanism, the method returns the full pathname
105 It is the caller's responsability to remove this file after use.
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
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 - 'systemd-debug' - turn on systemd debug in bootcd
124 Tags: the following tags are taken into account when attached to the node:
125 'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck', 'systemd-debug'
128 - Non-admins can only generate files for nodes at their sites.
129 - Non-admins, when they provide a filename, *must* specify it in the %d area
132 Whenever needed, the method stores intermediate files in a
133 private area, typically not located under the web server's
134 accessible area, and are cleaned up by the method.
138 roles = ['admin', 'pi', 'tech']
142 Mixed(Node.fields['node_id'],
143 Node.fields['hostname']),
144 Parameter (str, "Action mode, expected value depends of the type of node"),
145 Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
146 Parameter ([str], "Options"),
149 returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
151 # define globals for regular nodes, override later for other types
152 BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
153 BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
154 GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
155 WORKDIR = "/var/tmp/bootmedium"
156 LOGDIR = "/var/tmp/bootmedium/logs/"
158 # uncomment this to preserve temporary area and bootcustom logs
161 ### returns (host, domain) :
162 # 'host' : host part of the hostname
163 # 'domain' : domain part of the hostname
164 def split_hostname (self, node):
165 # Split hostname into host and domain parts
166 parts = node['hostname'].split(".", 1)
168 raise PLCInvalidArgument("Node hostname {} is invalid".format(node['hostname']))
171 # Generate the node (plnode.txt) configuration content.
173 # This function will create the configuration file a node
175 # - a common part, regardless of the 'node_type' tag
176 # - XXX a special part, depending on the 'node_type' tag value.
177 def floppy_contents (self, node, renew_key):
180 if node['peer_id'] is not None:
181 raise PLCInvalidArgument("Not a local node {}".format(node['hostname']))
183 # If we are not an admin, make sure that the caller is a
184 # member of the site at which the node is located.
185 if 'admin' not in self.caller['roles']:
186 if node['site_id'] not in self.caller['site_ids']:
187 raise PLCPermissionDenied(
188 "Not allowed to generate a configuration file for {}"\
189 .format(node['hostname']))
191 # Get interface for this node
193 interfaces = Interfaces(self.api, node['interface_ids'])
194 for interface in interfaces:
195 if interface['is_primary']:
199 raise PLCInvalidArgument(
200 "No primary network configured on {}".format(node['hostname']))
202 host, domain = self.split_hostname (node)
204 # renew the key and save it on the database
206 node['key'] = compute_key()
207 node.update_last_download(commit=False)
210 # Generate node configuration file suitable for BootCD
214 file += 'NODE_ID="{}"\n'.format(node['node_id'])
215 file += 'NODE_KEY="{}"\n'.format(node['key'])
216 # not used anywhere, just a note for operations people
217 file += 'KEY_RENEWAL_DATE="{}"\n'\
218 .format(time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime()))
221 file += 'NET_DEVICE="{}"\n'.format(primary['mac'].lower())
223 file += 'IP_METHOD="{}"\n'.format(primary['method'])
225 if primary['method'] == 'static':
226 file += 'IP_ADDRESS="{}"\n'.format(primary['ip'])
227 file += 'IP_GATEWAY="{}"\n'.format(primary['gateway'])
228 file += 'IP_NETMASK="{}"\n'.format(primary['netmask'])
229 file += 'IP_NETADDR="{}"\n'.format(primary['network'])
230 file += 'IP_BROADCASTADDR="{}"\n'.format(primary['broadcast'])
231 file += 'IP_DNS1="{}"\n'.format(primary['dns1'])
232 file += 'IP_DNS2="{}"\n'.format(primary['dns2'] or "")
234 file += 'HOST_NAME="{}"\n'.format(host)
235 file += 'DOMAIN_NAME="{}"\n'.format(domain)
237 # define various interface settings attached to the primary interface
238 settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
241 for setting in settings:
242 if setting['category'] is not None:
243 categories.add(setting['category'])
245 for category in categories:
246 category_settings = InterfaceTags(self.api,{'interface_id' : interface['interface_id'],
247 'category' : category})
248 if category_settings:
249 file += '### Category : {}\n'.format(category)
250 for setting in category_settings:
251 file += '{}_{}="{}"\n'\
252 .format(category.upper(), setting['tagname'].upper(), setting['value'])
254 for interface in interfaces:
255 if interface['method'] == 'ipmi':
256 file += 'IPMI_ADDRESS="{}"\n'.format(interface['ip'])
258 file += 'IPMI_MAC="{}"\n'.format(interface['mac'].lower())
263 # see also GetNodeFlavour that does similar things
264 def get_nodefamily (self, node, auth):
265 pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
266 fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
267 arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
269 return (pldistro,fcdistro,arch)
271 node_id = node['node_id']
273 # no support for deployment-based BootCD's, use kvariants instead
274 node_pldistro = GetNodePldistro (self.api,self.caller).call(auth, node_id)
276 pldistro = node_pldistro
278 node_fcdistro = GetNodeFcdistro (self.api,self.caller).call(auth, node_id)
280 fcdistro = node_fcdistro
282 node_arch = GetNodeArch (self.api,self.caller).call(auth,node_id)
286 return (pldistro,fcdistro,arch)
288 def bootcd_version (self):
290 return file(self.BOOTCDDIR + "/build/version.txt").readline().strip()
292 raise Exception("Unknown boot cd version - probably wrong bootcd dir : {}"\
293 .format(self.BOOTCDDIR))
295 def cleantrash (self):
296 for file in self.trash:
298 logger.debug('DEBUG -- preserving trash file {}'.format(file))
303 # build the filename string
304 # check for permissions and concurrency
305 # returns the filename
306 def handle_filename (self, filename, nodename, suffix, arch):
307 # allow to set filename to None or any other empty value
308 if not filename: filename=''
309 filename = filename.replace ("%d",self.WORKDIR)
310 filename = filename.replace ("%n",nodename)
311 filename = filename.replace ("%s",suffix)
312 filename = filename.replace ("%p",self.api.config.PLC_NAME)
314 try: filename = filename.replace ("%f", self.nodefamily)
316 try: filename = filename.replace ("%a", arch)
318 try: filename = filename.replace ("%v",self.bootcd_version())
321 ### Check filename location
323 if 'admin' not in self.caller['roles']:
324 if ( filename.index(self.WORKDIR) != 0):
325 raise PLCInvalidArgument("File {} not under {}".format(filename, self.WORKDIR))
327 ### output should not exist (concurrent runs ..)
328 # numerous reports of issues with this policy
329 # looks like people sometime suspend/cancel their download
330 # and this leads to the old file sitting in there forever
331 # so, if the file is older than 5 minutes, we just trash
333 if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
335 if os.path.exists(filename):
336 raise PLCInvalidArgument(
337 "Resulting file {} already exists - please try again in {} minutes"\
338 .format(filename, grace))
340 ### we can now safely create the file,
341 ### either we are admin or under a controlled location
342 filedir=os.path.dirname(filename)
343 # dirname does not return "." for a local filename like its shell counterpart
345 if not os.path.exists(filedir):
347 os.makedirs (filedir,0777)
349 raise PLCPermissionDenied("Could not create dir {}".format(filedir))
353 def build_command(self, nodename, node_type, build_sh_spec, node_image, type, floppy_file):
356 (*) build command to be run
357 (*) location of the log_file
362 # regular node, make build's arguments
363 # and build the full command line to be called
364 if node_type not in [ 'regular', 'reservable' ]:
365 logger.error("GetBootMedium.build_command: unexpected node_type {}".format(node_type))
369 if "cramfs" in build_sh_spec:
371 if "serial" in build_sh_spec:
372 build_sh_options += " -s {}".format(build_sh_spec['serial'])
373 if "variant" in build_sh_spec:
374 build_sh_options += " -V {}".format(build_sh_spec['variant'])
376 for karg in build_sh_spec['kargs']:
377 build_sh_options += ' -k "{}"'.format(karg)
380 date = time.strftime('%Y-%m-%d-%H-%M', time.gmtime())
381 if not os.path.isdir(self.LOGDIR):
382 os.makedirs(self.LOGDIR)
383 log_file = "{}/{}-{}.log".format(self.LOGDIR, date, nodename)
385 command = '{} -f "{}" -o "{}" -t "{}" {} > {} 2>&1'\
386 .format(self.BOOTCDBUILD,
393 logger.info("The build command line is {}".format(command))
395 return command, log_file
397 def call(self, auth, node_id_or_hostname, action, filename, options = []):
401 ### compute file suffix and type
402 if action.find("-iso") >= 0 :
405 elif action.find("-usb") >= 0:
412 # check for node existence and get node_type
413 nodes = Nodes(self.api, [node_id_or_hostname])
415 raise PLCInvalidArgument("No such node {}".format(node_id_or_hostname))
418 logger.info("GetBootMedium: {} requested on node {}. Node type is: {}"\
419 .format(action, node['node_id'], node['node_type']))
421 # check the required action against the node type
422 node_type = node['node_type']
423 if action not in allowed_actions[node_type]:
424 raise PLCInvalidArgument("Action {} not valid for {} nodes, valid actions are {}"\
425 .format(action, node_type, "|".join(allowed_actions[node_type])))
427 # handle / canonicalize options
430 raise PLCInvalidArgument("Options are not supported for node configs")
432 # create a dict for build.sh
433 build_sh_spec={'kargs':[]}
434 # use node tags as defaults
435 # check for node tag equivalents
436 tags = NodeTags(self.api,
437 {'node_id': node['node_id'],
438 'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
439 'no-hangcheck', 'systemd-debug' ]},
440 ['tagname', 'value'])
443 if tag['tagname'] == 'serial':
444 build_sh_spec['serial'] = tag['value']
445 elif tag['tagname'] == 'cramfs':
446 build_sh_spec['cramfs'] = True
447 elif tag['tagname'] == 'kvariant':
448 build_sh_spec['variant'] = tag['value']
449 elif tag['tagname'] == 'kargs':
450 build_sh_spec['kargs'] += tag['value'].split()
451 elif tag['tagname'] == 'no-hangcheck':
452 build_sh_spec['kargs'].append('hcheck_reboot0')
453 elif tag['tagname'] == 'systemd-debug':
454 build_sh_spec['kargs'].append('systemd.log_level=debug')
455 build_sh_spec['kargs'].append('systemd.log_target=console')
456 # then options can override tags
457 for option in options:
458 if option == "cramfs":
459 build_sh_spec['cramfs']=True
460 elif option == 'partition':
462 raise PLCInvalidArgument("option 'partition' is for USB images only")
465 elif option == "serial":
466 build_sh_spec['serial']='default'
467 elif option.find("serial:") == 0:
468 build_sh_spec['serial']=option.replace("serial:","")
469 elif option.find("variant:") == 0:
470 build_sh_spec['variant']=option.replace("variant:","")
471 elif option == "no-hangcheck":
472 build_sh_spec['kargs'].append('hcheck_reboot0')
473 elif option == "systemd-debug":
474 build_sh_spec['kargs'].append('systemd.log_level=debug')
476 raise PLCInvalidArgument("unknown option {}".format(option))
478 # compute nodename according the action
479 if action.find("node-") == 0:
480 nodename = node['hostname']
483 # compute a 8 bytes random number
484 tempbytes = random.sample (xrange(0,256), 8);
485 def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
486 nodename = "".join(map(hexa2,tempbytes))
489 (pldistro,fcdistro,arch) = self.get_nodefamily(node, auth)
490 self.nodefamily="{}-{}-{}".format(pldistro, fcdistro, arch)
493 for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
494 setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
496 filename = self.handle_filename(filename, nodename, suffix, arch)
500 self.message='GetBootMedium on node {} - action={}'.format(nodename, action)
501 self.event_objects={'Node': [ node ['node_id'] ]}
503 self.message='GetBootMedium - generic - action={}'.format(action)
506 if action == 'generic-iso' or action == 'generic-usb':
508 raise PLCInvalidArgument("Options are not supported for generic images")
509 # this raises an exception if bootcd is missing
510 version = self.bootcd_version()
511 generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
512 generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
515 ret=os.system ('cp "{}" "{}"'.format(generic_path, filename))
519 raise PLCPermissionDenied("Could not copy {} into {}"\
520 .format(generic_path, filename))
522 ### return the generic medium content as-is, just base64 encoded
523 return base64.b64encode(file(generic_path).read())
525 ### config file preview or regenerated
526 if action == 'node-preview' or action == 'node-floppy':
527 renew_key = (action == 'node-floppy')
528 floppy = self.floppy_contents (node,renew_key)
531 file(filename,'w').write(floppy)
533 raise PLCPermissionDenied("Could not write into {}".format(filename))
538 ### we're left with node-iso and node-usb
539 # the steps involved in the image creation are:
540 # - create and test the working environment
541 # - generate the configuration file
542 # - build and invoke the build command
543 # - delivery the resulting image file
545 if action == 'node-iso' or action == 'node-usb':
547 ### check we've got required material
548 version = self.bootcd_version()
550 if not os.path.isfile(self.BOOTCDBUILD):
551 raise PLCAPIError("Cannot locate bootcd/build.sh script {}".format(self.BOOTCDBUILD))
553 # create the workdir if needed
554 if not os.path.isdir(self.WORKDIR):
556 os.makedirs(self.WORKDIR,0777)
557 os.chmod(self.WORKDIR,0777)
559 raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
562 # generate floppy config
563 floppy_text = self.floppy_contents(node, True)
565 floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
567 file(floppy_file,"w").write(floppy_text)
569 raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
571 self.trash.append(floppy_file)
573 node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
575 command, log_file = self.build_command(nodename, node_type, build_sh_spec,
576 node_image, type, floppy_file)
578 # invoke the image build script
580 ret = os.system(command)
583 raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
584 .format(self.BOOTCDBUILD, command, log_file))
586 if not os.path.isfile (node_image):
587 raise PLCAPIError("Unexpected location of build.sh output - {}".format(node_image))
591 ret = os.system('mv "{}" "{}"'.format(node_image, filename))
593 self.trash.append(node_image)
595 raise PLCAPIError("Could not move node image {} into {}"\
596 .format(node_image, filename))
600 result = file(node_image).read()
601 self.trash.append(node_image)
603 logger.info("GetBootMedium - done with build.sh")
604 encoded_result = base64.b64encode(result)
605 logger.info("GetBootMedium - done with base64 encoding - lengths: raw={} - b64={}"
606 .format(len(result), len(encoded_result)))
607 return encoded_result
612 # we're done here, or we missed something
613 raise PLCAPIError('Unhandled action {}'.format(action))