diff --git a/releasenotes/notes/overcloud_ceph_spec-e1cfd358c4db2b22.yaml b/releasenotes/notes/overcloud_ceph_spec-e1cfd358c4db2b22.yaml new file mode 100644 index 000000000..e2182bedb --- /dev/null +++ b/releasenotes/notes/overcloud_ceph_spec-e1cfd358c4db2b22.yaml @@ -0,0 +1,14 @@ +--- +features: + - | + New command "openstack overcloud ceph spec" has been added. This command + may be used to create a cephadm spec file as a function of the output of + metalsmith and a TripleO roles file. For example, if metalsmith output a + file with multiple hosts of differing roles and each role contained various + Ceph services, then a cephadm spec file could parse these files and return + input compatible with cephadm. The ceph spec file may be then be passed to + "openstack overcloud ceph deploy" so that cephadm deploys only those Ceph + services on those hosts. This feature should save users from the need to + create two different files containing much of the same data and make it + easier and less error prone to include Ceph in a deployment without the + need to manually create the Ceph spec file. diff --git a/setup.cfg b/setup.cfg index eafffac45..1310002b1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ openstack.tripleoclient.v2 = 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_ceph_spec = tripleoclient.v2.overcloud_ceph:OvercloudCephSpec overcloud_ceph_user_disable = tripleoclient.v2.overcloud_ceph:OvercloudCephUserDisable overcloud_ceph_user_enable = tripleoclient.v2.overcloud_ceph:OvercloudCephUserEnable overcloud_container_image_upload = tripleoclient.v1.container_image:UploadImage diff --git a/tripleoclient/tests/v2/overcloud_ceph/test_overcloud_ceph.py b/tripleoclient/tests/v2/overcloud_ceph/test_overcloud_ceph.py index f76eda46a..1552485db 100644 --- a/tripleoclient/tests/v2/overcloud_ceph/test_overcloud_ceph.py +++ b/tripleoclient/tests/v2/overcloud_ceph/test_overcloud_ceph.py @@ -45,6 +45,7 @@ class TestOvercloudCephDeploy(fakes.FakePlaybookExecution): '--stack', 'overcloud', '--skip-user-create', '--skip-hosts-config', + '--mon-ip', '127.0.0.1', '--cephadm-ssh-user', 'jimmy', '--output', 'deployed-ceph.yaml', '--container-namespace', 'quay.io/ceph', @@ -66,8 +67,10 @@ class TestOvercloudCephDeploy(fakes.FakePlaybookExecution): "deployed_ceph_tht_path": mock.ANY, "working_dir": mock.ANY, "stack_name": 'overcloud', + "tripleo_cephadm_standalone": False, 'tripleo_cephadm_ssh_user': 'jimmy', 'tripleo_cephadm_cluster': 'ceph', + 'tripleo_cephadm_first_mon_ip': '127.0.0.1', 'tripleo_roles_path': mock.ANY, 'tripleo_cephadm_container_ns': 'quay.io/ceph', 'tripleo_cephadm_container_image': 'ceph', @@ -75,6 +78,53 @@ class TestOvercloudCephDeploy(fakes.FakePlaybookExecution): } ) + @mock.patch('tripleoclient.utils.get_ceph_networks', autospect=True) + @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_deploy_ceph_spec(self, mock_playbook, mock_abspath, + mock_path_exists, mock_tempdirs, + mock_get_ceph_networks): + arglist = ['--yes', + '--stack', 'overcloud', + '--skip-user-create', + '--skip-hosts-config', + '--mon-ip', '127.0.0.1', + '--ceph-spec', 'ceph_spec.yaml', + '--cephadm-ssh-user', 'jimmy', + '--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, + skip_tags='cephadm_ssh_user', + reproduce_command=False, + extra_vars_file=mock.ANY, + extra_vars={ + "deployed_ceph_tht_path": mock.ANY, + "working_dir": mock.ANY, + "stack_name": 'overcloud', + "tripleo_cephadm_standalone": False, + 'tripleo_roles_path': mock.ANY, + 'tripleo_cephadm_first_mon_ip': '127.0.0.1', + 'tripleo_cephadm_cluster': 'ceph', + 'dynamic_ceph_spec': False, + 'ceph_spec_path': mock.ANY, + 'tripleo_cephadm_container_ns': 'quay.io/ceph', + 'tripleo_cephadm_container_image': 'ceph', + 'tripleo_cephadm_container_tag': 'latest', + 'tripleo_cephadm_ssh_user': 'jimmy', + } + ) + @mock.patch('os.path.abspath', autospect=True) @mock.patch('os.path.exists', autospect=True) def test_overcloud_deploy_ceph_no_overwrite(self, mock_abspath, @@ -104,6 +154,16 @@ class TestOvercloudCephDeploy(fakes.FakePlaybookExecution): self.assertRaises(osc_lib_exc.CommandError, self.cmd.take_action, parsed_args) + @mock.patch('os.path.abspath', autospect=True) + @mock.patch('os.path.exists', autospect=True) + def test_overcloud_deploy_ceph_no_metal(self, mock_abspath, + mock_path_exists): + arglist = ['--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) + class TestOvercloudCephUserDisable(fakes.FakePlaybookExecution): def setUp(self): @@ -265,3 +325,45 @@ class TestOvercloudCephUserEnable(fakes.FakePlaybookExecution): "tripleo_cephadm_action": 'enable' } ) + + +class TestOvercloudCephSpec(fakes.FakePlaybookExecution): + + def setUp(self): + super(TestOvercloudCephSpec, 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.OvercloudCephSpec(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_ceph_spec(self, mock_playbook, mock_abspath, + mock_path_exists, mock_tempdirs): + arglist = ['deployed-metal.yaml', '--yes', + '--stack', 'overcloud', + '--roles-data', 'roles_data.yaml', + '--osd-spec', 'osd_spec.yaml', + '--output', 'ceph_spec.yaml'] + 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, + tags='ceph_spec', + reproduce_command=False, + extra_vars={ + "baremetal_deployed_path": mock.ANY, + 'tripleo_roles_path': mock.ANY, + 'osd_spec_path': mock.ANY, + 'ceph_spec_path': mock.ANY, + } + ) diff --git a/tripleoclient/utils.py b/tripleoclient/utils.py index 537e2ef2a..f0b75369c 100644 --- a/tripleoclient/utils.py +++ b/tripleoclient/utils.py @@ -3226,6 +3226,29 @@ def get_host_groups_from_ceph_spec(ceph_spec_path, prefix='', return hosts +def standalone_ceph_inventory(working_dir): + """return an ansible inventory for deployed ceph standalone + :param working_dir: directory where inventory should be written + :return string: the path to the inventory + """ + host = get_hostname() + inv = \ + {'Standalone': + {'hosts': {host: {}, + 'undercloud': {}}, + 'vars': {'ansible_connection': 'local', + 'ansible_host': host, + 'ansible_python_interpreter': sys.executable}}, + 'allovercloud': + {'children': {'Standalone': {}}}} + + path = os.path.join(working_dir, + constants.TRIPLEO_STATIC_INVENTORY) + with open(path, 'w') as f: + f.write(yaml.safe_dump(inv)) + return path + + def cleanup_host_entry(entry): # remove any tab or space excess entry_stripped = re.sub('[ \t]+', ' ', str(entry).rstrip()) diff --git a/tripleoclient/v2/overcloud_ceph.py b/tripleoclient/v2/overcloud_ceph.py index 68afd4ee6..2d9989fb6 100644 --- a/tripleoclient/v2/overcloud_ceph.py +++ b/tripleoclient/v2/overcloud_ceph.py @@ -79,11 +79,13 @@ class OvercloudCephDeploy(command.Command): def get_parser(self, prog_name): parser = super(OvercloudCephDeploy, self).get_parser(prog_name) - parser.add_argument('baremetal_env', + parser.add_argument('baremetal_env', nargs='?', metavar='', help=_('Path to the environment file ' 'output from "openstack ' - 'overcloud node provision".')) + 'overcloud node provision". ' + 'This argument may be excluded ' + 'only if --ceph-spec is used.')) parser.add_argument('-o', '--output', required=True, metavar='', help=_('The path to the output environment ' @@ -176,6 +178,14 @@ class OvercloudCephDeploy(command.Command): "'ceph --cluster foo health' unless export " "CEPH_ARGS='--cluster foo' is used."), default='ceph') + parser.add_argument('--mon-ip', + help=_( + "IP address of the first Ceph monitor. " + "If not set, an IP from the Ceph " + "public_network of a server with the " + "mon label from the Ceph spec is used. " + "IP must already be active on server."), + default='') parser.add_argument('--config', help=_( "Path to an existing ceph.conf with settings " @@ -247,10 +257,12 @@ class OvercloudCephDeploy(command.Command): 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 " + "Path to an existing Ceph spec file. If " + "not provided a spec will be generated " "automatically based on --roles-data and " - ""), + ". The " + " parameter is " + "optional only if --ceph-spec is used."), default=None) spec_group.add_argument('--osd-spec', help=_( @@ -270,6 +282,11 @@ class OvercloudCephDeploy(command.Command): "Path to an existing crush hierarchy spec " "file. "), default=None) + parser.add_argument('--standalone', default=False, + action='store_true', + help=_("Use single host Ansible inventory. " + "Used only for development or testing " + "environments.")) parser.add_argument('--container-image-prepare', help=_( "Path to an alternative " @@ -327,14 +344,7 @@ class OvercloudCephDeploy(command.Command): 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( @@ -354,8 +364,11 @@ class OvercloudCephDeploy(command.Command): working_dir = os.path.abspath(parsed_args.working_dir) oooutils.makedirs(working_dir) - inventory = os.path.join(working_dir, - constants.TRIPLEO_STATIC_INVENTORY) + if parsed_args.standalone: + inventory = oooutils.standalone_ceph_inventory(working_dir) + else: + 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: " @@ -365,13 +378,29 @@ class OvercloudCephDeploy(command.Command): # 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, + "tripleo_cephadm_standalone": parsed_args.standalone } extra_vars_file = None # optional paths to pass to playbook + if parsed_args.ceph_spec is None and \ + parsed_args.baremetal_env is None: + raise oscexc.CommandError( + "Either " + "or --ceph-spec must be used.") + + if parsed_args.baremetal_env: + baremetal_env_path = os.path.abspath(parsed_args.baremetal_env) + if not os.path.exists(baremetal_env_path): + raise oscexc.CommandError( + "Baremetal environment file does not exist:" + " %s" % parsed_args.baremetal_env) + else: + extra_vars['baremetal_deployed_path'] = \ + os.path.abspath(parsed_args.baremetal_env) + if parsed_args.roles_data: if not os.path.exists(parsed_args.roles_data): raise oscexc.CommandError( @@ -406,6 +435,15 @@ class OvercloudCephDeploy(command.Command): extra_vars['tripleo_cephadm_cluster'] = \ parsed_args.cluster + if parsed_args.mon_ip: + if not oooutils.is_valid_ip(parsed_args.mon_ip): + raise oscexc.CommandError( + "Invalid IP address '%s' passed to --mon-ip." + % parsed_args.mon_ip) + else: + extra_vars['tripleo_cephadm_first_mon_ip'] = \ + parsed_args.mon_ip + if parsed_args.ceph_spec: if not os.path.exists(parsed_args.ceph_spec): raise oscexc.CommandError( @@ -658,6 +696,11 @@ class OvercloudCephUserDisable(command.Command): metavar='', required=True, help=_("The FSID of the Ceph cluster to be " "disabled. Required for disable option.")) + parser.add_argument('--standalone', default=False, + action='store_true', + help=_("Use single host Ansible inventory. " + "Used only for development or testing " + "environments.")) return parser @@ -693,8 +736,11 @@ class OvercloudCephUserDisable(command.Command): working_dir = os.path.abspath(parsed_args.working_dir) oooutils.makedirs(working_dir) - inventory = os.path.join(working_dir, - constants.TRIPLEO_STATIC_INVENTORY) + if parsed_args.standalone: + inventory = oooutils.standalone_ceph_inventory(working_dir) + else: + 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: " @@ -777,6 +823,11 @@ class OvercloudCephUserEnable(command.Command): "so that cephadm will be re-enabled " "for the Ceph cluster idenified " "by the FSID.")) + parser.add_argument('--standalone', default=False, + action='store_true', + help=_("Use single host Ansible inventory. " + "Used only for development or testing " + "environments.")) parser = arg_parse_common(parser) return parser @@ -807,8 +858,11 @@ class OvercloudCephUserEnable(command.Command): working_dir = os.path.abspath(parsed_args.working_dir) oooutils.makedirs(working_dir) - inventory = os.path.join(working_dir, - constants.TRIPLEO_STATIC_INVENTORY) + if parsed_args.standalone: + inventory = oooutils.standalone_ceph_inventory(working_dir) + else: + 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: " @@ -860,3 +914,196 @@ class OvercloudCephUserEnable(command.Command): limit_hosts=ceph_hosts['_admin'][0], reproduce_command=False, ) + + +class OvercloudCephSpec(command.Command): + + log = logging.getLogger(__name__ + ".OvercloudCephSpec") + auth_required = False + + def get_parser(self, prog_name): + parser = super(OvercloudCephSpec, self).get_parser(prog_name) + + parser.add_argument('baremetal_env', nargs='?', + metavar='', + help=_('Path to the environment file ' + 'output from "openstack ' + 'overcloud node provision". ' + 'This argument may be excluded ' + 'only if --standalone is used.')) + parser.add_argument('-o', '--output', required=True, + metavar='', + help=_('The path to the output cephadm spec ' + 'file to pass to the "openstack ' + 'overcloud ceph deploy --ceph-spec ' + '" command.')) + 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)) + parser.add_argument('--mon-ip', + help=_( + "IP address of the first Ceph monitor. " + "Only available with --standalone."), + default='') + parser.add_argument('--standalone', default=False, + action='store_true', + help=_("Create a spec file for a standalone " + "deployment. Used for single server " + "development or testing environments.")) + spec_group = parser.add_mutually_exclusive_group() + spec_group.add_argument('--osd-spec', + help=_( + "Path to an existing OSD spec file. " + "When the Ceph spec file is generated " + "its OSD spec 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) + spec_group.add_argument('--crush-hierarchy', + help=_( + "Path to an existing crush hierarchy spec " + "file. "), + default=None) + + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)" % parsed_args) + + output_path = os.path.abspath(parsed_args.output) + 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) + + if parsed_args.standalone: + inventory = oooutils.standalone_ceph_inventory(working_dir) + else: + 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 = { + 'ceph_spec_path': output_path, + } + + # optional paths to pass to playbook + if parsed_args.standalone is None and \ + parsed_args.baremetal_env is None: + raise oscexc.CommandError( + "Either " + "or --standalone must be used.") + + if parsed_args.baremetal_env: + baremetal_env_path = os.path.abspath(parsed_args.baremetal_env) + if not os.path.exists(baremetal_env_path): + raise oscexc.CommandError( + "Baremetal environment file does not exist:" + " %s" % parsed_args.baremetal_env) + else: + extra_vars['baremetal_deployed_path'] = \ + os.path.abspath(parsed_args.baremetal_env) + + 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.mon_ip: + if not oooutils.is_valid_ip(parsed_args.mon_ip): + raise oscexc.CommandError( + "Invalid IP address '%s' passed to --mon-ip." + % parsed_args.mon_ip) + else: + if parsed_args.standalone: + extra_vars['tripleo_cephadm_first_mon_ip'] = \ + parsed_args.mon_ip + else: + raise oscexc.CommandError( + "Option --mon-ip may only be " + "used with --standalone") + + 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) + + if parsed_args.crush_hierarchy: + if not os.path.exists(parsed_args.crush_hierarchy): + raise oscexc.CommandError( + "Crush Hierarchy Spec file not found --crush-hierarchy %s." + % os.path.abspath(parsed_args.crush_hierarchy)) + else: + extra_vars['crush_hierarchy_path'] = \ + os.path.abspath(parsed_args.crush_hierarchy) + + if parsed_args.standalone: + spec_playbook = 'cli-standalone-ceph-spec.yaml' + tags = '' + else: + spec_playbook = 'cli-deployed-ceph.yaml' + tags = 'ceph_spec' + + with oooutils.TempDirs() as tmp: + oooutils.run_ansible_playbook( + playbook=spec_playbook, + inventory=inventory, + workdir=tmp, + playbook_dir=constants.ANSIBLE_TRIPLEO_PLAYBOOKS, + verbosity=oooutils.playbook_verbosity(self=self), + extra_vars=extra_vars, + reproduce_command=False, + tags=tags, + )