1 # pylint: disable=c0111, c0103
9 from PLC.Faults import *
10 from PLC.Method import Method
11 from PLC.Parameter import Parameter, Mixed
12 from PLC.Auth import Auth
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
19 from PLC.Logger import logger
21 from PLC.Accessors.Accessors_standard import * # node accessors
23 # could not define this in the class..
24 # create a dict with the allowed actions for each type of node
25 # reservable nodes being more recent, we do not support the floppy stuff anymore
44 # Generate 32 random bytes
45 bytes = random.sample(range(0, 256), 32)
46 # Base64 encode their string representation
47 key = base64.b64encode("".join(map(chr, bytes)))
48 # Boot Manager cannot handle = in the key
49 # XXX this sounds wrong, as it might prevent proper decoding
50 key = key.replace("=", "")
53 class GetBootMedium(Method):
55 This method is a redesign based on former, supposedly dedicated,
56 AdmGenerateNodeConfFile
58 As compared with its ancestor, this method provides a much more
59 detailed interface, that allows to
60 (*) either just preview the node config file -- in which case
61 the node key is NOT recomputed, and NOT provided in the output
62 (*) or regenerate the node config file for storage on a floppy
63 that is, exactly what the ancestor method used todo,
64 including renewing the node's key
65 (*) or regenerate the config file and bundle it inside an ISO or USB image
66 (*) or just provide the generic ISO or USB boot images
67 in which case of course the node_id_or_hostname parameter is not used
69 action is expected among the following string constants according the
80 Apart for the preview mode, this method generates a new node key for the
81 specified node, effectively invalidating any old boot medium.
82 Note that 'reservable' nodes do not support 'node-floppy',
83 'generic-iso' nor 'generic-usb'.
85 In addition, two return mechanisms are supported.
86 (*) The default behaviour is that the file's content is returned as a
87 base64-encoded string. This is how the ancestor method used to work.
88 To use this method, pass an empty string as the file parameter.
90 (*) Or, for efficiency -- this makes sense only when the API is used
91 by the web pages that run on the same host -- the caller may provide
92 a filename, in which case the resulting file is stored in that location instead.
93 The filename argument can use the following markers, that are expanded
95 - %d : default root dir (some builtin dedicated area under /var/tmp/)
96 Using this is recommended, and enforced for non-admin users
97 - %n : the node's name when this makes sense, or a mktemp-like name when
98 generic media is requested
99 - %s : a file suffix appropriate in the context (.txt, .iso or the like)
100 - %v : the bootcd version string (e.g. 4.0)
102 - %f : the nodefamily
104 With the file-based return mechanism, the method returns the full pathname
107 It is the caller's responsability to remove this file after use.
109 Options: an optional array of keywords.
110 options are not supported for generic images
111 Currently supported are
112 - 'partition' - for USB actions only
114 - 'serial' or 'serial:<console_spec>'
115 console_spec (or 'default') is passed as-is to bootcd/build.sh
116 it is expected to be a colon separated string denoting
117 tty - baudrate - parity - bits
118 e.g. ttyS0:115200:n:8
119 - 'variant:<variantname>'
120 passed to build.sh as -V <variant>
121 variants are used to run a different kernel on the bootCD
122 see kvariant.sh for how to create a variant
123 - 'no-hangcheck' - disable hangcheck
124 - 'systemd-debug' - turn on systemd debug in bootcd
126 Tags: the following tags are taken into account when attached to the node:
127 'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck', 'systemd-debug'
130 - Non-admins can only generate files for nodes at their sites.
131 - Non-admins, when they provide a filename, *must* specify it in the %d area
134 Whenever needed, the method stores intermediate files in a
135 private area, typically not located under the web server's
136 accessible area, and are cleaned up by the method.
140 roles = ['admin', 'pi', 'tech']
144 Mixed(Node.fields['node_id'],
145 Node.fields['hostname']),
146 Parameter (str, "Action mode, expected value depends of the type of node"),
147 Parameter (str, "Empty string for verbatim result, resulting file full path otherwise"),
148 Parameter ([str], "Options"),
151 returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
153 # define globals for regular nodes, override later for other types
154 BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
155 BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
156 GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
157 WORKDIR = "/var/tmp/bootmedium"
158 LOGDIR = "/var/tmp/bootmedium/logs/"
160 # uncomment this to preserve temporary area and bootcustom logs
163 ### returns (host, domain) :
164 # 'host' : host part of the hostname
165 # 'domain' : domain part of the hostname
166 def split_hostname (self, node):
167 # Split hostname into host and domain parts
168 parts = node['hostname'].split(".", 1)
170 raise PLCInvalidArgument("Node hostname {} is invalid".format(node['hostname']))
173 # Generate the node (plnode.txt) configuration content.
175 # This function will create the configuration file a node
177 # - a common part, regardless of the 'node_type' tag
178 # - XXX a special part, depending on the 'node_type' tag value.
179 def floppy_contents (self, node, renew_key):
182 if node['peer_id'] is not None:
183 raise PLCInvalidArgument("Not a local node {}".format(node['hostname']))
185 # If we are not an admin, make sure that the caller is a
186 # member of the site at which the node is located.
187 if 'admin' not in self.caller['roles']:
188 if node['site_id'] not in self.caller['site_ids']:
189 raise PLCPermissionDenied(
190 "Not allowed to generate a configuration file for {}"\
191 .format(node['hostname']))
193 # Get interface for this node
195 interfaces = Interfaces(self.api, node['interface_ids'])
196 for interface in interfaces:
197 if interface['is_primary']:
201 raise PLCInvalidArgument(
202 "No primary network configured on {}".format(node['hostname']))
204 host, domain = self.split_hostname (node)
206 # renew the key and save it on the database
208 node['key'] = compute_key()
209 node.update_last_download(commit=False)
212 # Generate node configuration file suitable for BootCD
216 file += 'NODE_ID="{}"\n'.format(node['node_id'])
217 file += 'NODE_KEY="{}"\n'.format(node['key'])
218 # not used anywhere, just a note for operations people
219 file += 'KEY_RENEWAL_DATE="{}"\n'\
220 .format(time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime()))
223 file += 'NET_DEVICE="{}"\n'.format(primary['mac'].lower())
225 file += 'IP_METHOD="{}"\n'.format(primary['method'])
227 if primary['method'] == 'static':
228 file += 'IP_ADDRESS="{}"\n'.format(primary['ip'])
229 file += 'IP_GATEWAY="{}"\n'.format(primary['gateway'])
230 file += 'IP_NETMASK="{}"\n'.format(primary['netmask'])
231 file += 'IP_NETADDR="{}"\n'.format(primary['network'])
232 file += 'IP_BROADCASTADDR="{}"\n'.format(primary['broadcast'])
233 file += 'IP_DNS1="{}"\n'.format(primary['dns1'])
234 file += 'IP_DNS2="{}"\n'.format(primary['dns2'] or "")
236 file += 'HOST_NAME="{}"\n'.format(host)
237 file += 'DOMAIN_NAME="{}"\n'.format(domain)
239 # define various interface settings attached to the primary interface
240 settings = InterfaceTags (self.api, {'interface_id':interface['interface_id']})
243 for setting in settings:
244 if setting['category'] is not None:
245 categories.add(setting['category'])
247 for category in categories:
248 category_settings = InterfaceTags(self.api,{'interface_id' : interface['interface_id'],
249 'category' : category})
250 if category_settings:
251 file += '### Category : {}\n'.format(category)
252 for setting in category_settings:
253 file += '{}_{}="{}"\n'\
254 .format(category.upper(), setting['tagname'].upper(), setting['value'])
256 for interface in interfaces:
257 if interface['method'] == 'ipmi':
258 file += 'IPMI_ADDRESS="{}"\n'.format(interface['ip'])
260 file += 'IPMI_MAC="{}"\n'.format(interface['mac'].lower())
265 # see also GetNodeFlavour that does similar things
266 def get_nodefamily (self, node, auth):
267 pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
268 fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
269 arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
271 return (pldistro,fcdistro,arch)
273 node_id = node['node_id']
275 # no support for deployment-based BootCD's, use kvariants instead
276 node_pldistro = GetNodePldistro (self.api,self.caller).call(auth, node_id)
278 pldistro = node_pldistro
280 node_fcdistro = GetNodeFcdistro (self.api,self.caller).call(auth, node_id)
282 fcdistro = node_fcdistro
284 node_arch = GetNodeArch (self.api,self.caller).call(auth,node_id)
288 return (pldistro,fcdistro,arch)
290 def bootcd_version (self):
292 with open(self.BOOTCDDIR + "/build/version.txt") as feed:
293 return feed.readline().strip()
295 raise Exception("Unknown boot cd version - probably wrong bootcd dir : {}"\
296 .format(self.BOOTCDDIR))
298 def cleantrash (self):
299 for file in self.trash:
301 logger.debug('DEBUG -- preserving trash file {}'.format(file))
306 # build the filename string
307 # check for permissions and concurrency
308 # returns the filename
309 def handle_filename (self, filename, nodename, suffix, arch):
310 # allow to set filename to None or any other empty value
311 if not filename: filename=''
312 filename = filename.replace ("%d",self.WORKDIR)
313 filename = filename.replace ("%n",nodename)
314 filename = filename.replace ("%s",suffix)
315 filename = filename.replace ("%p",self.api.config.PLC_NAME)
317 try: filename = filename.replace ("%f", self.nodefamily)
319 try: filename = filename.replace ("%a", arch)
321 try: filename = filename.replace ("%v",self.bootcd_version())
324 ### Check filename location
326 if 'admin' not in self.caller['roles']:
327 if ( filename.index(self.WORKDIR) != 0):
328 raise PLCInvalidArgument("File {} not under {}".format(filename, self.WORKDIR))
330 ### output should not exist (concurrent runs ..)
331 # numerous reports of issues with this policy
332 # looks like people sometime suspend/cancel their download
333 # and this leads to the old file sitting in there forever
334 # so, if the file is older than 5 minutes, we just trash
336 if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
338 if os.path.exists(filename):
339 raise PLCInvalidArgument(
340 "Resulting file {} already exists - please try again in {} minutes"\
341 .format(filename, grace))
343 ### we can now safely create the file,
344 ### either we are admin or under a controlled location
345 filedir=os.path.dirname(filename)
346 # dirname does not return "." for a local filename like its shell counterpart
348 if not os.path.exists(filedir):
350 os.makedirs (filedir,0o777)
352 raise PLCPermissionDenied("Could not create dir {}".format(filedir))
356 def build_command(self, nodename, node_type, build_sh_spec, node_image, type, floppy_file):
359 (*) build command to be run
360 (*) location of the log_file
365 # regular node, make build's arguments
366 # and build the full command line to be called
367 if node_type not in [ 'regular', 'reservable' ]:
368 logger.error("GetBootMedium.build_command: unexpected node_type {}".format(node_type))
372 if "cramfs" in build_sh_spec:
374 if "serial" in build_sh_spec:
375 build_sh_options += " -s {}".format(build_sh_spec['serial'])
376 if "variant" in build_sh_spec:
377 build_sh_options += " -V {}".format(build_sh_spec['variant'])
379 for karg in build_sh_spec['kargs']:
380 build_sh_options += ' -k "{}"'.format(karg)
383 date = time.strftime('%Y-%m-%d-%H-%M', time.gmtime())
384 if not os.path.isdir(self.LOGDIR):
385 os.makedirs(self.LOGDIR)
386 log_file = "{}/{}-{}.log".format(self.LOGDIR, date, nodename)
388 command = '{} -f "{}" -o "{}" -t "{}" {} > {} 2>&1'\
389 .format(self.BOOTCDBUILD,
396 logger.info("The build command line is {}".format(command))
398 return command, log_file
400 def call(self, auth, node_id_or_hostname, action, filename, options = []):
404 ### compute file suffix and type
405 if action.find("-iso") >= 0 :
408 elif action.find("-usb") >= 0:
415 # check for node existence and get node_type
416 nodes = Nodes(self.api, [node_id_or_hostname])
418 raise PLCInvalidArgument("No such node {}".format(node_id_or_hostname))
421 logger.info("GetBootMedium: {} requested on node {}. Node type is: {}"\
422 .format(action, node['node_id'], node['node_type']))
424 # check the required action against the node type
425 node_type = node['node_type']
426 if action not in allowed_actions[node_type]:
427 raise PLCInvalidArgument("Action {} not valid for {} nodes, valid actions are {}"\
428 .format(action, node_type, "|".join(allowed_actions[node_type])))
430 # handle / canonicalize options
433 raise PLCInvalidArgument("Options are not supported for node configs")
435 # create a dict for build.sh
436 build_sh_spec={'kargs':[]}
437 # use node tags as defaults
438 # check for node tag equivalents
439 tags = NodeTags(self.api,
440 {'node_id': node['node_id'],
441 'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
442 'no-hangcheck', 'systemd-debug' ]},
443 ['tagname', 'value'])
446 if tag['tagname'] == 'serial':
447 build_sh_spec['serial'] = tag['value']
448 elif tag['tagname'] == 'cramfs':
449 build_sh_spec['cramfs'] = True
450 elif tag['tagname'] == 'kvariant':
451 build_sh_spec['variant'] = tag['value']
452 elif tag['tagname'] == 'kargs':
453 build_sh_spec['kargs'] += tag['value'].split()
454 elif tag['tagname'] == 'no-hangcheck':
455 build_sh_spec['kargs'].append('hcheck_reboot0')
456 elif tag['tagname'] == 'systemd-debug':
457 build_sh_spec['kargs'].append('systemd.log_level=debug')
458 build_sh_spec['kargs'].append('systemd.log_target=console')
459 # then options can override tags
460 for option in options:
461 if option == "cramfs":
462 build_sh_spec['cramfs']=True
463 elif option == 'partition':
465 raise PLCInvalidArgument("option 'partition' is for USB images only")
468 elif option == "serial":
469 build_sh_spec['serial']='default'
470 elif option.find("serial:") == 0:
471 build_sh_spec['serial']=option.replace("serial:","")
472 elif option.find("variant:") == 0:
473 build_sh_spec['variant']=option.replace("variant:","")
474 elif option == "no-hangcheck":
475 build_sh_spec['kargs'].append('hcheck_reboot0')
476 elif option == "systemd-debug":
477 build_sh_spec['kargs'].append('systemd.log_level=debug')
479 raise PLCInvalidArgument("unknown option {}".format(option))
481 # compute nodename according the action
482 if action.find("node-") == 0:
483 nodename = node['hostname']
486 # compute a 8 bytes random number
487 tempbytes = random.sample (range(0,256), 8);
488 def hexa2 (c): return chr((c>>4)+65) + chr ((c&16)+65)
489 nodename = "".join(map(hexa2,tempbytes))
492 (pldistro,fcdistro,arch) = self.get_nodefamily(node, auth)
493 self.nodefamily="{}-{}-{}".format(pldistro, fcdistro, arch)
496 for attr in [ "BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR" ]:
497 setattr(self,attr,getattr(self,attr).replace("@NODEFAMILY@",self.nodefamily))
499 filename = self.handle_filename(filename, nodename, suffix, arch)
503 self.message='GetBootMedium on node {} - action={}'.format(nodename, action)
504 self.event_objects={'Node': [ node ['node_id'] ]}
506 self.message='GetBootMedium - generic - action={}'.format(action)
509 if action == 'generic-iso' or action == 'generic-usb':
511 raise PLCInvalidArgument("Options are not supported for generic images")
512 # this raises an exception if bootcd is missing
513 version = self.bootcd_version()
514 generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
515 generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
518 ret=os.system ('cp "{}" "{}"'.format(generic_path, filename))
522 raise PLCPermissionDenied("Could not copy {} into {}"\
523 .format(generic_path, filename))
525 ### return the generic medium content as-is, just base64 encoded
526 with open(generic_path) as feed:
527 return base64.b64encode(feed.read())
529 ### config file preview or regenerated
530 if action == 'node-preview' or action == 'node-floppy':
531 renew_key = (action == 'node-floppy')
532 floppy = self.floppy_contents (node,renew_key)
535 with open(filename, 'w') as writer:
538 raise PLCPermissionDenied("Could not write into {}".format(filename))
543 ### we're left with node-iso and node-usb
544 # the steps involved in the image creation are:
545 # - create and test the working environment
546 # - generate the configuration file
547 # - build and invoke the build command
548 # - delivery the resulting image file
550 if action == 'node-iso' or action == 'node-usb':
552 ### check we've got required material
553 version = self.bootcd_version()
555 if not os.path.isfile(self.BOOTCDBUILD):
556 raise PLCAPIError("Cannot locate bootcd/build.sh script {}".format(self.BOOTCDBUILD))
558 # create the workdir if needed
559 if not os.path.isdir(self.WORKDIR):
561 os.makedirs(self.WORKDIR,0o777)
562 os.chmod(self.WORKDIR,0o777)
564 raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
567 # generate floppy config
568 floppy_text = self.floppy_contents(node, True)
570 floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
572 with open(floppy_file, "w") as writer:
573 writer.write(floppy_text)
575 raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
577 self.trash.append(floppy_file)
579 node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
581 command, log_file = self.build_command(nodename, node_type, build_sh_spec,
582 node_image, type, floppy_file)
584 # invoke the image build script
586 ret = os.system(command)
589 raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
590 .format(self.BOOTCDBUILD, command, log_file))
592 if not os.path.isfile(node_image):
593 raise PLCAPIError("Unexpected location of build.sh output - {}".format(node_image))
597 ret = os.system('mv "{}" "{}"'.format(node_image, filename))
599 self.trash.append(node_image)
601 raise PLCAPIError("Could not move node image {} into {}"\
602 .format(node_image, filename))
606 with open(node_image) as feed:
608 self.trash.append(node_image)
610 logger.info("GetBootMedium - done with build.sh")
611 encoded_result = base64.b64encode(result)
612 logger.info("GetBootMedium - done with base64 encoding - lengths: raw={} - b64={}"
613 .format(len(result), len(encoded_result)))
614 return encoded_result
619 # we're done here, or we missed something
620 raise PLCAPIError('Unhandled action {}'.format(action))