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-debug' - turn on systemd debug in bootcd
127 Tags: the following tags are taken into account when attached to the node:
128 'serial', 'cramfs', 'kvariant', 'kargs', 'no-hangcheck', 'systemd-debug'
131 - Non-admins can only generate files for nodes at their sites.
132 - Non-admins, when they provide a filename, *must* specify it in the %d area
135 Whenever needed, the method stores intermediate files in a
136 private area, typically not located under the web server's
137 accessible area, and are cleaned up by the method.
141 roles = ['admin', 'pi', 'tech']
145 Mixed(Node.fields['node_id'],
146 Node.fields['hostname']),
147 Parameter(str, "Action mode, expected value depends of the type of node"),
148 Parameter(str, "Empty string for verbatim result, resulting file full path otherwise"),
149 Parameter([str], "Options"),
152 returns = Parameter(str, "Node boot medium, either inlined, or filename, depending on the filename parameter")
154 # define globals for regular nodes, override later for other types
155 BOOTCDDIR = "/usr/share/bootcd-@NODEFAMILY@/"
156 BOOTCDBUILD = "/usr/share/bootcd-@NODEFAMILY@/build.sh"
157 GENERICDIR = "/var/www/html/download-@NODEFAMILY@/"
158 WORKDIR = "/var/tmp/bootmedium"
159 LOGDIR = "/var/tmp/bootmedium/logs/"
161 # uncomment this to preserve temporary area and bootcustom logs
164 ### returns (host, domain) :
165 # 'host' : host part of the hostname
166 # 'domain' : domain part of the hostname
167 def split_hostname(self, node):
168 # Split hostname into host and domain parts
169 parts = node['hostname'].split(".", 1)
171 raise PLCInvalidArgument("Node hostname {} is invalid".format(node['hostname']))
174 # Generate the node (plnode.txt) configuration content.
176 # This function will create the configuration file a node
178 # - a common part, regardless of the 'node_type' tag
179 # - XXX a special part, depending on the 'node_type' tag value.
180 def floppy_contents(self, node, renew_key):
183 if node['peer_id'] is not None:
184 raise PLCInvalidArgument("Not a local node {}".format(node['hostname']))
186 # If we are not an admin, make sure that the caller is a
187 # member of the site at which the node is located.
188 if 'admin' not in self.caller['roles']:
189 if node['site_id'] not in self.caller['site_ids']:
190 raise PLCPermissionDenied(
191 "Not allowed to generate a configuration file for {}"\
192 .format(node['hostname']))
194 # Get interface for this node
196 interfaces = Interfaces(self.api, node['interface_ids'])
197 for interface in interfaces:
198 if interface['is_primary']:
202 raise PLCInvalidArgument(
203 "No primary network configured on {}".format(node['hostname']))
205 host, domain = self.split_hostname(node)
207 # renew the key and save it on the database
209 node['key'] = compute_key()
210 node.update_last_download(commit=False)
213 # Generate node configuration file suitable for BootCD
217 file += 'NODE_ID="{}"\n'.format(node['node_id'])
218 file += 'NODE_KEY="{}"\n'.format(node['key'])
219 # not used anywhere, just a note for operations people
220 file += 'KEY_RENEWAL_DATE="{}"\n'\
221 .format(time.strftime('%Y/%m/%d at %H:%M +0000',time.gmtime()))
224 file += 'NET_DEVICE="{}"\n'.format(primary['mac'].lower())
226 file += 'IP_METHOD="{}"\n'.format(primary['method'])
228 if primary['method'] == 'static':
229 file += 'IP_ADDRESS="{}"\n'.format(primary['ip'])
230 file += 'IP_GATEWAY="{}"\n'.format(primary['gateway'])
231 file += 'IP_NETMASK="{}"\n'.format(primary['netmask'])
232 file += 'IP_NETADDR="{}"\n'.format(primary['network'])
233 file += 'IP_BROADCASTADDR="{}"\n'.format(primary['broadcast'])
234 file += 'IP_DNS1="{}"\n'.format(primary['dns1'])
235 file += 'IP_DNS2="{}"\n'.format(primary['dns2'] or "")
237 file += 'HOST_NAME="{}"\n'.format(host)
238 file += 'DOMAIN_NAME="{}"\n'.format(domain)
240 # define various interface settings attached to the primary interface
241 settings = InterfaceTags(self.api, {'interface_id':interface['interface_id']})
244 for setting in settings:
245 if setting['category'] is not None:
246 categories.add(setting['category'])
248 for category in categories:
249 category_settings = InterfaceTags(self.api,{'interface_id' : interface['interface_id'],
250 'category' : category})
251 if category_settings:
252 file += '### Category : {}\n'.format(category)
253 for setting in category_settings:
254 file += '{}_{}="{}"\n'\
255 .format(category.upper(), setting['tagname'].upper(), setting['value'])
257 for interface in interfaces:
258 if interface['method'] == 'ipmi':
259 file += 'IPMI_ADDRESS="{}"\n'.format(interface['ip'])
261 file += 'IPMI_MAC="{}"\n'.format(interface['mac'].lower())
266 # see also GetNodeFlavour that does similar things
267 def get_nodefamily(self, node, auth):
268 pldistro = self.api.config.PLC_FLAVOUR_NODE_PLDISTRO
269 fcdistro = self.api.config.PLC_FLAVOUR_NODE_FCDISTRO
270 arch = self.api.config.PLC_FLAVOUR_NODE_ARCH
272 return pldistro, fcdistro, arch
274 node_id = node['node_id']
276 # no support for deployment-based BootCD's, use kvariants instead
277 node_pldistro = GetNodePldistro(self.api,self.caller).call(auth, node_id)
279 pldistro = node_pldistro
281 node_fcdistro = GetNodeFcdistro(self.api,self.caller).call(auth, node_id)
283 fcdistro = node_fcdistro
285 node_arch = GetNodeArch(self.api,self.caller).call(auth,node_id)
289 return pldistro, fcdistro, arch
291 def bootcd_version(self):
293 with open(self.BOOTCDDIR + "/build/version.txt") as feed:
294 return feed.readline().strip()
296 raise Exception("Unknown boot cd version - probably wrong bootcd dir : {}"\
297 .format(self.BOOTCDDIR))
299 def cleantrash(self):
300 for file in self.trash:
302 logger.debug('DEBUG -- preserving trash file {}'.format(file))
307 # build the filename string
308 # check for permissions and concurrency
309 # returns the filename
310 def handle_filename(self, filename, nodename, suffix, arch):
311 # allow to set filename to None or any other empty value
312 if not filename: filename=''
313 filename = filename.replace("%d", self.WORKDIR)
314 filename = filename.replace("%n", nodename)
315 filename = filename.replace("%s", suffix)
316 filename = filename.replace("%p", self.api.config.PLC_NAME)
318 try: filename = filename.replace("%f", self.nodefamily)
320 try: filename = filename.replace("%a", arch)
322 try: filename = filename.replace("%v", self.bootcd_version())
325 ### Check filename location
327 if 'admin' not in self.caller['roles']:
328 if filename.index(self.WORKDIR) != 0:
329 raise PLCInvalidArgument("File {} not under {}".format(filename, self.WORKDIR))
331 ### output should not exist (concurrent runs ..)
332 # numerous reports of issues with this policy
333 # looks like people sometime suspend/cancel their download
334 # and this leads to the old file sitting in there forever
335 # so, if the file is older than 5 minutes, we just trash
337 if os.path.exists(filename) and (time.time()-os.path.getmtime(filename)) >= (grace*60):
339 if os.path.exists(filename):
340 raise PLCInvalidArgument(
341 "Resulting file {} already exists - please try again in {} minutes"\
342 .format(filename, grace))
344 ### we can now safely create the file,
345 ### either we are admin or under a controlled location
346 filedir = os.path.dirname(filename)
347 # dirname does not return "." for a local filename like its shell counterpart
349 if not os.path.exists(filedir):
351 os.makedirs(filedir, 0o777)
353 raise PLCPermissionDenied("Could not create dir {}".format(filedir))
357 def build_command(self, nodename, node_type, build_sh_spec, node_image, type, floppy_file):
360 (*) build command to be run
361 (*) location of the log_file
366 # regular node, make build's arguments
367 # and build the full command line to be called
368 if node_type not in [ 'regular', 'reservable' ]:
369 logger.error("GetBootMedium.build_command: unexpected node_type {}".format(node_type))
372 build_sh_options = ""
373 if "cramfs" in build_sh_spec:
375 if "serial" in build_sh_spec:
376 build_sh_options += " -s {}".format(build_sh_spec['serial'])
377 if "variant" in build_sh_spec:
378 build_sh_options += " -V {}".format(build_sh_spec['variant'])
380 for karg in build_sh_spec['kargs']:
381 build_sh_options += ' -k "{}"'.format(karg)
384 date = time.strftime('%Y-%m-%d-%H-%M', time.gmtime())
385 if not os.path.isdir(self.LOGDIR):
386 os.makedirs(self.LOGDIR)
387 log_file = "{}/{}-{}.log".format(self.LOGDIR, date, nodename)
389 command = '{} -f "{}" -o "{}" -t "{}" {} > {} 2>&1'\
390 .format(self.BOOTCDBUILD,
397 logger.info("The build command line is {}".format(command))
399 return command, log_file
401 def call(self, auth, node_id_or_hostname, action, filename, options=None):
408 ### compute file suffix and type
409 if action.find("-iso") >= 0 :
412 elif action.find("-usb") >= 0:
419 # check for node existence and get node_type
420 nodes = Nodes(self.api, [node_id_or_hostname])
422 raise PLCInvalidArgument("No such node {}".format(node_id_or_hostname))
425 logger.info("GetBootMedium: {} requested on node {}. Node type is: {}"
426 .format(action, node['node_id'], node['node_type']))
428 # check the required action against the node type
429 node_type = node['node_type']
430 if action not in allowed_actions[node_type]:
431 raise PLCInvalidArgument(
432 "Action {} not valid for {} nodes, valid actions are {}"
433 .format(action, node_type, "|".join(allowed_actions[node_type])))
435 # handle / canonicalize options
438 raise PLCInvalidArgument("Options are not supported for node configs")
440 # create a dict for build.sh
441 build_sh_spec = {'kargs':[]}
442 # use node tags as defaults
443 # check for node tag equivalents
444 tags = NodeTags(self.api,
445 {'node_id': node['node_id'],
446 'tagname': ['serial', 'cramfs', 'kvariant', 'kargs',
447 'no-hangcheck', 'systemd-debug']},
448 ['tagname', 'value'])
451 if tag['tagname'] == 'serial':
452 build_sh_spec['serial'] = tag['value']
453 elif tag['tagname'] == 'cramfs':
454 build_sh_spec['cramfs'] = True
455 elif tag['tagname'] == 'kvariant':
456 build_sh_spec['variant'] = tag['value']
457 elif tag['tagname'] == 'kargs':
458 build_sh_spec['kargs'] += tag['value'].split()
459 elif tag['tagname'] == 'no-hangcheck':
460 build_sh_spec['kargs'].append('hcheck_reboot0')
461 elif tag['tagname'] == 'systemd-debug':
463 build_sh_spec['kargs'].append('systemd.log_level=debug')
464 build_sh_spec['kargs'].append('systemd.log_target=console')
465 build_sh_spec['kargs'].append(
466 'systemd.default_standard_output=journal+console')
467 build_sh_spec['kargs'].append(
468 'systemd.default_standard_error=journal+console')
469 # then options can override tags
470 for option in options:
471 if option == "cramfs":
472 build_sh_spec['cramfs'] = True
473 elif option == 'partition':
475 raise PLCInvalidArgument("option 'partition' is for USB images only")
477 type = "usb_partition"
478 elif option == "serial":
479 build_sh_spec['serial'] = 'default'
480 elif option.find("serial:") == 0:
481 build_sh_spec['serial'] = option.replace("serial:", "")
482 elif option.find("variant:") == 0:
483 build_sh_spec['variant'] = option.replace("variant:", "")
484 elif option == "no-hangcheck":
485 build_sh_spec['kargs'].append('hcheck_reboot0')
486 elif option == "systemd-debug":
487 build_sh_spec['kargs'].append('systemd.log_level=debug')
488 build_sh_spec['kargs'].append('systemd.log_target=console')
489 build_sh_spec['kargs'].append(
490 'systemd.default_standard_output=journal+console')
491 build_sh_spec['kargs'].append(
492 'systemd.default_standard_error=journal+console')
494 raise PLCInvalidArgument("unknown option {}".format(option))
496 # compute nodename according the action
497 if action.find("node-") == 0:
498 nodename = node['hostname']
501 # compute a 8 bytes random number
502 tempbytes = random.sample(range(0, 256), 8);
504 return chr((c>>4)+65) + chr((c&16)+65)
505 nodename = "".join(map(hexa2, tempbytes))
508 pldistro, fcdistro, arch = self.get_nodefamily(node, auth)
509 self.nodefamily = "{}-{}-{}".format(pldistro, fcdistro, arch)
512 for attr in ["BOOTCDDIR", "BOOTCDBUILD", "GENERICDIR"]:
514 getattr(self, attr).replace("@NODEFAMILY@", self.nodefamily))
516 filename = self.handle_filename(filename, nodename, suffix, arch)
520 self.message = 'GetBootMedium on node {} - action={}'.format(nodename, action)
521 self.event_objects = {'Node': [node ['node_id']]}
523 self.message = 'GetBootMedium - generic - action={}'.format(action)
526 if action in ('generic-iso', 'generic-usb'):
528 raise PLCInvalidArgument("Options are not supported for generic images")
529 # this raises an exception if bootcd is missing
530 version = self.bootcd_version()
531 generic_name = "{}-BootCD-{}{}".format(self.api.config.PLC_NAME, version, suffix)
532 generic_path = "{}/{}".format(self.GENERICDIR, generic_name)
535 ret = os.system('cp "{}" "{}"'.format(generic_path, filename))
539 raise PLCPermissionDenied("Could not copy {} into {}"\
540 .format(generic_path, filename))
542 ### return the generic medium content as-is, just base64 encoded
543 with open(generic_path, "rb") as feed:
544 return base64.b64encode(feed.read()).decode()
546 ### config file preview or regenerated
547 if action == 'node-preview' or action == 'node-floppy':
548 renew_key = (action == 'node-floppy')
549 floppy = self.floppy_contents(node, renew_key)
552 with open(filename, 'w') as writer:
555 raise PLCPermissionDenied("Could not write into {}".format(filename))
560 ### we're left with node-iso and node-usb
561 # the steps involved in the image creation are:
562 # - create and test the working environment
563 # - generate the configuration file
564 # - build and invoke the build command
565 # - delivery the resulting image file
567 if action in ('node-iso', 'node-usb'):
569 ### check we've got required material
570 version = self.bootcd_version()
572 if not os.path.isfile(self.BOOTCDBUILD):
573 raise PLCAPIError("Cannot locate bootcd/build.sh script {}"
574 .format(self.BOOTCDBUILD))
576 # create the workdir if needed
577 if not os.path.isdir(self.WORKDIR):
579 os.makedirs(self.WORKDIR, 0o777)
580 os.chmod(self.WORKDIR, 0o777)
582 raise PLCPermissionDenied("Could not create dir {}".format(self.WORKDIR))
585 # generate floppy config
586 floppy_text = self.floppy_contents(node, True)
588 floppy_file = "{}/{}.txt".format(self.WORKDIR, nodename)
590 with open(floppy_file, "w") as writer:
591 writer.write(floppy_text)
593 raise PLCPermissionDenied("Could not write into {}".format(floppy_file))
595 self.trash.append(floppy_file)
597 node_image = "{}/{}{}".format(self.WORKDIR, nodename, suffix)
599 command, log_file = self.build_command(nodename, node_type, build_sh_spec,
600 node_image, type, floppy_file)
602 # invoke the image build script
604 ret = os.system(command)
607 raise PLCAPIError("{} failed Command line was: {} See logs in {}"\
608 .format(self.BOOTCDBUILD, command, log_file))
610 if not os.path.isfile(node_image):
611 raise PLCAPIError("Unexpected location of build.sh output - {}"
616 ret = os.system('mv "{}" "{}"'.format(node_image, filename))
618 self.trash.append(node_image)
620 raise PLCAPIError("Could not move node image {} into {}"\
621 .format(node_image, filename))
625 with open(node_image, "rb") as feed:
627 self.trash.append(node_image)
629 logger.info("GetBootMedium - done with build.sh")
630 # stupidly enough, we need to decode this as str now
631 # so that we remain compatible with former python2 PLCAPI
632 encoded_result = base64.b64encode(result).decode()
633 logger.info("GetBootMedium - done with base64 encoding -"
634 " lengths: raw={} - b64={}"
635 .format(len(result), len(encoded_result)))
636 return encoded_result
641 # we're done here, or we missed something
642 raise PLCAPIError('Unhandled action {}'.format(action))