diff --git a/releasenotes/notes/overcloud_ceph_deploy-485f59b64eb93c70.yaml b/releasenotes/notes/overcloud_ceph_deploy-485f59b64eb93c70.yaml new file mode 100644 index 000000000..e6cfd6fb8 --- /dev/null +++ b/releasenotes/notes/overcloud_ceph_deploy-485f59b64eb93c70.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + A new command "openstack overcloud ceph deploy" is added. The command is + used to deploy Ceph after the hardware has been provisioned with networking + and before the overcloud is deployed. The command takes the output of + "openstack overcloud node provision" as input and returns a Heat enviornment + file, e.g. deployed_ceph.yaml, as output. The deployed_ceph.yaml file may then + be passed to the "openstack overcloud deploy" command as input. During overcloud + deployment the Ceph cluster is then configured to host OpenStack. E.g. cephx keys + and pools are still created on the Ceph cluster by "openstack overcloud deploy". \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 79cce4f86..d6f705479 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,7 @@ openstack.tripleoclient.v2 = overcloud_admin_authorize = tripleoclient.v1.overcloud_admin:Authorize overcloud_netenv_validate = tripleoclient.v1.overcloud_netenv_validate:ValidateOvercloudNetenv overcloud_cell_export = tripleoclient.v1.overcloud_cell:ExportCell + overcloud_ceph_deploy = tripleoclient.v2.overcloud_ceph:OvercloudCephDeploy overcloud_config_download = tripleoclient.v1.overcloud_config:DownloadConfig overcloud_container_image_upload = tripleoclient.v1.container_image:UploadImage overcloud_container_image_build = tripleoclient.v1.container_image:BuildImage diff --git a/tripleoclient/tests/test_utils.py b/tripleoclient/tests/test_utils.py index d4b5664d2..17a262481 100644 --- a/tripleoclient/tests/test_utils.py +++ b/tripleoclient/tests/test_utils.py @@ -2295,3 +2295,41 @@ class TestProhibitedOverrides(base.TestCommand): environment) resource_registry.pop("neutron") self.assertIsNone(utils.check_neutron_resources(environment)) + + +class TestParseContainerImagePrepare(TestCase): + + fake_env = {'parameter_defaults': {'ContainerImagePrepare': + [{'push_destination': True, 'set': + {'ceph_image': 'ceph', + 'ceph_namespace': 'quay.io:443/ceph', + 'ceph_tag': 'latest'}}], + 'ContainerImageRegistryCredentials': + {'quay.io:443': {'quay_username': + 'quay_password'}}}} + + def test_parse_container_image_prepare(self): + key = 'ContainerImagePrepare' + keys = ['ceph_namespace', 'ceph_image', 'ceph_tag'] + reg_expected = {'ceph_image': 'ceph', + 'ceph_namespace': 'quay.io:443/ceph', + 'ceph_tag': 'latest'} + with tempfile.NamedTemporaryFile(mode='w') as cfgfile: + yaml.safe_dump(self.fake_env, cfgfile) + reg_actual = \ + utils.parse_container_image_prepare(key, keys, + cfgfile.name) + self.assertEqual(reg_actual, reg_expected) + + def test_parse_container_image_prepare_credentials(self): + key = 'ContainerImageRegistryCredentials' + keys = ['quay.io:443/ceph'] + reg_expected = {'registry_url': 'quay.io:443', + 'registry_username': 'quay_username', + 'registry_password': 'quay_password'} + with tempfile.NamedTemporaryFile(mode='w') as cfgfile: + yaml.safe_dump(self.fake_env, cfgfile) + reg_actual = \ + utils.parse_container_image_prepare(key, keys, + cfgfile.name) + self.assertEqual(reg_actual, reg_expected) diff --git a/tripleoclient/tests/v2/overcloud_ceph/__init__.py b/tripleoclient/tests/v2/overcloud_ceph/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tripleoclient/tests/v2/overcloud_ceph/test_overcloud_ceph.py b/tripleoclient/tests/v2/overcloud_ceph/test_overcloud_ceph.py new file mode 100644 index 000000000..17dc5e98b --- /dev/null +++ b/tripleoclient/tests/v2/overcloud_ceph/test_overcloud_ceph.py @@ -0,0 +1,77 @@ +# Copyright 2021 Red Hat, Inc. +# +# 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. +# + +import mock + +from osc_lib import exceptions as osc_lib_exc + +from tripleoclient.tests import fakes +from tripleoclient.v2 import overcloud_ceph + + +class TestOvercloudCephDeploy(fakes.FakePlaybookExecution): + + def setUp(self): + super(TestOvercloudCephDeploy, self).setUp() + + # Get the command object to test + app_args = mock.Mock() + app_args.verbose_level = 1 + self.app.options = fakes.FakeOptions() + self.cmd = overcloud_ceph.OvercloudCephDeploy(self.app, + app_args) + + @mock.patch('tripleoclient.utils.TempDirs', autospect=True) + @mock.patch('os.path.abspath', autospect=True) + @mock.patch('os.path.exists', autospect=True) + @mock.patch('tripleoclient.utils.run_ansible_playbook', autospec=True) + def test_overcloud_deploy_ceph(self, mock_playbook, mock_abspath, + mock_path_exists, mock_tempdirs): + arglist = ['deployed-metal.yaml', '--yes', + '--stack', 'overcloud', + '--output', 'deployed-ceph.yaml', + '--container-namespace', 'quay.io/ceph', + '--container-image', 'ceph', + '--container-tag', 'latest'] + parsed_args = self.check_parser(self.cmd, arglist, []) + self.cmd.take_action(parsed_args) + mock_playbook.assert_called_once_with( + playbook='cli-deployed-ceph.yaml', + inventory=mock.ANY, + workdir=mock.ANY, + playbook_dir=mock.ANY, + verbosity=3, + extra_vars={ + "baremetal_deployed_path": mock.ANY, + "deployed_ceph_tht_path": mock.ANY, + "working_dir": mock.ANY, + "stack_name": 'overcloud', + 'tripleo_roles_path': mock.ANY, + 'tripleo_cephadm_container_ns': 'quay.io/ceph', + 'tripleo_cephadm_container_image': 'ceph', + 'tripleo_cephadm_container_tag': 'latest', + } + ) + + @mock.patch('os.path.abspath', autospect=True) + @mock.patch('os.path.exists', autospect=True) + def test_overcloud_deploy_ceph_no_overwrite(self, mock_abspath, + mock_path_exists): + arglist = ['deployed-metal.yaml', + '--stack', 'overcloud', + '--output', 'deployed-ceph.yaml'] + parsed_args = self.check_parser(self.cmd, arglist, []) + self.assertRaises(osc_lib_exc.CommandError, + self.cmd.take_action, parsed_args) diff --git a/tripleoclient/utils.py b/tripleoclient/utils.py index 9b8e10bd3..048ffcb26 100644 --- a/tripleoclient/utils.py +++ b/tripleoclient/utils.py @@ -59,6 +59,7 @@ from six.moves import configparser from heatclient import exc as hc_exc from six.moves.urllib import error as url_error +from six.moves.urllib import parse as url_parse from six.moves.urllib import request from tenacity import retry @@ -2968,3 +2969,86 @@ def check_prohibited_overrides(protected_overrides, user_environments): if found_conflict: raise exceptions.DeploymentError(msg) + + +def parse_container_image_prepare(tht_key='ContainerImagePrepare', + keys=[], source=None): + """Extracts key/value pairs from list of keys in source file + If keys=[foo,bar] and source is the following, + then return {foo: 1, bar: 2} + + parameter_defaults: + ContainerImagePrepare: + - tag_from_label: grault + set: + foo: 1 + bar: 2 + namespace: quay.io/garply + ContainerImageRegistryCredentials: + 'quay.io': {'quay_username': 'quay_password'} + + Alternatively, if tht_key='ContainerImageRegistryCredentials' and + keys=['quay.io/garply'] for the above, then return the following: + + {'registry_url': 'quay.io', + 'registry_username': 'quay_username', + 'registry_password': 'quay_password'} + + If the tht_key is not found, return an empty dictionary + + :param tht_key: string of a THT parameter (only 2 options) + :param keys: list of keys to extract + :param source: (string) path to container_image_prepare_defaults.yaml + + :return: dictionary + """ + image_map = {} + if source is None: + source = kolla_builder.DEFAULT_PREPARE_FILE + if not os.path.exists(source): + raise RuntimeError( + "Path to container image prepare defaults file " + "not found: %s." % os.path.abspath(source)) + with open(source, 'r') as stream: + try: + images = yaml.safe_load(stream) + except yaml.YAMLError as exc: + raise RuntimeError( + "yaml.safe_load(%s) returned '%s'" % (source, exc)) + + if tht_key == 'ContainerImagePrepare': + try: + tag_list = images['parameter_defaults'][tht_key] + for key in keys: + for tag in tag_list: + if 'set' in tag: + if key in tag['set']: + image_map[key] = tag['set'][key] + except KeyError: + raise RuntimeError( + "The expected parameter_defaults and %s are not " + "defined in data file: %s" % (tht_key, source)) + elif tht_key == 'ContainerImageRegistryCredentials': + try: + tag_list = images['parameter_defaults'][tht_key] + for key in keys: + for tag in tag_list: + registry = url_parse.urlparse(key).netloc + if len(registry) == 0: + registry = url_parse.urlparse('//' + key).netloc + if tag == registry: + if isinstance(tag_list[registry], collections.Mapping): + credentials = tag_list[registry].popitem() + image_map['registry_username'] = credentials[0] + image_map['registry_password'] = credentials[1] + image_map['registry_url'] = registry + except KeyError: + LOG.info("Unable to parse %s from %s. " + "Assuming the container registry does not " + "require authentication or that the " + "registry URL, username and password " + "will be passed another way." + % (tht_key, source)) + else: + raise RuntimeError("Unsupported tht_key: %s" % tht_key) + return image_map diff --git a/tripleoclient/v2/overcloud_ceph.py b/tripleoclient/v2/overcloud_ceph.py new file mode 100644 index 000000000..7c7b31fa9 --- /dev/null +++ b/tripleoclient/v2/overcloud_ceph.py @@ -0,0 +1,272 @@ +# Copyright 2021 Red Hat, Inc. +# +# 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. +# + +import logging +import os + +from osc_lib import exceptions as oscexc +from osc_lib.i18n import _ +from osc_lib import utils + +from tripleoclient import command +from tripleoclient import constants +from tripleoclient import utils as oooutils + + +class OvercloudCephDeploy(command.Command): + + log = logging.getLogger(__name__ + ".OvercloudCephDeploy") + + def get_parser(self, prog_name): + parser = super(OvercloudCephDeploy, self).get_parser(prog_name) + + parser.add_argument('baremetal_env', + metavar='', + help=_('Path to the environment file ' + 'output from "openstack ' + 'overcloud node provision".')) + parser.add_argument('-o', '--output', required=True, + metavar='', + help=_('The path to the output environment ' + 'file describing the Ceph deployment ' + ' to pass to the overcloud deployment.')) + parser.add_argument('-y', '--yes', default=False, action='store_true', + help=_('Skip yes/no prompt before overwriting an ' + 'existing output file ' + '(assume yes).')) + parser.add_argument('--stack', dest='stack', + help=_('Name or ID of heat stack ' + '(default=Env: OVERCLOUD_STACK_NAME)'), + default=utils.env('OVERCLOUD_STACK_NAME', + default='overcloud')) + parser.add_argument( + '--working-dir', action='store', + help=_('The working directory for the deployment where all ' + 'input, output, and generated files will be stored.\n' + 'Defaults to "$HOME/overcloud-deploy/"')) + parser.add_argument('--roles-data', + help=_( + "Path to an alternative roles_data.yaml. " + "Used to decide which node gets which " + "Ceph mon, mgr, or osd service " + "based on the node's role in " + "."), + default=os.path.join( + constants.TRIPLEO_HEAT_TEMPLATES, + constants.OVERCLOUD_ROLES_FILE)) + spec_group = parser.add_mutually_exclusive_group() + spec_group.add_argument('--ceph-spec', + help=_( + "Path to an existing Ceph spec file. " + "If not provided a spec will be generated " + "automatically based on --roles-data and " + ""), + default=None) + spec_group.add_argument('--osd-spec', + help=_( + "Path to an existing OSD spec file. " + "Mutually exclusive with --ceph-spec. " + "If the Ceph spec file is generated " + "automatically, then the OSD spec " + "in the Ceph spec file defaults to " + "{data_devices: {all: true}} " + "for all service_type osd. " + "Use --osd-spec to override the " + "data_devices value inside the " + "Ceph spec file."), + default=None) + parser.add_argument('--container-image-prepare', + help=_( + "Path to an alternative " + "container_image_prepare_defaults.yaml. " + "Used to control which Ceph container is " + "pulled by cephadm via the ceph_namespace, " + "ceph_image, and ceph_tag variables in " + "addition to registry authentication via " + "ContainerImageRegistryCredentials." + ), + default=None) + container_group = parser.add_argument_group("container-image-prepare " + "overrides", + "The following options " + "may be used to override " + "individual values " + "set via " + "--container-image-prepare" + ". If the example " + "variables below were " + "set the image would be " + "concatenated into " + "quay.io/ceph/ceph:latest " + "and a custom registry " + "login would be used." + ) + container_group.add_argument('--container-namespace', + required=False, + help='e.g. quay.io/ceph') + container_group.add_argument('--container-image', + required=False, + help='e.g. ceph') + container_group.add_argument('--container-tag', + required=False, + help='e.g. latest') + container_group.add_argument('--registry-url', + required=False, + help='') + container_group.add_argument('--registry-username', + required=False, + help='') + container_group.add_argument('--registry-password', + required=False, + help='') + + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)" % parsed_args) + + baremetal_env_path = os.path.abspath(parsed_args.baremetal_env) + output_path = os.path.abspath(parsed_args.output) + + if not os.path.exists(baremetal_env_path): + raise oscexc.CommandError( + "Baremetal environment file does not exist:" + " %s" % parsed_args.baremetal_env) + + overwrite = parsed_args.yes + if (os.path.exists(output_path) and not overwrite + and not oooutils.prompt_user_for_confirmation( + 'Overwrite existing file %s [y/N]?' % parsed_args.output, + self.log)): + raise oscexc.CommandError("Will not overwrite existing file:" + " %s. See the --yes parameter to " + "override this behavior. " % + parsed_args.output) + else: + overwrite = True + + if not parsed_args.working_dir: + working_dir = oooutils.get_default_working_dir( + parsed_args.stack) + else: + working_dir = os.path.abspath(parsed_args.working_dir) + oooutils.makedirs(working_dir) + + inventory = os.path.join(working_dir, + constants.TRIPLEO_STATIC_INVENTORY) + if not os.path.exists(inventory): + raise oscexc.CommandError( + "Inventory file not found in working directory: " + "%s. It should have been created by " + "'openstack overcloud node provision'." + % inventory) + + # mandatory extra_vars are now set, add others conditionally + extra_vars = { + "baremetal_deployed_path": baremetal_env_path, + "deployed_ceph_tht_path": output_path, + "working_dir": working_dir, + "stack_name": parsed_args.stack, + } + + # optional paths to pass to playbook + if parsed_args.roles_data: + if not os.path.exists(parsed_args.roles_data): + raise oscexc.CommandError( + "Roles Data file not found --roles-data %s." + % os.path.abspath(parsed_args.roles_data)) + else: + extra_vars['tripleo_roles_path'] = \ + os.path.abspath(parsed_args.roles_data) + + if parsed_args.ceph_spec: + if not os.path.exists(parsed_args.ceph_spec): + raise oscexc.CommandError( + "Ceph Spec file not found --ceph-spec %s." + % os.path.abspath(parsed_args.ceph_spec)) + else: + extra_vars['dynamic_ceph_spec'] = False + extra_vars['ceph_spec_path'] = \ + os.path.abspath(parsed_args.ceph_spec) + + if parsed_args.osd_spec: + if not os.path.exists(parsed_args.osd_spec): + raise oscexc.CommandError( + "OSD Spec file not found --osd-spec %s." + % os.path.abspath(parsed_args.osd_spec)) + else: + extra_vars['osd_spec_path'] = \ + os.path.abspath(parsed_args.osd_spec) + + # optional container vars to pass to playbook + keys = ['ceph_namespace', 'ceph_image', 'ceph_tag'] + key = 'ContainerImagePrepare' + container_dict = \ + oooutils.parse_container_image_prepare(key, keys, + parsed_args. + container_image_prepare) + extra_vars['tripleo_cephadm_container_ns'] = \ + parsed_args.container_namespace or \ + container_dict['ceph_namespace'] + extra_vars['tripleo_cephadm_container_image'] = \ + parsed_args.container_image or \ + container_dict['ceph_image'] + extra_vars['tripleo_cephadm_container_tag'] = \ + parsed_args.container_tag or \ + container_dict['ceph_tag'] + + # optional container registry vars to pass to playbook + if 'tripleo_cephadm_container_ns' in extra_vars: + keys = [extra_vars['tripleo_cephadm_container_ns']] + key = 'ContainerImageRegistryCredentials' + registry_dict = \ + oooutils.parse_container_image_prepare(key, keys, + parsed_args. + container_image_prepare) + # It's valid for the registry_dict to be empty so + # we cannot default to it with an 'or' like we can + # for ceph_{namespace,image,tag} as above. + if 'registry_url' in registry_dict: + extra_vars['tripleo_cephadm_registry_url'] = \ + registry_dict['registry_url'] + if 'registry_password' in registry_dict: + extra_vars['tripleo_cephadm_registry_password'] = \ + registry_dict['registry_password'] + if 'registry_username' in registry_dict: + extra_vars['tripleo_cephadm_registry_username'] = \ + registry_dict['registry_username'] + # Whether registry vars came out of --container-image-prepare + # or not, we need either to set them (as above) or override + # them if they were passed via the CLI (as follows) + if parsed_args.registry_url: + extra_vars['tripleo_cephadm_registry_url'] = \ + parsed_args.registry_url + if parsed_args.registry_password: + extra_vars['tripleo_cephadm_registry_password'] = \ + parsed_args.registry_password + if parsed_args.registry_username: + extra_vars['tripleo_cephadm_registry_username'] = \ + parsed_args.registry_username + + # call the playbook + with oooutils.TempDirs() as tmp: + oooutils.run_ansible_playbook( + playbook='cli-deployed-ceph.yaml', + inventory=inventory, + workdir=tmp, + playbook_dir=constants.ANSIBLE_TRIPLEO_PLAYBOOKS, + verbosity=oooutils.playbook_verbosity(self=self), + extra_vars=extra_vars, + )