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 key = base64.b64encode(bytes(int8s))
48 # Boot Manager cannot handle = in the key
49 key = key.replace(b"=", b"")
52 class GetBootMedium(Method):
54 This method is a redesign based on former, supposedly dedicated,
55 AdmGenerateNodeConfFile
57 As compared with its ancestor, this method provides a much more
58 detailed interface, that allows to
59 (*) either just preview the node config file -- in which case
60 the node key is NOT recomputed, and NOT provided in the output
61 (*) or regenerate the node config file for storage on a floppy
62 that is, exactly what the ancestor method used todo,
63 including renewing the node's key
64 (*) or regenerate the config file and bundle it inside an ISO or USB image
65 (*) or just provide the generic ISO or USB boot images
66 in which case of course the node_id_or_hostname parameter is not used
68 action is expected among the following string constants according the
79 Apart for the preview mode, this method generates a new node key for the
80 specified node, effectively invalidating any old boot medium.
81 Note that 'reservable' nodes do not support 'node-floppy',
82 'generic-iso' nor 'generic-usb'.
84 In addition, two return mechanisms are supported.
85 (*) The default behaviour is that the file's content is returned as a
86 base64-encoded string. This is how the ancestor method used to work.
87 To use this method, pass an empty string as the file parameter.
89 (*) Or, for efficiency -- this makes sense only when the API is used
90 by the web pages that run on the same host -- the caller may provide
91 a filename, in which case the resulting file is stored in that location instead.
92 The filename argument can use the following markers, that are expanded
94 - %d : default root dir (some builtin dedicated area under /var/tmp/)
95 Using this is recommended, and enforced for non-admin users
96 - %n : the node's name when this makes sense, or a mktemp-like name when
97 generic media is requested
98 - %s : a file suffix appropriate in the context (.txt, .iso or the like)
99 - %v : the bootcd version string (e.g. 4.0)
101 - %f : the nodefamily
103 With the file-based return mechanism, the method returns the full pathname
106 It is the caller's responsability to remove this file after use.
108 Options: an optional array of keywords.
109 options are not supported for generic images
110 Currently supported are
111 - 'partition' - for USB actions only
113 - 'serial' or 'serial:<console_spec>'
114 console_spec (or 'default') is passed as-is to bootcd/build.sh
115 it is expected to be a colon separated string denoting
116 tty - baudrate - parity - bits
117 e.g. ttyS0:115200:n:8
118 - 'variant:<variantname>'
119 passed to build.sh as -V <variant>
120 variants are used to run a different kernel on the bootCD
121 see kvariant.sh for how to create a variant
122 - 'no-hangcheck' - disable hangcheck
123 - 'systemd-debug' - turn on systemd debug in bootcd
125 Tags: the following tags are taken into account when attached to the node:
126 'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck', 'systemd-debug'
129 - Non-admins can only generate files for nodes at their sites.
130 - Non-admins, when they provide a filename, *must* specify it in the %d area
133 Whenever needed, the method stores intermediate files in a
134 private area, typically not located under the web server's
135 accessible area, and are cleaned up by the method.
139 roles = ['admin', 'pi', 'tech']
143 Mixed(Node.fields['node_id'],
144 Node.fields['hostname']),
145 Parameter(str, "Action mode, expected value depends of the type of node"),
146 Parameter(str, "Empty string for verbatim result, resulting file full path otherwise"),
147 Parameter([str], "Options"),
150 returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
152 # define globals for regular nodes, override later for other types
153 BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
154 BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
155 GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
156 WORKDIR = "/var/tmp/bootmedium"
157 LOGDIR = "/var/tmp/bootmedium/logs/"
159 # uncomment this to preserve temporary area and bootcustom logs
162 ### returns (host, domain) :
163 # 'host' : host part of the hostname
164 # 'domain' : domain part of the hostname
165 def split_hostname(self, node):
166 # Split hostname into host and domain parts
167 parts = node['hostname'].split(".", 1)
169 raise PLCInvalidArgument("Node hostname {} is invalid".format(node['hostname']))
172 # Generate the node (plnode.txt) configuration content.
174 # This function will create the configuration file a node
176 # - a common part, regardless of the 'node_type' tag
177 # - XXX a special part, depending on the 'node_type' tag value.
178 def floppy_contents(self, node, renew_key):
181 if node['peer_id'] is not None:
182 raise PLCInvalidArgument("Not a local node {}".format(node['hostname']))
184 # If we are not an admin, make sure that the caller is a
185 # member of the site at which the node is located.
186 if 'admin' not in self.caller['roles']:
187 if node['site_id'] not in self.caller['site_ids']:
188 raise PLCPermissionDenied(
189 "Not allowed to generate a configuration file for {}"\
190 .format(node['hostname']))
192 # Get interface for this node
194 interfaces = Interfaces(self.api, node['interface_ids'])
195 for interface in interfaces:
196 if interface['is_primary']:
200 raise PLCInvalidArgument(
201 "No primary network configured on {}".format(node['hostname']))
203 host, domain = self.split_hostname(node)
205 # renew the key and save it on the database
207 node['key'] = compute_key()
208 node.update_last_download(commit=False)
211 # Generate node configuration file suitable for BootCD
215 file += 'NODE_ID="{}"\n'.format(node['node_id'])
216 file += 'NODE_KEY="{}"\n'.format(node['key'])
217 # not used anywhere, just a note for operations people
218 file += 'KEY_RENEWAL_DATE="{}"\n'\
219 .format(time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime()))
222 file += 'NET_DEVICE="{}"\n'.format(primary['mac'].lower())
224 file += 'IP_METHOD="{}"\n'.format(primary['method'])
226 if primary['method'] == 'static':
227 file += 'IP_ADDRESS="{}"\n'.format(primary['ip'])
228 file += 'IP_GATEWAY="{}"\n'.format(primary['gateway'])
229 file += 'IP_NETMASK="{}"\n'.format(primary['netmask'])
230 file += 'IP_NETADDR="{}"\n'.format(primary['network'])
231 file += 'IP_BROADCASTADDR="{}"\n'.format(primary['broadcast'])
232 file += 'IP_DNS1="{}"\n'.format(primary['dns1'])
233 file += 'IP_DNS2="{}"\n'.format(primary['dns2'] or "")
235 file += 'HOST_NAME="{}"\n'.format(host)
236 file += 'DOMAIN_NAME="{}"\n'.format(domain)
238 # define various interface settings attached to the primary interface
239 settings = InterfaceTags(self.api, {'interface_id':interface['interface_id']})
242 for setting in settings:
243 if setting['category'] is not None:
244 categories.add(setting['category'])
246 for category in categories:
247 category_settings = InterfaceTags(self.api,{'interface_id' : interface['interface_id'],
248 'category' : category})
249 if category_settings:
250 file += '### Category : {}\n'.format(category)
251 for setting in category_settings:
252 file += '{}_{}="{}"\n'\
253 .format(category.upper(), setting['tagname'].upper(), setting['value'])
255 for interface in interfaces:
256 if interface['method'] == 'ipmi':
257 file += 'IPMI_ADDRESS="{}"\n'.format(interface['ip'])
259 file += 'IPMI_MAC="{}"\n'.format(interface['mac'].lower())
264 # see also GetNodeFlavour that does similar things
265 def get_nodefamily(self, node, auth):
266 pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
267 fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
268 arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
270 return pldistro, fcdistro, arch
272 node_id = node['node_id']
274 # no support for deployment-based BootCD's, use kvariants instead
275 node_pldistro = GetNodePldistro(self.api,self.caller).call(auth, node_id)
277 pldistro = node_pldistro
279 node_fcdistro = GetNodeFcdistro(self.api,self.caller).call(auth, node_id)
281 fcdistro = node_fcdistro
283 node_arch = GetNodeArch(self.api,self.caller).call(auth,node_id)
287 return pldistro, fcdistro, arch
289 def bootcd_version(self):
291 with open(self.BOOTCDDIR + "/build/version.txt") as feed:
292 return feed.readline().strip()
294 raise Exception("Unknown boot cd version - probably wrong bootcd dir : {}"\
295 .format(self.BOOTCDDIR))
297 def cleantrash(self):
298 for file in self.trash:
300 logger.debug('DEBUG -- preserving trash file {}'.format(file))
305 # build the filename string
306 # check for permissions and concurrency
307 # returns the filename
308 def handle_filename(self, filename, nodename, suffix, arch):
309 # allow to set filename to None or any other empty value
310 if not filename: filename=''
311 filename = filename.replace("%d", self.WORKDIR)
312 filename = filename.replace("%n", nodename)
313 filename = filename.replace("%s", suffix)
314 filename = filename.replace("%p", self.api.config.PLC_NAME)
316 try: filename = filename.replace("%f", self.nodefamily)
318 try: filename = filename.replace("%a", arch)
320 try: filename = filename.replace("%v", self.bootcd_version())
323 ### Check filename location
325 if 'admin' not in self.caller['roles']:
326 if filename.index(self.WORKDIR) != 0:
327 raise PLCInvalidArgument("File {} not under {}".format(filename, self.WORKDIR))
329 ### output should not exist (concurrent runs ..)
330 # numerous reports of issues with this policy
331 # looks like people sometime suspend/cancel their download
332 # and this leads to the old file sitting in there forever
333 # so, if the file is older than 5 minutes, we just trash
335 if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
337 if os.path.exists(filename):
338 raise PLCInvalidArgument(
339 "Resulting file {} already exists - please try again in {} minutes"\
340 .format(filename, grace))
342 ### we can now safely create the file,
343 ### either we are admin or under a controlled location
344 filedir = os.path.dirname(filename)
345 # dirname does not return "." for a local filename like its shell counterpart
347 if not os.path.exists(filedir):
349 os.makedirs(filedir, 0o777)
351 raise PLCPermissionDenied("Could not create dir {}".format(filedir))
355 def build_command(self, nodename, node_type, build_sh_spec, node_image, type, floppy_file):
358 (*) build command to be run
359 (*) location of the log_file
364 # regular node, make build's arguments
365 # and build the full command line to be called
366 if node_type not in [ 'regular', 'reservable' ]:
367 logger.error("GetBootMedium.build_command: unexpected node_type {}".format(node_type))
370 build_sh_options = ""
371 if "cramfs" in build_sh_spec:
373 if "serial" in build_sh_spec:
374 build_sh_options += " -s {}".format(build_sh_spec['serial'])
375 if "variant" in build_sh_spec:
376 build_sh_options += " -V {}".format(build_sh_spec['variant'])
378 for karg in build_sh_spec['kargs']:
379 build_sh_options += ' -k "{}"'.format(karg)
382 date = time.strftime('%Y-%m-%d-%H-%M', time.gmtime())
383 if not os.path.isdir(self.LOGDIR):
384 os.makedirs(self.LOGDIR)
385 log_file = "{}/{}-{}.log".format(self.LOGDIR, date, nodename)
387 command = '{} -f "{}" -o "{}" -t "{}" {} > {} 2>&1'\
388 .format(self.BOOTCDBUILD,
395 logger.info("The build command line is {}".format(command))
397 return command, log_file
399 def call(self, auth, node_id_or_hostname, action, filename, options=None):
406 ### compute file suffix and type
407 if action.find("-iso") >= 0 :
410 elif action.find("-usb") >= 0:
417 # check for node existence and get node_type
418 nodes = Nodes(self.api, [node_id_or_hostname])
420 raise PLCInvalidArgument("No such node {}".format(node_id_or_hostname))
423 logger.info("GetBootMedium: {} requested on node {}. Node type is: {}"
424 .format(action, node['node_id'], node['node_type']))
426 # check the required action against the node type
427 node_type = node['node_type']
428 if action not in allowed_actions[node_type]:
429 raise PLCInvalidArgument(
430 "Action {} not valid for {} nodes, valid actions are {}"
431 .format(action, node_type, "|".join(allowed_actions[node_type])))
433 # handle / canonicalize options
436 raise PLCInvalidArgument("Options are not supported for node configs")
438 # create a dict for build.sh
439 build_sh_spec = {'kargs':[]}
440 # use node tags as defaults
441 # check for node tag equivalents
442 tags = NodeTags(self.api,
443 {'node_id': node['node_id'],
444 'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
445 'no-hangcheck', 'systemd-debug']},
446 ['tagname', 'value'])
449 if tag['tagname'] == 'serial':
450 build_sh_spec['serial'] = tag['value']
451 elif tag['tagname'] == 'cramfs':
452 build_sh_spec['cramfs'] = True
453 elif tag['tagname'] == 'kvariant':
454 build_sh_spec['variant'] = tag['value']
455 elif tag['tagname'] == 'kargs':
456 build_sh_spec['kargs'] += tag['value'].split()
457 elif tag['tagname'] == 'no-hangcheck':
458 build_sh_spec['kargs'].append('hcheck_reboot0')
459 elif tag['tagname'] == 'systemd-debug':
461 build_sh_spec['kargs'].append('systemd.log_level=debug')
462 build_sh_spec['kargs'].append('systemd.log_target=console')
463 build_sh_spec['kargs'].append(
464 'systemd.default_standard_output=journal+console')
465 build_sh_spec['kargs'].append(
466 'systemd.default_standard_error=journal+console')
467 # then options can override tags
468 for option in options:
469 if option == "cramfs":
470 build_sh_spec['cramfs'] = True
471 elif option == 'partition':
473 raise PLCInvalidArgument("option 'partition' is for USB images only")
475 type = "usb_partition"
476 elif option == "serial":
477 build_sh_spec['serial'] = 'default'
478 elif option.find("serial:") == 0:
479 build_sh_spec['serial'] = option.replace("serial:", "")
480 elif option.find("variant:") == 0:
481 build_sh_spec['variant'] = option.replace("variant:", "")
482 elif option == "no-hangcheck":
483 build_sh_spec['kargs'].append('hcheck_reboot0')
484 elif option == "systemd-debug":
485 build_sh_spec['kargs'].append('systemd.log_level=debug')
486 build_sh_spec['kargs'].append('systemd.log_target=console')
487 build_sh_spec['kargs'].append(
488 'systemd.default_standard_output=journal+console')
489 build_sh_spec['kargs'].append(
490 'systemd.default_standard_error=journal+console')
492 raise PLCInvalidArgument("unknown option {}".format(option))
494 # compute nodename according the action
495 if action.find("node-") == 0:
496 nodename = node['hostname']
499 # compute a 8 bytes random number
500 tempbytes = random.sample(range(0, 256), 8);
502 return chr((c>>4)+65) + chr((c&16)+65)
503 nodename = "".join(map(hexa2, tempbytes))
506 pldistro, fcdistro, arch = self.get_nodefamily(node, auth)
507 self.nodefamily = "{}-{}-{}".format(pldistro, fcdistro, arch)
510 for attr in ["BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR"]:
512 getattr(self, attr).replace("@NODEFAMILY@", self.nodefamily))
514 filename = self.handle_filename(filename, nodename, suffix, arch)
518 self.message = 'GetBootMedium on node {} - action={}'.format(nodename, action)
519 self.event_objects = {'Node': [node ['node_id']]}
521 self.message = 'GetBootMedium - generic - action={}'.format(action)
524 if action in ('generic-iso', 'generic-usb'):
526 raise PLCInvalidArgument("Options are not supported for generic images")
527 # this raises an exception if bootcd is missing
528 version = self.bootcd_version()
529 generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
530 generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
533 ret = os.system('cp "{}" "{}"'.format(generic_path, filename))
537 raise PLCPermissionDenied("Could not copy {} into {}"\
538 .format(generic_path, filename))
540 ### return the generic medium content as-is, just base64 encoded
541 with open(generic_path) as feed:
542 return base64.b64encode(feed.read())
544 ### config file preview or regenerated
545 if action == 'node-preview' or action == 'node-floppy':
546 renew_key = (action == 'node-floppy')
547 floppy = self.floppy_contents(node, renew_key)
550 with open(filename, 'w') as writer:
553 raise PLCPermissionDenied("Could not write into {}".format(filename))
558 ### we're left with node-iso and node-usb
559 # the steps involved in the image creation are:
560 # - create and test the working environment
561 # - generate the configuration file
562 # - build and invoke the build command
563 # - delivery the resulting image file
565 if action in ('node-iso', 'node-usb'):
567 ### check we've got required material
568 version = self.bootcd_version()
570 if not os.path.isfile(self.BOOTCDBUILD):
571 raise PLCAPIError("Cannot locate bootcd/build.sh script {}"
572 .format(self.BOOTCDBUILD))
574 # create the workdir if needed
575 if not os.path.isdir(self.WORKDIR):
577 os.makedirs(self.WORKDIR, 0o777)
578 os.chmod(self.WORKDIR, 0o777)
580 raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
583 # generate floppy config
584 floppy_text = self.floppy_contents(node, True)
586 floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
588 with open(floppy_file, "w") as writer:
589 writer.write(floppy_text)
591 raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
593 self.trash.append(floppy_file)
595 node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
597 command, log_file = self.build_command(nodename, node_type, build_sh_spec,
598 node_image, type, floppy_file)
600 # invoke the image build script
602 ret = os.system(command)
605 raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
606 .format(self.BOOTCDBUILD, command, log_file))
608 if not os.path.isfile(node_image):
609 raise PLCAPIError("Unexpected location of build.sh output - {}"
614 ret = os.system('mv "{}" "{}"'.format(node_image, filename))
616 self.trash.append(node_image)
618 raise PLCAPIError("Could not move node image {} into {}"\
619 .format(node_image, filename))
623 with open(node_image, "rb") as feed:
625 self.trash.append(node_image)
627 logger.info("GetBootMedium - done with build.sh")
628 encoded_result = base64.b64encode(result)
629 # stupidly enough, we need to decode this as str now
630 # so that we remain compatible with former python2 PLCAPI
631 encoded_result = encoded_result.decode()
632 logger.info("GetBootMedium - done with base64 encoding -"
633 " lengths: raw={} - b64={}"
634 .format(len(result), len(encoded_result)))
635 return encoded_result
640 # we're done here, or we missed something
641 raise PLCAPIError('Unhandled action {}'.format(action))