From cb48f7e5d26618029f36a543b60922a907f902a9 Mon Sep 17 00:00:00 2001
From: Mark Goddard <mark@stackhpc.com>
Date: Tue, 9 Nov 2021 15:19:55 +0000
Subject: [PATCH] Refactor host configure commands to use a single playbook

Ansible failure handling is different when executing multiple top-level
playbooks (CLI arguments) vs. multiple plays within a top-level
playbook. If any hosts have failed or are unreachable at the end of a
top-level playbook, then ansible-playbook exits non-zero.

In contrast, execution will continue at the end of a mid-playbook play
if there are hosts that have not failed or become unreachable. This is
documented in [1].

Currently, Kayobe executes multiple top-level playbooks, most notably in
the host configure commands where there is a long list of them. This has
implications when working at scale, where failures are more common. If a
host fails at any point, then execution of the command will stop at the
end of the current playbook. This means that the command must be run
again for all hosts. Additionally, if any hosts are unreachable, then
the command is unable to progress at all without removing them from the
inventory.

This change refactors the host configure and host upgrade commands to
use a single top-level playbook.

[1] https://github.com/markgoddard/ansible-experiments/tree/master/14-error-handling

Story: 2009854
Task: 44482

Change-Id: Ia63d66097b10b6ddda30ad693636143f8b1a85e0
---
 ansible/infra-vm-host-configure.yml           |  24 ++
 ansible/overcloud-host-configure.yml          |  26 ++
 ansible/overcloud-host-upgrade.yml            |   5 +
 ansible/seed-host-configure.yml               |  27 ++
 ansible/seed-host-upgrade.yml                 |   3 +
 ansible/seed-hypervisor-host-configure.yml    |  22 ++
 ansible/wipe-disks.yml                        |  17 +-
 kayobe/cli/commands.py                        |  64 ++--
 kayobe/tests/unit/cli/test_commands.py        | 284 +++++++++++-------
 ...gure-single-playbook-060e9fd96e0aebb5.yaml |   9 +
 10 files changed, 321 insertions(+), 160 deletions(-)
 create mode 100644 ansible/infra-vm-host-configure.yml
 create mode 100644 ansible/overcloud-host-configure.yml
 create mode 100644 ansible/overcloud-host-upgrade.yml
 create mode 100644 ansible/seed-host-configure.yml
 create mode 100644 ansible/seed-host-upgrade.yml
 create mode 100644 ansible/seed-hypervisor-host-configure.yml
 create mode 100644 releasenotes/notes/host-configure-single-playbook-060e9fd96e0aebb5.yaml

diff --git a/ansible/infra-vm-host-configure.yml b/ansible/infra-vm-host-configure.yml
new file mode 100644
index 00000000..ce7b25c8
--- /dev/null
+++ b/ansible/infra-vm-host-configure.yml
@@ -0,0 +1,24 @@
+---
+- import_playbook: "ssh-known-host.yml"
+- import_playbook: "kayobe-ansible-user.yml"
+- import_playbook: "proxy.yml"
+- import_playbook: "apt.yml"
+- import_playbook: "dnf.yml"
+- import_playbook: "pip.yml"
+- import_playbook: "kayobe-target-venv.yml"
+- import_playbook: "wipe-disks.yml"
+- import_playbook: "users.yml"
+- import_playbook: "dev-tools.yml"
+- import_playbook: "disable-selinux.yml"
+- import_playbook: "network.yml"
+- import_playbook: "firewall.yml"
+- import_playbook: "tuned.yml"
+- import_playbook: "sysctl.yml"
+- import_playbook: "disable-glean.yml"
+- import_playbook: "disable-cloud-init.yml"
+- import_playbook: "time.yml"
+- import_playbook: "mdadm.yml"
+- import_playbook: "luks.yml"
+- import_playbook: "lvm.yml"
+- import_playbook: "docker-devicemapper.yml"
+- import_playbook: "docker.yml"
diff --git a/ansible/overcloud-host-configure.yml b/ansible/overcloud-host-configure.yml
new file mode 100644
index 00000000..31587891
--- /dev/null
+++ b/ansible/overcloud-host-configure.yml
@@ -0,0 +1,26 @@
+---
+- import_playbook: "ssh-known-host.yml"
+- import_playbook: "kayobe-ansible-user.yml"
+- import_playbook: "proxy.yml"
+- import_playbook: "apt.yml"
+- import_playbook: "dnf.yml"
+- import_playbook: "pip.yml"
+- import_playbook: "kayobe-target-venv.yml"
+- import_playbook: "wipe-disks.yml"
+- import_playbook: "users.yml"
+- import_playbook: "dev-tools.yml"
+- import_playbook: "disable-selinux.yml"
+- import_playbook: "network.yml"
+- import_playbook: "firewall.yml"
+- import_playbook: "tuned.yml"
+- import_playbook: "sysctl.yml"
+- import_playbook: "disable-glean.yml"
+- import_playbook: "disable-cloud-init.yml"
+- import_playbook: "time.yml"
+- import_playbook: "mdadm.yml"
+- import_playbook: "luks.yml"
+- import_playbook: "lvm.yml"
+- import_playbook: "docker-devicemapper.yml"
+- import_playbook: "kolla-ansible-user.yml"
+- import_playbook: "kolla-pip.yml"
+- import_playbook: "kolla-target-venv.yml"
diff --git a/ansible/overcloud-host-upgrade.yml b/ansible/overcloud-host-upgrade.yml
new file mode 100644
index 00000000..4564abec
--- /dev/null
+++ b/ansible/overcloud-host-upgrade.yml
@@ -0,0 +1,5 @@
+---
+- import_playbook: "kayobe-target-venv.yml"
+- import_playbook: "kolla-target-venv.yml"
+- import_playbook: "overcloud-docker-sdk-upgrade.yml"
+- import_playbook: "overcloud-etc-hosts-fixup.yml"
diff --git a/ansible/seed-host-configure.yml b/ansible/seed-host-configure.yml
new file mode 100644
index 00000000..4a89f4f0
--- /dev/null
+++ b/ansible/seed-host-configure.yml
@@ -0,0 +1,27 @@
+---
+- import_playbook: "ssh-known-host.yml"
+- import_playbook: "kayobe-ansible-user.yml"
+- import_playbook: "proxy.yml"
+- import_playbook: "apt.yml"
+- import_playbook: "dnf.yml"
+- import_playbook: "pip.yml"
+- import_playbook: "kayobe-target-venv.yml"
+- import_playbook: "wipe-disks.yml"
+- import_playbook: "users.yml"
+- import_playbook: "dev-tools.yml"
+- import_playbook: "disable-selinux.yml"
+- import_playbook: "network.yml"
+- import_playbook: "firewall.yml"
+- import_playbook: "tuned.yml"
+- import_playbook: "sysctl.yml"
+- import_playbook: "ip-routing.yml"
+- import_playbook: "snat.yml"
+- import_playbook: "disable-glean.yml"
+- import_playbook: "time.yml"
+- import_playbook: "mdadm.yml"
+- import_playbook: "luks.yml"
+- import_playbook: "lvm.yml"
+- import_playbook: "docker-devicemapper.yml"
+- import_playbook: "kolla-ansible-user.yml"
+- import_playbook: "kolla-pip.yml"
+- import_playbook: "kolla-target-venv.yml"
diff --git a/ansible/seed-host-upgrade.yml b/ansible/seed-host-upgrade.yml
new file mode 100644
index 00000000..d803c397
--- /dev/null
+++ b/ansible/seed-host-upgrade.yml
@@ -0,0 +1,3 @@
+---
+- import_playbook: "kayobe-target-venv.yml"
+- import_playbook: "kolla-target-venv.yml"
diff --git a/ansible/seed-hypervisor-host-configure.yml b/ansible/seed-hypervisor-host-configure.yml
new file mode 100644
index 00000000..86c70623
--- /dev/null
+++ b/ansible/seed-hypervisor-host-configure.yml
@@ -0,0 +1,22 @@
+---
+- import_playbook: "ssh-known-host.yml"
+- import_playbook: "kayobe-ansible-user.yml"
+- import_playbook: "proxy.yml"
+- import_playbook: "apt.yml"
+- import_playbook: "dnf.yml"
+- import_playbook: "pip.yml"
+- import_playbook: "kayobe-target-venv.yml"
+- import_playbook: "wipe-disks.yml"
+- import_playbook: "users.yml"
+- import_playbook: "dev-tools.yml"
+- import_playbook: "network.yml"
+- import_playbook: "firewall.yml"
+- import_playbook: "tuned.yml"
+- import_playbook: "sysctl.yml"
+- import_playbook: "ip-routing.yml"
+- import_playbook: "snat.yml"
+- import_playbook: "time.yml"
+- import_playbook: "mdadm.yml"
+- import_playbook: "luks.yml"
+- import_playbook: "lvm.yml"
+- import_playbook: "seed-hypervisor-libvirt-host.yml"
diff --git a/ansible/wipe-disks.yml b/ansible/wipe-disks.yml
index 8ade2328..a833f3c1 100644
--- a/ansible/wipe-disks.yml
+++ b/ansible/wipe-disks.yml
@@ -11,8 +11,15 @@
   hosts: seed-hypervisor:seed:overcloud:infra-vms
   tags:
     - wipe-disks
-  roles:
-    - role: stackhpc.luks
-      vars:
-        luks_action: teardown-unmounted
-    - role: wipe-disks
+  tasks:
+    - block:
+        - name: Tear down unmounted LUKS devices
+          include_role:
+            name: stackhpc.luks
+          vars:
+            luks_action: teardown-unmounted
+
+        - name: Wipe disks
+          include_role:
+            name: wipe-disks
+      when: wipe_disks | default(false) | bool
diff --git a/kayobe/cli/commands.py b/kayobe/cli/commands.py
index 0ba90f2a..788d493b 100644
--- a/kayobe/cli/commands.py
+++ b/kayobe/cli/commands.py
@@ -442,17 +442,12 @@ class SeedHypervisorHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin,
         self.run_kayobe_playbooks(parsed_args, playbooks,
                                   limit="seed-hypervisor")
 
-        playbooks = _build_playbook_list(
-            "ssh-known-host", "kayobe-ansible-user", "proxy",
-            "apt", "dnf", "pip", "kayobe-target-venv")
+        kwargs = {}
         if parsed_args.wipe_disks:
-            playbooks += _build_playbook_list("wipe-disks")
-        playbooks += _build_playbook_list(
-            "users", "dev-tools", "network", "firewall", "tuned", "sysctl",
-            "ip-routing", "snat", "time", "mdadm", "luks", "lvm",
-            "seed-hypervisor-libvirt-host")
+            kwargs["extra_vars"] = {"wipe_disks": True}
+        playbooks = _build_playbook_list("seed-hypervisor-host-configure")
         self.run_kayobe_playbooks(parsed_args, playbooks,
-                                  limit="seed-hypervisor")
+                                  limit="seed-hypervisor", **kwargs)
 
 
 class SeedHypervisorHostPackageUpdate(KayobeAnsibleMixin, VaultMixin, Command):
@@ -600,17 +595,12 @@ class SeedHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
         self.run_kayobe_playbooks(parsed_args, playbooks, limit="seed")
 
         # Run kayobe playbooks.
-        playbooks = _build_playbook_list(
-            "ssh-known-host", "kayobe-ansible-user", "proxy",
-            "apt", "dnf", "pip", "kayobe-target-venv")
+        kwargs = {}
         if parsed_args.wipe_disks:
-            playbooks += _build_playbook_list("wipe-disks")
-        playbooks += _build_playbook_list(
-            "users", "dev-tools", "disable-selinux", "network", "firewall",
-            "tuned", "sysctl", "ip-routing", "snat", "disable-glean", "time",
-            "mdadm", "luks", "lvm", "docker-devicemapper",
-            "kolla-ansible-user", "kolla-pip", "kolla-target-venv")
-        self.run_kayobe_playbooks(parsed_args, playbooks, limit="seed")
+            kwargs["extra_vars"] = {"wipe_disks": True}
+        playbooks = _build_playbook_list("seed-host-configure")
+        self.run_kayobe_playbooks(parsed_args, playbooks, limit="seed",
+                                  **kwargs)
 
         self.generate_kolla_ansible_config(parsed_args, service_config=False)
 
@@ -685,8 +675,7 @@ class SeedHostUpgrade(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
 
     def take_action(self, parsed_args):
         self.app.LOG.debug("Upgrading seed host services")
-        playbooks = _build_playbook_list(
-            "kayobe-target-venv", "kolla-target-venv")
+        playbooks = _build_playbook_list("seed-host-upgrade")
         self.run_kayobe_playbooks(parsed_args, playbooks, limit="seed")
 
 
@@ -906,16 +895,12 @@ class InfraVMHostConfigure(KayobeAnsibleMixin, VaultMixin,
         self.run_kayobe_playbooks(parsed_args, playbooks, limit="infra-vms")
 
         # Kayobe playbooks.
-        playbooks = _build_playbook_list(
-            "ssh-known-host", "kayobe-ansible-user", "proxy",
-            "apt", "dnf", "pip", "kayobe-target-venv")
+        kwargs = {}
         if parsed_args.wipe_disks:
-            playbooks += _build_playbook_list("wipe-disks")
-        playbooks += _build_playbook_list(
-            "users", "dev-tools", "disable-selinux", "network", "firewall",
-            "tuned", "sysctl", "disable-glean", "disable-cloud-init", "time",
-            "mdadm", "luks", "lvm", "docker-devicemapper", "docker")
-        self.run_kayobe_playbooks(parsed_args, playbooks, limit="infra-vms")
+            kwargs["extra_vars"] = {"wipe_disks": True}
+        playbooks = _build_playbook_list("infra-vm-host-configure")
+        self.run_kayobe_playbooks(parsed_args, playbooks, limit="infra-vms",
+                                  **kwargs)
 
 
 class InfraVMHostPackageUpdate(KayobeAnsibleMixin, VaultMixin, Command):
@@ -1159,17 +1144,12 @@ class OvercloudHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
         self.run_kayobe_playbooks(parsed_args, playbooks, limit="overcloud")
 
         # Kayobe playbooks.
-        playbooks = _build_playbook_list(
-            "ssh-known-host", "kayobe-ansible-user", "proxy",
-            "apt", "dnf", "pip", "kayobe-target-venv")
+        kwargs = {}
         if parsed_args.wipe_disks:
-            playbooks += _build_playbook_list("wipe-disks")
-        playbooks += _build_playbook_list(
-            "users", "dev-tools", "disable-selinux", "network", "firewall",
-            "tuned", "sysctl", "disable-glean", "disable-cloud-init", "time",
-            "mdadm", "luks", "lvm", "docker-devicemapper",
-            "kolla-ansible-user", "kolla-pip", "kolla-target-venv")
-        self.run_kayobe_playbooks(parsed_args, playbooks, limit="overcloud")
+            kwargs["extra_vars"] = {"wipe_disks": True}
+        playbooks = _build_playbook_list("overcloud-host-configure")
+        self.run_kayobe_playbooks(parsed_args, playbooks, limit="overcloud",
+                                  **kwargs)
 
         self.generate_kolla_ansible_config(parsed_args, service_config=False)
 
@@ -1238,9 +1218,7 @@ class OvercloudHostUpgrade(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
 
     def take_action(self, parsed_args):
         self.app.LOG.debug("Upgrading overcloud host services")
-        playbooks = _build_playbook_list(
-            "kayobe-target-venv", "kolla-target-venv",
-            "overcloud-docker-sdk-upgrade", "overcloud-etc-hosts-fixup")
+        playbooks = _build_playbook_list("overcloud-host-upgrade")
         self.run_kayobe_playbooks(parsed_args, playbooks, limit="overcloud")
 
 
diff --git a/kayobe/tests/unit/cli/test_commands.py b/kayobe/tests/unit/cli/test_commands.py
index 6ba5aa11..dbb402e1 100644
--- a/kayobe/tests/unit/cli/test_commands.py
+++ b/kayobe/tests/unit/cli/test_commands.py
@@ -316,31 +316,38 @@ class TestCase(unittest.TestCase):
             mock.call(
                 mock.ANY,
                 [
-                    utils.get_data_files_path("ansible", "ssh-known-host.yml"),
                     utils.get_data_files_path(
-                        "ansible", "kayobe-ansible-user.yml"),
-                    utils.get_data_files_path("ansible", "proxy.yml"),
-                    utils.get_data_files_path("ansible", "apt.yml"),
-                    utils.get_data_files_path("ansible", "dnf.yml"),
-                    utils.get_data_files_path("ansible", "pip.yml"),
+                        "ansible", "seed-hypervisor-host-configure.yml"),
+                ],
+                limit="seed-hypervisor",
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_run.call_args_list)
+
+    @mock.patch.object(commands.KayobeAnsibleMixin,
+                       "run_kayobe_playbooks")
+    def test_seed_hypervisor_host_configure_wipe_disks(self, mock_run):
+        command = commands.SeedHypervisorHostConfigure(TestApp(), [])
+        parser = command.get_parser("test")
+        parsed_args = parser.parse_args(["--wipe-disks"])
+
+        result = command.run(parsed_args)
+        self.assertEqual(0, result)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                [utils.get_data_files_path("ansible", "ip-allocation.yml")],
+                limit="seed-hypervisor",
+            ),
+            mock.call(
+                mock.ANY,
+                [
                     utils.get_data_files_path(
-                        "ansible", "kayobe-target-venv.yml"),
-                    utils.get_data_files_path("ansible", "users.yml"),
-                    utils.get_data_files_path("ansible", "dev-tools.yml"),
-                    utils.get_data_files_path("ansible", "network.yml"),
-                    utils.get_data_files_path("ansible", "firewall.yml"),
-                    utils.get_data_files_path("ansible", "tuned.yml"),
-                    utils.get_data_files_path("ansible", "sysctl.yml"),
-                    utils.get_data_files_path("ansible", "ip-routing.yml"),
-                    utils.get_data_files_path("ansible", "snat.yml"),
-                    utils.get_data_files_path("ansible", "time.yml"),
-                    utils.get_data_files_path("ansible", "mdadm.yml"),
-                    utils.get_data_files_path("ansible", "luks.yml"),
-                    utils.get_data_files_path("ansible", "lvm.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "seed-hypervisor-libvirt-host.yml"),
+                        "ansible", "seed-hypervisor-host-configure.yml"),
                 ],
                 limit="seed-hypervisor",
+                extra_vars={"wipe_disks": True},
             ),
         ]
         self.assertEqual(expected_calls, mock_run.call_args_list)
@@ -492,39 +499,70 @@ class TestCase(unittest.TestCase):
             mock.call(
                 mock.ANY,
                 [
-                    utils.get_data_files_path("ansible", "ssh-known-host.yml"),
                     utils.get_data_files_path(
-                        "ansible", "kayobe-ansible-user.yml"),
-                    utils.get_data_files_path("ansible", "proxy.yml"),
-                    utils.get_data_files_path("ansible", "apt.yml"),
-                    utils.get_data_files_path("ansible", "dnf.yml"),
-                    utils.get_data_files_path("ansible", "pip.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "kayobe-target-venv.yml"),
-                    utils.get_data_files_path("ansible", "users.yml"),
-                    utils.get_data_files_path("ansible", "dev-tools.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "disable-selinux.yml"),
-                    utils.get_data_files_path("ansible", "network.yml"),
-                    utils.get_data_files_path("ansible", "firewall.yml"),
-                    utils.get_data_files_path("ansible", "tuned.yml"),
-                    utils.get_data_files_path("ansible", "sysctl.yml"),
-                    utils.get_data_files_path("ansible", "ip-routing.yml"),
-                    utils.get_data_files_path("ansible", "snat.yml"),
-                    utils.get_data_files_path("ansible", "disable-glean.yml"),
-                    utils.get_data_files_path("ansible", "time.yml"),
-                    utils.get_data_files_path("ansible", "mdadm.yml"),
-                    utils.get_data_files_path("ansible", "luks.yml"),
-                    utils.get_data_files_path("ansible", "lvm.yml"),
+                        "ansible", "seed-host-configure.yml"),
+                ],
+                limit="seed",
+            ),
+            mock.call(
+                mock.ANY,
+                [utils.get_data_files_path("ansible", "kolla-ansible.yml")],
+                tags="config",
+                ignore_limit=True,
+            ),
+            mock.call(
+                mock.ANY,
+                [
+                    utils.get_data_files_path("ansible", "docker.yml"),
+                ],
+                limit="seed",
+            ),
+            mock.call(
+                mock.ANY,
+                [
                     utils.get_data_files_path("ansible",
-                                              "docker-devicemapper.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "kolla-ansible-user.yml"),
-                    utils.get_data_files_path("ansible", "kolla-pip.yml"),
+                                              "docker-registry.yml"),
+                ],
+                limit="seed",
+                extra_vars={'kayobe_action': 'deploy'},
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_run.call_args_list)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                "bootstrap-servers",
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_kolla_run.call_args_list)
+
+    @mock.patch.object(commands.KayobeAnsibleMixin,
+                       "run_kayobe_playbooks")
+    @mock.patch.object(commands.KollaAnsibleMixin,
+                       "run_kolla_ansible_seed")
+    def test_seed_host_configure_wipe_disks(self, mock_kolla_run, mock_run):
+        command = commands.SeedHostConfigure(TestApp(), [])
+        parser = command.get_parser("test")
+        parsed_args = parser.parse_args(["--wipe-disks"])
+
+        result = command.run(parsed_args)
+        self.assertEqual(0, result)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                [utils.get_data_files_path("ansible", "ip-allocation.yml")],
+                limit="seed",
+            ),
+            mock.call(
+                mock.ANY,
+                [
                     utils.get_data_files_path(
-                        "ansible", "kolla-target-venv.yml"),
+                        "ansible", "seed-host-configure.yml"),
                 ],
                 limit="seed",
+                extra_vars={"wipe_disks": True},
             ),
             mock.call(
                 mock.ANY,
@@ -549,6 +587,8 @@ class TestCase(unittest.TestCase):
                 extra_vars={'kayobe_action': 'deploy'},
             ),
         ]
+        print(expected_calls)
+        print(mock_run.call_args_list)
         self.assertEqual(expected_calls, mock_run.call_args_list)
 
         expected_calls = [
@@ -678,9 +718,7 @@ class TestCase(unittest.TestCase):
                 mock.ANY,
                 [
                     utils.get_data_files_path(
-                        "ansible", "kayobe-target-venv.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "kolla-target-venv.yml"),
+                        "ansible", "seed-host-upgrade.yml"),
                 ],
                 limit="seed",
             ),
@@ -984,35 +1022,38 @@ class TestCase(unittest.TestCase):
             mock.call(
                 mock.ANY,
                 [
-                    utils.get_data_files_path("ansible", "ssh-known-host.yml"),
                     utils.get_data_files_path(
-                        "ansible", "kayobe-ansible-user.yml"),
-                    utils.get_data_files_path("ansible", "proxy.yml"),
-                    utils.get_data_files_path("ansible", "apt.yml"),
-                    utils.get_data_files_path("ansible", "dnf.yml"),
-                    utils.get_data_files_path("ansible", "pip.yml"),
+                        "ansible", "infra-vm-host-configure.yml"),
+                ],
+                limit="infra-vms",
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_run.call_args_list)
+
+    @mock.patch.object(commands.KayobeAnsibleMixin,
+                       "run_kayobe_playbooks")
+    def test_infra_vm_host_configure_wipe_disks(self, mock_run):
+        command = commands.InfraVMHostConfigure(TestApp(), [])
+        parser = command.get_parser("test")
+        parsed_args = parser.parse_args(["--wipe-disks"])
+
+        result = command.run(parsed_args)
+        self.assertEqual(0, result)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                [utils.get_data_files_path("ansible", "ip-allocation.yml")],
+                limit="infra-vms",
+            ),
+            mock.call(
+                mock.ANY,
+                [
                     utils.get_data_files_path(
-                        "ansible", "kayobe-target-venv.yml"),
-                    utils.get_data_files_path("ansible", "users.yml"),
-                    utils.get_data_files_path("ansible", "dev-tools.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "disable-selinux.yml"),
-                    utils.get_data_files_path("ansible", "network.yml"),
-                    utils.get_data_files_path("ansible", "firewall.yml"),
-                    utils.get_data_files_path("ansible", "tuned.yml"),
-                    utils.get_data_files_path("ansible", "sysctl.yml"),
-                    utils.get_data_files_path("ansible", "disable-glean.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "disable-cloud-init.yml"),
-                    utils.get_data_files_path("ansible", "time.yml"),
-                    utils.get_data_files_path("ansible", "mdadm.yml"),
-                    utils.get_data_files_path("ansible", "luks.yml"),
-                    utils.get_data_files_path("ansible", "lvm.yml"),
-                    utils.get_data_files_path("ansible",
-                                              "docker-devicemapper.yml"),
-                    utils.get_data_files_path("ansible", "docker.yml"),
+                        "ansible", "infra-vm-host-configure.yml"),
                 ],
                 limit="infra-vms",
+                extra_vars={"wipe_disks": True},
             ),
         ]
         self.assertEqual(expected_calls, mock_run.call_args_list)
@@ -1264,39 +1305,64 @@ class TestCase(unittest.TestCase):
             mock.call(
                 mock.ANY,
                 [
-                    utils.get_data_files_path("ansible", "ssh-known-host.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "kayobe-ansible-user.yml"),
-                    utils.get_data_files_path("ansible", "proxy.yml"),
-                    utils.get_data_files_path("ansible", "apt.yml"),
-                    utils.get_data_files_path("ansible", "dnf.yml"),
-                    utils.get_data_files_path("ansible", "pip.yml"),
                     utils.get_data_files_path(
-                        "ansible", "kayobe-target-venv.yml"),
-                    utils.get_data_files_path("ansible", "users.yml"),
-                    utils.get_data_files_path("ansible", "dev-tools.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "disable-selinux.yml"),
-                    utils.get_data_files_path("ansible", "network.yml"),
-                    utils.get_data_files_path("ansible", "firewall.yml"),
-                    utils.get_data_files_path("ansible", "tuned.yml"),
-                    utils.get_data_files_path("ansible", "sysctl.yml"),
-                    utils.get_data_files_path("ansible", "disable-glean.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "disable-cloud-init.yml"),
-                    utils.get_data_files_path("ansible", "time.yml"),
-                    utils.get_data_files_path("ansible", "mdadm.yml"),
-                    utils.get_data_files_path("ansible", "luks.yml"),
-                    utils.get_data_files_path("ansible", "lvm.yml"),
-                    utils.get_data_files_path("ansible",
-                                              "docker-devicemapper.yml"),
+                        "ansible", "overcloud-host-configure.yml"),
+                ],
+                limit="overcloud",
+            ),
+            mock.call(
+                mock.ANY,
+                [utils.get_data_files_path("ansible", "kolla-ansible.yml")],
+                tags="config",
+                ignore_limit=True,
+            ),
+            mock.call(
+                mock.ANY,
+                [
+                    utils.get_data_files_path("ansible", "docker.yml"),
                     utils.get_data_files_path(
-                        "ansible", "kolla-ansible-user.yml"),
-                    utils.get_data_files_path("ansible", "kolla-pip.yml"),
+                        "ansible", "swift-block-devices.yml"),
+                ],
+                limit="overcloud",
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_run.call_args_list)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                "bootstrap-servers",
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_kolla_run.call_args_list)
+
+    @mock.patch.object(commands.KayobeAnsibleMixin,
+                       "run_kayobe_playbooks")
+    @mock.patch.object(commands.KollaAnsibleMixin,
+                       "run_kolla_ansible_overcloud")
+    def test_overcloud_host_configure_wipe_disks(self, mock_kolla_run,
+                                                 mock_run):
+        command = commands.OvercloudHostConfigure(TestApp(), [])
+        parser = command.get_parser("test")
+        parsed_args = parser.parse_args(["--wipe-disks"])
+
+        result = command.run(parsed_args)
+        self.assertEqual(0, result)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                [utils.get_data_files_path("ansible", "ip-allocation.yml")],
+                limit="overcloud",
+            ),
+            mock.call(
+                mock.ANY,
+                [
                     utils.get_data_files_path(
-                        "ansible", "kolla-target-venv.yml"),
+                        "ansible", "overcloud-host-configure.yml"),
                 ],
                 limit="overcloud",
+                extra_vars={"wipe_disks": True},
             ),
             mock.call(
                 mock.ANY,
@@ -1443,13 +1509,7 @@ class TestCase(unittest.TestCase):
                 mock.ANY,
                 [
                     utils.get_data_files_path(
-                        "ansible", "kayobe-target-venv.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "kolla-target-venv.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "overcloud-docker-sdk-upgrade.yml"),
-                    utils.get_data_files_path(
-                        "ansible", "overcloud-etc-hosts-fixup.yml"),
+                        "ansible", "overcloud-host-upgrade.yml"),
                 ],
                 limit="overcloud",
             ),
diff --git a/releasenotes/notes/host-configure-single-playbook-060e9fd96e0aebb5.yaml b/releasenotes/notes/host-configure-single-playbook-060e9fd96e0aebb5.yaml
new file mode 100644
index 00000000..418ebabe
--- /dev/null
+++ b/releasenotes/notes/host-configure-single-playbook-060e9fd96e0aebb5.yaml
@@ -0,0 +1,9 @@
+---
+features:
+  - |
+    Improves error handling by adding a top-level playbook for the ``kayobe *
+    host configure`` and ``kayobe * host upgrade`` commands. This ensures that
+    if a host fails during a host configuration command, other hosts are able
+    to continue to completion. This is useful at scale, where host failures
+    occur more frequently. See `story 2009854
+    <https://storyboard.openstack.org/#!/story/2009854>`__ for details.
-- 
GitLab