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 int8s = random.sample(range(0, 256), 32)
46 # Base64 encode their string representation
47 # and, importantly, decode back to return a str
48 # this is important as otherwise we pollute node.txt
49 key = base64.b64encode(bytes(int8s)).decode()
50 # Boot Manager cannot handle = in the key
51 key = key.replace("=", "")
54 class GetBootMedium(Method):
56 This method is a redesign based on former, supposedly dedicated,
57 AdmGenerateNodeConfFile
59 As compared with its ancestor, this method provides a much more
60 detailed interface, that allows to
61 (*) either just preview the node config file -- in which case
62 the node key is NOT recomputed, and NOT provided in the output
63 (*) or regenerate the node config file for storage on a floppy
64 that is, exactly what the ancestor method used todo,
65 including renewing the node's key
66 (*) or regenerate the config file and bundle it inside an ISO or USB image
67 (*) or just provide the generic ISO or USB boot images
68 in which case of course the node_id_or_hostname parameter is not used
70 action is expected among the following string constants according the
81 Apart for the preview mode, this method generates a new node key for the
82 specified node, effectively invalidating any old boot medium.
83 Note that 'reservable' nodes do not support 'node-floppy',
84 'generic-iso' nor 'generic-usb'.
86 In addition, two return mechanisms are supported.
87 (*) The default behaviour is that the file's content is returned as a
88 base64-encoded string. This is how the ancestor method used to work.
89 To use this method, pass an empty string as the file parameter.
91 (*) Or, for efficiency -- this makes sense only when the API is used
92 by the web pages that run on the same host -- the caller may provide
93 a filename, in which case the resulting file is stored in that location instead.
94 The filename argument can use the following markers, that are expanded
96 - %d : default root dir (some builtin dedicated area under /var/tmp/)
97 Using this is recommended, and enforced for non-admin users
98 - %n : the node's name when this makes sense, or a mktemp-like name when
99 generic media is requested
100 - %s : a file suffix appropriate in the context (.txt, .iso or the like)
101 - %v : the bootcd version string (e.g. 4.0)
103 - %f : the nodefamily
105 With the file-based return mechanism, the method returns the full pathname
108 It is the caller's responsability to remove this file after use.
110 Options: an optional array of keywords.
111 options are not supported for generic images
112 Currently supported are
113 - 'partition' - for USB actions only
115 - 'serial' or 'serial:<console_spec>'
116 console_spec (or 'default') is passed as-is to bootcd/build.sh
117 it is expected to be a colon separated string denoting
118 tty - baudrate - parity - bits
119 e.g. ttyS0:115200:n:8
120 - 'variant:<variantname>'
121 passed to build.sh as -V <variant>
122 variants are used to run a different kernel on the bootCD
123 see kvariant.sh for how to create a variant
124 - 'no-hangcheck' - disable hangcheck
125 - 'systemd-console' - redirect systemd services outputs in console
126 - 'systemd-debug' - turn on systemd debug in bootcd
128 Tags: the following tags are taken into account when attached to the node:
129 'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck',
130 'systemd-console', 'systemd-debug',
133 - Non-admins can only generate files for nodes at their sites.
134 - Non-admins, when they provide a filename, *must* specify it in the %d area
137 Whenever needed, the method stores intermediate files in a
138 private area, typically not located under the web server's
139 accessible area, and are cleaned up by the method.
143 roles = ['admin', 'pi', 'tech']
147 Mixed(Node.fields['node_id'],
148 Node.fields['hostname']),
149 Parameter(str, "Action mode, expected value depends of the type of node"),
150 Parameter(str, "Empty string for verbatim result, resulting file full path otherwise"),
151 Parameter([str], "Options"),
154 returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
156 # define globals for regular nodes, override later for other types
157 BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
158 BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
159 GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
160 WORKDIR = "/var/tmp/bootmedium"
161 LOGDIR = "/var/tmp/bootmedium/logs/"
163 # uncomment this to preserve temporary area and bootcustom logs
166 ### returns (host, domain) :
167 # 'host' : host part of the hostname
168 # 'domain' : domain part of the hostname
169 def split_hostname(self, node):
170 # Split hostname into host and domain parts
171 parts = node['hostname'].split(".", 1)
173 raise PLCInvalidArgument("Node hostname {} is invalid".format(node['hostname']))
176 # Generate the node (plnode.txt) configuration content.
178 # This function will create the configuration file a node
180 # - a common part, regardless of the 'node_type' tag
181 # - XXX a special part, depending on the 'node_type' tag value.
182 def floppy_contents(self, node, renew_key):
185 if node['peer_id'] is not None:
186 raise PLCInvalidArgument("Not a local node {}".format(node['hostname']))
188 # If we are not an admin, make sure that the caller is a
189 # member of the site at which the node is located.
190 if 'admin' not in self.caller['roles']:
191 if node['site_id'] not in self.caller['site_ids']:
192 raise PLCPermissionDenied(
193 "Not allowed to generate a configuration file for {}"\
194 .format(node['hostname']))
196 # Get interface for this node
198 interfaces = Interfaces(self.api, node['interface_ids'])
199 for interface in interfaces:
200 if interface['is_primary']:
204 raise PLCInvalidArgument(
205 "No primary network configured on {}".format(node['hostname']))
207 host, domain = self.split_hostname(node)
209 # renew the key and save it on the database
211 node['key'] = compute_key()
212 node.update_last_download(commit=False)
215 # Generate node configuration file suitable for BootCD
219 file += 'NODE_ID="{}"\n'.format(node['node_id'])
220 file += 'NODE_KEY="{}"\n'.format(node['key'])
221 # not used anywhere, just a note for operations people
222 file += 'KEY_RENEWAL_DATE="{}"\n'\
223 .format(time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime()))
226 file += 'NET_DEVICE="{}"\n'.format(primary['mac'].lower())
228 file += 'IP_METHOD="{}"\n'.format(primary['method'])
230 if primary['method'] == 'static':
231 file += 'IP_ADDRESS="{}"\n'.format(primary['ip'])
232 file += 'IP_GATEWAY="{}"\n'.format(primary['gateway'])
233 file += 'IP_NETMASK="{}"\n'.format(primary['netmask'])
234 file += 'IP_NETADDR="{}"\n'.format(primary['network'])
235 file += 'IP_BROADCASTADDR="{}"\n'.format(primary['broadcast'])
236 file += 'IP_DNS1="{}"\n'.format(primary['dns1'])
237 file += 'IP_DNS2="{}"\n'.format(primary['dns2'] or "")
239 file += 'HOST_NAME="{}"\n'.format(host)
240 file += 'DOMAIN_NAME="{}"\n'.format(domain)
242 # define various interface settings attached to the primary interface
243 settings = InterfaceTags(self.api, {'interface_id':interface['interface_id']})
246 for setting in settings:
247 if setting['category'] is not None:
248 categories.add(setting['category'])
250 for category in categories:
251 category_settings = InterfaceTags(self.api,{'interface_id' : interface['interface_id'],
252 'category' : category})
253 if category_settings:
254 file += '### Category : {}\n'.format(category)
255 for setting in category_settings:
256 file += '{}_{}="{}"\n'\
257 .format(category.upper(), setting['tagname'].upper(), setting['value'])
259 for interface in interfaces:
260 if interface['method'] == 'ipmi':
261 file += 'IPMI_ADDRESS="{}"\n'.format(interface['ip'])
263 file += 'IPMI_MAC="{}"\n'.format(interface['mac'].lower())
268 # see also GetNodeFlavour that does similar things
269 def get_nodefamily(self, node, auth):
270 pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
271 fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
272 arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
274 return pldistro, fcdistro, arch
276 node_id = node['node_id']
278 # no support for deployment-based BootCD's, use kvariants instead
279 node_pldistro = GetNodePldistro(self.api,self.caller).call(auth, node_id)
281 pldistro = node_pldistro
283 node_fcdistro = GetNodeFcdistro(self.api,self.caller).call(auth, node_id)
285 fcdistro = node_fcdistro
287 node_arch = GetNodeArch(self.api,self.caller).call(auth,node_id)
291 return pldistro, fcdistro, arch
293 def bootcd_version(self):
295 with open(self.BOOTCDDIR + "/build/version.txt") as feed:
296 return feed.readline().strip()
298 raise Exception("Unknown boot cd version - probably wrong bootcd dir : {}"\
299 .format(self.BOOTCDDIR))
301 def cleantrash(self):
302 for file in self.trash:
304 logger.debug('DEBUG -- preserving trash file {}'.format(file))
309 # build the filename string
310 # check for permissions and concurrency
311 # returns the filename
312 def handle_filename(self, filename, nodename, suffix, arch):
313 # allow to set filename to None or any other empty value
314 if not filename: filename=''
315 filename = filename.replace("%d", self.WORKDIR)
316 filename = filename.replace("%n", nodename)
317 filename = filename.replace("%s", suffix)
318 filename = filename.replace("%p", self.api.config.PLC_NAME)
320 try: filename = filename.replace("%f", self.nodefamily)
322 try: filename = filename.replace("%a", arch)
324 try: filename = filename.replace("%v", self.bootcd_version())
327 ### Check filename location
329 if 'admin' not in self.caller['roles']:
330 if filename.index(self.WORKDIR) != 0:
331 raise PLCInvalidArgument("File {} not under {}".format(filename, self.WORKDIR))
333 ### output should not exist (concurrent runs ..)
334 # numerous reports of issues with this policy
335 # looks like people sometime suspend/cancel their download
336 # and this leads to the old file sitting in there forever
337 # so, if the file is older than 5 minutes, we just trash
339 if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
341 if os.path.exists(filename):
342 raise PLCInvalidArgument(
343 "Resulting file {} already exists - please try again in {} minutes"\
344 .format(filename, grace))
346 ### we can now safely create the file,
347 ### either we are admin or under a controlled location
348 filedir = os.path.dirname(filename)
349 # dirname does not return "." for a local filename like its shell counterpart
351 if not os.path.exists(filedir):
353 os.makedirs(filedir, 0o777)
355 raise PLCPermissionDenied("Could not create dir {}".format(filedir))
359 def build_command(self, nodename, node_type, build_sh_spec, node_image, type, floppy_file):
362 (*) build command to be run
363 (*) location of the log_file
368 # regular node, make build's arguments
369 # and build the full command line to be called
370 if node_type not in [ 'regular', 'reservable' ]:
371 logger.error("GetBootMedium.build_command: unexpected node_type {}".format(node_type))
374 build_sh_options = ""
375 if "cramfs" in build_sh_spec:
377 if "serial" in build_sh_spec:
378 build_sh_options += " -s {}".format(build_sh_spec['serial'])
379 if "variant" in build_sh_spec:
380 build_sh_options += " -V {}".format(build_sh_spec['variant'])
382 for karg in build_sh_spec['kargs']:
383 build_sh_options += ' -k "{}"'.format(karg)
386 date = time.strftime('%Y-%m-%d-%H-%M', time.gmtime())
387 if not os.path.isdir(self.LOGDIR):
388 os.makedirs(self.LOGDIR)
389 log_file = "{}/{}-{}.log".format(self.LOGDIR, date, nodename)
391 command = '{} -f "{}" -o "{}" -t "{}" {} > {} 2>&1'\
392 .format(self.BOOTCDBUILD,
399 logger.info("The build command line is {}".format(command))
401 return command, log_file
403 def call(self, auth, node_id_or_hostname, action, filename, options=None):
410 ### compute file suffix and type
411 if action.find("-iso") >= 0 :
414 elif action.find("-usb") >= 0:
421 # check for node existence and get node_type
422 nodes = Nodes(self.api, [node_id_or_hostname])
424 raise PLCInvalidArgument("No such node {}".format(node_id_or_hostname))
427 logger.info("GetBootMedium: {} requested on node {}. Node type is: {}"
428 .format(action, node['node_id'], node['node_type']))
430 # check the required action against the node type
431 node_type = node['node_type']
432 if action not in allowed_actions[node_type]:
433 raise PLCInvalidArgument(
434 "Action {} not valid for {} nodes, valid actions are {}"
435 .format(action, node_type, "|".join(allowed_actions[node_type])))
437 # handle / canonicalize options
440 raise PLCInvalidArgument("Options are not supported for node configs")
442 # create a dict for build.sh
443 build_sh_spec = {'kargs':[]}
444 # use node tags as defaults
445 # check for node tag equivalents
448 {'node_id': node['node_id'],
449 'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
450 'no-hangcheck', 'systemd-console', 'systemd-debug', ]},
451 ['tagname', 'value'])
454 if tag['tagname'] == 'serial':
455 build_sh_spec['serial'] = tag['value']
456 elif tag['tagname'] == 'cramfs':
457 build_sh_spec['cramfs'] = True
458 elif tag['tagname'] == 'kvariant':
459 build_sh_spec['variant'] = tag['value']
460 elif tag['tagname'] == 'kargs':
461 build_sh_spec['kargs'] += tag['value'].split()
462 elif tag['tagname'] == 'no-hangcheck':
463 build_sh_spec['kargs'].append('hcheck_reboot0')
464 elif tag['tagname'] == 'systemd-console':
465 build_sh_spec['kargs'].append('systemd.log_target=console')
466 build_sh_spec['kargs'].append(
467 'systemd.default_standard_output=journal+console')
468 build_sh_spec['kargs'].append(
469 'systemd.default_standard_error=journal+console')
470 elif tag['tagname'] == 'systemd-debug':
471 build_sh_spec['kargs'].append('systemd.log_level=debug')
472 # then options can override tags
473 for option in options:
474 if option == "cramfs":
475 build_sh_spec['cramfs'] = True
476 elif option == 'partition':
478 raise PLCInvalidArgument("option 'partition' is for USB images only")
480 type = "usb_partition"
481 elif option == "serial":
482 build_sh_spec['serial'] = 'default'
483 elif option.find("serial:") == 0:
484 build_sh_spec['serial'] = option.replace("serial:", "")
485 elif option.find("variant:") == 0:
486 build_sh_spec['variant'] = option.replace("variant:", "")
487 elif option == "no-hangcheck":
488 build_sh_spec['kargs'].append('hcheck_reboot0')
489 elif option == "systemd-console":
490 build_sh_spec['kargs'].append('systemd.log_target=console')
491 build_sh_spec['kargs'].append(
492 'systemd.default_standard_output=journal+console')
493 build_sh_spec['kargs'].append(
494 'systemd.default_standard_error=journal+console')
495 elif option == "systemd-debug":
496 build_sh_spec['kargs'].append('systemd.log_level=debug')
498 raise PLCInvalidArgument("unknown option {}".format(option))
500 # compute nodename according the action
501 if action.find("node-") == 0:
502 nodename = node['hostname']
505 # compute a 8 bytes random number
506 tempbytes = random.sample(range(0, 256), 8);
508 return chr((c>>4)+65) + chr((c&16)+65)
509 nodename = "".join(map(hexa2, tempbytes))
512 pldistro, fcdistro, arch = self.get_nodefamily(node, auth)
513 self.nodefamily = "{}-{}-{}".format(pldistro, fcdistro, arch)
516 for attr in ["BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR"]:
518 getattr(self, attr).replace("@NODEFAMILY@", self.nodefamily))
520 filename = self.handle_filename(filename, nodename, suffix, arch)
524 self.message = 'GetBootMedium on node {} - action={}'.format(nodename, action)
525 self.event_objects = {'Node': [node ['node_id']]}
527 self.message = 'GetBootMedium - generic - action={}'.format(action)
530 if action in ('generic-iso', 'generic-usb'):
532 raise PLCInvalidArgument("Options are not supported for generic images")
533 # this raises an exception if bootcd is missing
534 version = self.bootcd_version()
535 generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
536 generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
539 ret = os.system('cp "{}" "{}"'.format(generic_path, filename))
543 raise PLCPermissionDenied("Could not copy {} into {}"\
544 .format(generic_path, filename))
546 ### return the generic medium content as-is, just base64 encoded
547 with open(generic_path, "rb") as feed:
548 return base64.b64encode(feed.read()).decode()
550 ### config file preview or regenerated
551 if action == 'node-preview' or action == 'node-floppy':
552 renew_key = (action == 'node-floppy')
553 floppy = self.floppy_contents(node, renew_key)
556 with open(filename, 'w') as writer:
559 raise PLCPermissionDenied("Could not write into {}".format(filename))
564 ### we're left with node-iso and node-usb
565 # the steps involved in the image creation are:
566 # - create and test the working environment
567 # - generate the configuration file
568 # - build and invoke the build command
569 # - delivery the resulting image file
571 if action in ('node-iso', 'node-usb'):
573 ### check we've got required material
574 version = self.bootcd_version()
576 if not os.path.isfile(self.BOOTCDBUILD):
577 raise PLCAPIError("Cannot locate bootcd/build.sh script {}"
578 .format(self.BOOTCDBUILD))
580 # create the workdir if needed
581 if not os.path.isdir(self.WORKDIR):
583 os.makedirs(self.WORKDIR, 0o777)
584 os.chmod(self.WORKDIR, 0o777)
586 raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
589 # generate floppy config
590 floppy_text = self.floppy_contents(node, True)
592 floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
594 with open(floppy_file, "w") as writer:
595 writer.write(floppy_text)
597 raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
599 self.trash.append(floppy_file)
601 node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
603 command, log_file = self.build_command(nodename, node_type, build_sh_spec,
604 node_image, type, floppy_file)
606 # invoke the image build script
608 ret = os.system(command)
611 raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
612 .format(self.BOOTCDBUILD, command, log_file))
614 if not os.path.isfile(node_image):
615 raise PLCAPIError("Unexpected location of build.sh output - {}"
620 ret = os.system('mv "{}" "{}"'.format(node_image, filename))
622 self.trash.append(node_image)
624 raise PLCAPIError("Could not move node image {} into {}"\
625 .format(node_image, filename))
629 with open(node_image, "rb") as feed:
631 self.trash.append(node_image)
633 logger.info("GetBootMedium - done with build.sh")
634 # stupidly enough, we need to decode this as str now
635 # so that we remain compatible with former python2 PLCAPI
636 encoded_result = base64.b64encode(result).decode()
637 logger.info("GetBootMedium - done with base64 encoding -"
638 " lengths: raw={} - b64={}"
639 .format(len(result), len(encoded_result)))
640 return encoded_result
645 # we're done here, or we missed something
646 raise PLCAPIError('Unhandled action {}'.format(action))