ironic-python-agent/ironic_python_agent/inject_files.py

257 lines
9.3 KiB
Python

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implementation of the inject_files deploy step."""
import base64
import contextlib
import os
from ironic_lib import disk_utils
from ironic_lib import utils as ironic_utils
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_log import log
from ironic_python_agent import errors
from ironic_python_agent import hardware
from ironic_python_agent import utils
CONF = cfg.CONF
LOG = log.getLogger(__name__)
ARGSINFO = {
"files": {
"description": (
"Files to inject, a list of file structures with keys: 'path' "
"(path to the file), 'partition' (partition specifier), "
"'content' (base64 encoded string), 'mode' (new file mode) and "
"'dirmode' (mode for the leaf directory, if created). "
"Merged with the values from node.properties[inject_files]."
),
"required": False,
},
"verify_ca": {
"description": (
"Whether to verify TLS certificates. Global agent options "
"are used by default."
),
"required": False,
}
}
def inject_files(node, ports, files, verify_ca=True):
"""A deploy step to inject arbitrary files.
:param node: A dictionary of the node object
:param ports: A list of dictionaries containing information
of ports for the node
:param files: See ARGSINFO.
:param verify_ca: Whether to verify TLS certificate.
:raises: InvalidCommandParamsError
"""
files = _validate_files(
node['properties'].get('inject_files') or [],
files or [])
if not files:
LOG.info('No files to inject')
return
http_get = utils.StreamingClient(verify_ca)
root_dev = hardware.dispatch_to_managers('get_os_install_device')
for fl in files:
_inject_one(node, ports, fl, root_dev, http_get)
def _inject_one(node, ports, fl, root_dev, http_get):
"""Inject one file.
:param node: A dictionary of the node object
:param ports: A list of dictionaries containing information
of ports for the node
:param fl: File information.
:param root_dev: Root device used for the current node.
:param http_get: Context manager to get HTTP URLs.
"""
with _find_and_mount_path(fl['path'], fl.get('partition'),
root_dev) as path:
if fl.get('deleted'):
ironic_utils.unlink_without_raise(path)
return
try:
dirpath = os.path.dirname(path)
try:
os.makedirs(dirpath)
except FileExistsError:
pass
else:
# Use chmod here and below to avoid relying on umask
if fl.get('dirmode'):
os.chmod(dirpath, fl['dirmode'])
content = fl['content']
with open(path, 'wb') as fp:
if '://' in content:
# Allow node-specific URLs to be used in a deploy template
url = content.format(node=node, ports=ports)
with http_get(url) as resp:
for chunk in resp:
fp.write(chunk)
else:
fp.write(base64.b64decode(content))
if fl.get('mode'):
os.chmod(path, fl['mode'])
if fl.get('owner') is not None or fl.get('group') is not None:
# -1 means do not change
os.chown(path, fl.get('owner', -1), fl.get('group', -1))
except Exception as exc:
LOG.exception('Failed to process file %s', fl)
raise errors.CommandExecutionError(
'Failed to process file %s. %s: %s'
% (fl, type(exc).__class__, exc))
@contextlib.contextmanager
def _find_and_mount_path(path, partition, root_dev):
"""Find the specified path on a device.
Tries to find the suitable device for the file based on the ``path`` and
``partition``, mount the device and provides the actual full path.
:param path: Path to the file to find.
:param partition: Device to find the file on or None.
:param root_dev: Root device from the hardware manager.
:return: Context manager that yields the full path to the file.
"""
path = os.path.normpath(path.strip('/')) # to make path joining work
if partition:
try:
part_num = int(partition)
except ValueError:
with ironic_utils.mounted(partition) as part_path:
yield os.path.join(part_path, path)
else:
# TODO(dtantsur): switch to ironic-lib instead:
# https://review.opendev.org/c/openstack/ironic-lib/+/774502
part_template = '%s%s'
if 'nvme' in root_dev:
part_template = '%sp%s'
part_dev = part_template % (root_dev, part_num)
with ironic_utils.mounted(part_dev) as part_path:
yield os.path.join(part_path, path)
else:
try:
# This turns e.g. etc/sysctl.d/my.conf into etc + sysctl.d/my.conf
detect_dir, rest_dir = path.split('/', 1)
except ValueError:
# Validation ensures that files in / have "partition" present,
# checking here just in case.
raise errors.InvalidCommandParamsError(
"Invalid path %s, must be an absolute path to a file" % path)
with find_partition_with_path(detect_dir, root_dev) as part_path:
yield os.path.join(part_path, rest_dir)
@contextlib.contextmanager
def find_partition_with_path(path, device=None):
"""Find a partition with the given path.
:param path: Expected path.
:param device: Target device. If None, the root device is used.
:returns: A context manager that will unmount and delete the temporary
mount point on exit.
"""
if device is None:
device = hardware.dispatch_to_managers('get_os_install_device')
partitions = disk_utils.list_partitions(device)
# Make os.path.join work as expected
lookup_path = path.lstrip('/')
for part in partitions:
if 'lvm' in part['flags']:
LOG.debug('Skipping LVM partition %s', part)
continue
# TODO(dtantsur): switch to ironic-lib instead:
# https://review.opendev.org/c/openstack/ironic-lib/+/774502
part_template = '%s%s'
if 'nvme' in device:
part_template = '%sp%s'
part_path = part_template % (device, part['number'])
LOG.debug('Inspecting partition %s for path %s', part, path)
try:
with ironic_utils.mounted(part_path) as local_path:
found_path = os.path.join(local_path, lookup_path)
if not os.path.isdir(found_path):
continue
LOG.info('Path %s has been found on partition %s', path, part)
yield found_path
return
except processutils.ProcessExecutionError as exc:
LOG.warning('Failure when inspecting partition %s: %s', part, exc)
raise errors.DeviceNotFound("No partition found with path %s, scanned: %s"
% (path, partitions))
def _validate_files(from_properties, from_args):
"""Sanity check for files."""
if not isinstance(from_properties, list):
raise errors.InvalidCommandParamsError(
"The `inject_files` node property must be a list, got %s"
% type(from_properties).__name__)
if not isinstance(from_args, list):
raise errors.InvalidCommandParamsError(
"The `files` argument must be a list, got %s"
% type(from_args).__name__)
files = from_properties + from_args
failures = []
for fl in files:
unknown = set(fl) - {'path', 'partition', 'content', 'deleted', 'mode',
'dirmode', 'owner', 'group'}
if unknown:
failures.append('unexpected fields in %s: %s'
% (fl, ', '.join(unknown)))
if not fl.get('path'):
failures.append('expected a path in %s' % fl)
elif os.path.dirname(fl['path']) == '/' and not fl.get('partition'):
failures.append('%s in root directory requires "partition"' % fl)
elif fl['path'].endswith('/'):
failures.append('directories not supported for %s' % fl)
if fl.get('content') and fl.get('deleted'):
failures.append('content cannot be used with deleted in %s' % fl)
for field in ('owner', 'group', 'mode', 'dirmode'):
if field in fl and type(fl[field]) is not int:
failures.append('%s must be a number in %s' % (field, fl))
if failures:
raise errors.InvalidCommandParamsError(
"Validation of files failed: %s" % '; '.join(failures))
return files