From 5535832c100fb73492d8b4f9b5e611e3b94a2fe1 Mon Sep 17 00:00:00 2001
From: Mark Goddard <mark@stackhpc.com>
Date: Tue, 22 Jun 2021 15:10:50 +0000
Subject: [PATCH] Support Ansible collections

This change adds support for installing Ansible collections via
requirements.yml in Kayobe or Kayobe config.

Story: 2008391
Task: 41315

Change-Id: I764ff019a18266b593add7ab80ee095d7d07a869
---
 doc/source/contributor/development.rst        | 34 +++----
 doc/source/custom-ansible-playbooks.rst       | 62 ++++++++++--
 kayobe/ansible.py                             | 48 +++++++++-
 kayobe/cli/commands.py                        |  4 +-
 kayobe/tests/unit/cli/test_commands.py        | 23 +++--
 kayobe/tests/unit/test_ansible.py             | 94 ++++++++++++++++++-
 kayobe/tests/unit/test_utils.py               | 63 +++++++++++--
 kayobe/utils.py                               | 25 ++++-
 .../notes/collections-b1b9a017c843dc1c.yaml   |  5 +
 requirements.yml                              | 91 +++++++++---------
 10 files changed, 354 insertions(+), 95 deletions(-)
 create mode 100644 releasenotes/notes/collections-b1b9a017c843dc1c.yaml

diff --git a/doc/source/contributor/development.rst b/doc/source/contributor/development.rst
index 48b3737f..6aede740 100644
--- a/doc/source/contributor/development.rst
+++ b/doc/source/contributor/development.rst
@@ -38,25 +38,27 @@ in `etc/kayobe/*.yml
 <https://opendev.org/openstack/kayobe/src/branch/master/etc/kayobe/>`__.
 A number of custom Jinja filters exist in `ansible/filter_plugins/*.py
 <https://opendev.org/openstack/kayobe/src/branch/master/ansible/filter_plugins>`__.
-Kayobe depends on roles hosted on Ansible Galaxy, and these and their version
-requirements are defined in `requirements.yml
+Kayobe depends on roles and collections hosted on Ansible Galaxy, and these and
+their version requirements are defined in `requirements.yml
 <https://opendev.org/openstack/kayobe/src/branch/master/requirements.yml>`__.
 
 Ansible Galaxy
 ==============
 
-Kayobe uses a number of Ansible roles hosted on Ansible Galaxy. The role
-dependencies are tracked in ``requirements.yml``, and specify required
-versions. The process for changing a Galaxy role is as follows:
-
-#. If required, develop changes for the role. This may be done outside of
-   Kayobe, or by modifying the role in place during development. If upstream
-   changes to the role have already been made, this step can be skipped.
-#. Commit changes to the role, typically via a Github pull request.
-#. Request that a tagged release of the role be made, or make one if you have
-   the necessary privileges.
-#. Ensure that automatic imports are configured for the role using e.g. a
-   TravisCI webhook notification, or perform a manual import of the role on
-   Ansible Galaxy.
+Kayobe uses a number of Ansible roles and collections hosted on Ansible Galaxy.
+The role dependencies are tracked in ``requirements.yml``, and specify required
+versions. The process for changing a Galaxy role or collection is as follows:
+
+#. If required, develop changes for the role or collection. This may be done
+   outside of Kayobe, or by modifying the code in place during development. If
+   upstream changes to the code have already been made, this step can be
+   skipped.
+#. Commit changes to the role or collection, typically via a Github pull
+   request.
+#. Request that a tagged release of the role or collection be made, or make one
+   if you have the necessary privileges.
+#. Ensure that automatic imports are configured for the repository using e.g. a
+   webhook notification, or perform a manual import of the role on Ansible
+   Galaxy.
 #. Modify the version in ``requirements.yml`` to match the new release of the
-   role.
+   role or collection.
diff --git a/doc/source/custom-ansible-playbooks.rst b/doc/source/custom-ansible-playbooks.rst
index 394904c1..44616a6a 100644
--- a/doc/source/custom-ansible-playbooks.rst
+++ b/doc/source/custom-ansible-playbooks.rst
@@ -75,14 +75,16 @@ These symlinks can even be committed to the kayobe-config Git repository.
 Ansible Galaxy
 --------------
 
-Ansible Galaxy provides a means for sharing Ansible roles.  Kayobe
-configuration may provide a Galaxy requirements file that defines roles to be
-installed from Galaxy.  These roles may then be used by custom playbooks.
+Ansible Galaxy provides a means for sharing Ansible roles and collections.
+Kayobe configuration may provide a Galaxy requirements file that defines roles
+and collections to be installed from Galaxy.  These roles and collections may
+then be used by custom playbooks.
 
-Galaxy role dependencies may be defined in
-``$KAYOBE_CONFIG_PATH/ansible/requirements.yml``.  These roles will be
-installed in ``$KAYOBE_CONFIG_PATH/ansible/roles/`` when bootstrapping the
-Ansible control host::
+Galaxy dependencies may be defined in
+``$KAYOBE_CONFIG_PATH/ansible/requirements.yml``.  These roles and collections
+will be installed in ``$KAYOBE_CONFIG_PATH/ansible/roles/`` and
+``$KAYOBE_CONFIG_PATH/ansible/collections`` when bootstrapping the Ansible
+control host::
 
     (kayobe) $ kayobe control host bootstrap
 
@@ -90,8 +92,8 @@ And updated when upgrading the Ansible control host::
 
     (kayobe) $ kayobe control host upgrade
 
-Example
-=======
+Example: roles
+==============
 
 The following example adds a ``foo.yml`` playbook to a set of kayobe
 configuration.  The playbook uses a Galaxy role, ``bar.baz``.
@@ -116,7 +118,8 @@ Here is the playbook, ``ansible/foo.yml``::
 Here is the Galaxy requirements file, ``ansible/requirements.yml``::
 
     ---
-    - bar.baz
+    roles:
+      - bar.baz
 
 We should first install the Galaxy role dependencies, to download the
 ``bar.baz`` role::
@@ -127,6 +130,45 @@ Then, to run the ``foo.yml`` playbook::
 
     (kayobe) $ kayobe playbook run $KAYOBE_CONFIG_PATH/ansible/foo.yml
 
+Example: collections
+====================
+
+The following example adds a ``foo.yml`` playbook to a set of kayobe
+configuration.  The playbook uses a role from a Galaxy collection,
+``bar.baz.qux``.
+
+Here is the kayobe configuration repository structure::
+
+    etc/kayobe/
+        ansible/
+            collections/
+            foo.yml
+            requirements.yml
+        bifrost.yml
+    ...
+
+Here is the playbook, ``ansible/foo.yml``::
+
+    ---
+    - hosts: controllers
+      roles:
+        - name: bar.baz.qux
+
+Here is the Galaxy requirements file, ``ansible/requirements.yml``::
+
+    ---
+    collections:
+      - bar.baz
+
+We should first install the Galaxy dependencies, to download the ``bar.baz``
+collection::
+
+    (kayobe) $ kayobe control host bootstrap
+
+Then, to run the ``foo.yml`` playbook::
+
+    (kayobe) $ kayobe playbook run $KAYOBE_CONFIG_PATH/ansible/foo.yml
+
 Hooks
 =====
 
diff --git a/kayobe/ansible.py b/kayobe/ansible.py
index 6dbc1eb8..69e71b61 100644
--- a/kayobe/ansible.py
+++ b/kayobe/ansible.py
@@ -291,7 +291,7 @@ def config_dump(parsed_args, host=None, hosts=None, var_name=None,
 def install_galaxy_roles(parsed_args, force=False):
     """Install Ansible Galaxy role dependencies.
 
-    Installs dependencies specified in kayobe, and if present, in kayobe
+    Installs role dependencies specified in kayobe, and if present, in kayobe
     configuration.
 
     :param parsed_args: Parsed command line arguments.
@@ -300,7 +300,7 @@ def install_galaxy_roles(parsed_args, force=False):
     LOG.info("Installing galaxy role dependencies from kayobe")
     requirements = utils.get_data_files_path("requirements.yml")
     roles_destination = utils.get_data_files_path('ansible', 'roles')
-    utils.galaxy_install(requirements, roles_destination, force=force)
+    utils.galaxy_role_install(requirements, roles_destination, force=force)
 
     # Check for requirements in kayobe configuration.
     kc_reqs_path = os.path.join(parsed_args.config_path,
@@ -323,7 +323,49 @@ def install_galaxy_roles(parsed_args, force=False):
                                   (parsed_args.config_path, str(e)))
 
     # Install roles from kayobe-config.
-    utils.galaxy_install(kc_reqs_path, kc_roles_path, force=force)
+    utils.galaxy_role_install(kc_reqs_path, kc_roles_path, force=force)
+
+
+def install_galaxy_collections(parsed_args, force=False):
+    """Install Ansible Galaxy collection dependencies.
+
+    Installs collection dependencies specified in kayobe, and if present, in
+    kayobe configuration.
+
+    :param parsed_args: Parsed command line arguments.
+    :param force: Whether to force reinstallation of roles.
+    """
+    LOG.info("Installing galaxy collection dependencies from kayobe")
+    requirements = utils.get_data_files_path("requirements.yml")
+    collections_destination = utils.get_data_files_path('ansible',
+                                                        'collections')
+    utils.galaxy_collection_install(requirements, collections_destination,
+                                    force=force)
+
+    # Check for requirements in kayobe configuration.
+    kc_reqs_path = os.path.join(parsed_args.config_path,
+                                "ansible", "requirements.yml")
+    if not utils.is_readable_file(kc_reqs_path)["result"]:
+        LOG.info("Not installing galaxy collection dependencies from kayobe "
+                 "config - requirements.yml not present")
+        return
+
+    LOG.info("Installing galaxy collection dependencies from kayobe config")
+    # Ensure a collections directory exists in kayobe-config.
+    kc_collections_path = os.path.join(parsed_args.config_path,
+                                       "ansible", "collections")
+    try:
+        os.makedirs(kc_collections_path)
+    except OSError as e:
+        if e.errno != errno.EEXIST:
+            raise exception.Error("Failed to create directory "
+                                  "ansible/collections/ "
+                                  "in kayobe configuration at %s: %s" %
+                                  (parsed_args.config_path, str(e)))
+
+    # Install collections from kayobe-config.
+    utils.galaxy_collection_install(kc_reqs_path, kc_collections_path,
+                                    force=force)
 
 
 def prune_galaxy_roles(parsed_args):
diff --git a/kayobe/cli/commands.py b/kayobe/cli/commands.py
index 38afdc6f..9199d9d5 100644
--- a/kayobe/cli/commands.py
+++ b/kayobe/cli/commands.py
@@ -232,6 +232,7 @@ class ControlHostBootstrap(KayobeAnsibleMixin, KollaAnsibleMixin, VaultMixin,
     def take_action(self, parsed_args):
         self.app.LOG.debug("Bootstrapping Kayobe Ansible control host")
         ansible.install_galaxy_roles(parsed_args)
+        ansible.install_galaxy_collections(parsed_args)
         playbooks = _build_playbook_list("bootstrap")
         self.run_kayobe_playbooks(parsed_args, playbooks, ignore_limit=True)
 
@@ -271,8 +272,9 @@ class ControlHostUpgrade(KayobeAnsibleMixin, VaultMixin, Command):
         # Remove roles that are no longer used. Do this before installing new
         # ones, just in case a custom role dependency includes any.
         ansible.prune_galaxy_roles(parsed_args)
-        # Use force to upgrade roles.
+        # Use force to upgrade roles and collections.
         ansible.install_galaxy_roles(parsed_args, force=True)
+        ansible.install_galaxy_collections(parsed_args, force=True)
         playbooks = _build_playbook_list("bootstrap")
         self.run_kayobe_playbooks(parsed_args, playbooks, ignore_limit=True)
         playbooks = _build_playbook_list("kolla-ansible")
diff --git a/kayobe/tests/unit/cli/test_commands.py b/kayobe/tests/unit/cli/test_commands.py
index 4e2a0f55..e8e70a1c 100644
--- a/kayobe/tests/unit/cli/test_commands.py
+++ b/kayobe/tests/unit/cli/test_commands.py
@@ -35,18 +35,21 @@ class TestApp(cliff.app.App):
 class TestCase(unittest.TestCase):
 
     @mock.patch.object(ansible, "install_galaxy_roles", autospec=True)
+    @mock.patch.object(ansible, "install_galaxy_collections", autospec=True)
     @mock.patch.object(ansible, "passwords_yml_exists", autospec=True)
     @mock.patch.object(commands.KayobeAnsibleMixin,
                        "run_kayobe_playbooks")
     def test_control_host_bootstrap(self, mock_run, mock_passwords,
-                                    mock_install):
+                                    mock_install_collections,
+                                    mock_install_roles):
         mock_passwords.return_value = False
         command = commands.ControlHostBootstrap(TestApp(), [])
         parser = command.get_parser("test")
         parsed_args = parser.parse_args([])
         result = command.run(parsed_args)
         self.assertEqual(0, result)
-        mock_install.assert_called_once_with(parsed_args)
+        mock_install_roles.assert_called_once_with(parsed_args)
+        mock_install_collections.assert_called_once_with(parsed_args)
         expected_calls = [
             mock.call(
                 mock.ANY,
@@ -63,20 +66,23 @@ class TestCase(unittest.TestCase):
         self.assertEqual(expected_calls, mock_run.call_args_list)
 
     @mock.patch.object(ansible, "install_galaxy_roles", autospec=True)
+    @mock.patch.object(ansible, "install_galaxy_collections", autospec=True)
     @mock.patch.object(ansible, "passwords_yml_exists", autospec=True)
     @mock.patch.object(commands.KayobeAnsibleMixin,
                        "run_kayobe_playbooks")
     @mock.patch.object(commands.KollaAnsibleMixin,
                        "run_kolla_ansible_overcloud")
     def test_control_host_bootstrap_with_passwords(
-            self, mock_kolla_run, mock_run, mock_passwords, mock_install):
+            self, mock_kolla_run, mock_run, mock_passwords,
+            mock_install_collections, mock_install_roles):
         mock_passwords.return_value = True
         command = commands.ControlHostBootstrap(TestApp(), [])
         parser = command.get_parser("test")
         parsed_args = parser.parse_args([])
         result = command.run(parsed_args)
         self.assertEqual(0, result)
-        mock_install.assert_called_once_with(parsed_args)
+        mock_install_roles.assert_called_once_with(parsed_args)
+        mock_install_collections.assert_called_once_with(parsed_args)
         expected_calls = [
             mock.call(
                 mock.ANY,
@@ -106,16 +112,21 @@ class TestCase(unittest.TestCase):
         self.assertEqual(expected_calls, mock_kolla_run.call_args_list)
 
     @mock.patch.object(ansible, "install_galaxy_roles", autospec=True)
+    @mock.patch.object(ansible, "install_galaxy_collections", autospec=True)
     @mock.patch.object(ansible, "prune_galaxy_roles", autospec=True)
     @mock.patch.object(commands.KayobeAnsibleMixin,
                        "run_kayobe_playbooks")
-    def test_control_host_upgrade(self, mock_run, mock_prune, mock_install):
+    def test_control_host_upgrade(self, mock_run, mock_prune,
+                                  mock_install_roles,
+                                  mock_install_collections):
         command = commands.ControlHostUpgrade(TestApp(), [])
         parser = command.get_parser("test")
         parsed_args = parser.parse_args([])
         result = command.run(parsed_args)
         self.assertEqual(0, result)
-        mock_install.assert_called_once_with(parsed_args, force=True)
+        mock_install_roles.assert_called_once_with(parsed_args, force=True)
+        mock_install_collections.assert_called_once_with(parsed_args,
+                                                         force=True)
         mock_prune.assert_called_once_with(parsed_args)
         expected_calls = [
             mock.call(
diff --git a/kayobe/tests/unit/test_ansible.py b/kayobe/tests/unit/test_ansible.py
index 4f9d8945..55da77a2 100644
--- a/kayobe/tests/unit/test_ansible.py
+++ b/kayobe/tests/unit/test_ansible.py
@@ -434,7 +434,7 @@ class TestCase(unittest.TestCase):
             mock.call(os.path.join(dump_dir, "host2.yml")),
         ])
 
-    @mock.patch.object(utils, 'galaxy_install', autospec=True)
+    @mock.patch.object(utils, 'galaxy_role_install', autospec=True)
     @mock.patch.object(utils, 'is_readable_file', autospec=True)
     @mock.patch.object(os, 'makedirs', autospec=True)
     def test_install_galaxy_roles(self, mock_mkdirs, mock_is_readable,
@@ -453,7 +453,7 @@ class TestCase(unittest.TestCase):
             "/etc/kayobe/ansible/requirements.yml")
         self.assertFalse(mock_mkdirs.called)
 
-    @mock.patch.object(utils, 'galaxy_install', autospec=True)
+    @mock.patch.object(utils, 'galaxy_role_install', autospec=True)
     @mock.patch.object(utils, 'is_readable_file', autospec=True)
     @mock.patch.object(os, 'makedirs', autospec=True)
     def test_install_galaxy_roles_with_kayobe_config(
@@ -476,7 +476,7 @@ class TestCase(unittest.TestCase):
             "/etc/kayobe/ansible/requirements.yml")
         mock_mkdirs.assert_called_once_with("/etc/kayobe/ansible/roles")
 
-    @mock.patch.object(utils, 'galaxy_install', autospec=True)
+    @mock.patch.object(utils, 'galaxy_role_install', autospec=True)
     @mock.patch.object(utils, 'is_readable_file', autospec=True)
     @mock.patch.object(os, 'makedirs', autospec=True)
     def test_install_galaxy_roles_with_kayobe_config_forced(
@@ -499,7 +499,7 @@ class TestCase(unittest.TestCase):
             "/etc/kayobe/ansible/requirements.yml")
         mock_mkdirs.assert_called_once_with("/etc/kayobe/ansible/roles")
 
-    @mock.patch.object(utils, 'galaxy_install', autospec=True)
+    @mock.patch.object(utils, 'galaxy_role_install', autospec=True)
     @mock.patch.object(utils, 'is_readable_file', autospec=True)
     @mock.patch.object(os, 'makedirs', autospec=True)
     def test_install_galaxy_roles_with_kayobe_config_mkdirs_failure(
@@ -520,6 +520,92 @@ class TestCase(unittest.TestCase):
             "/etc/kayobe/ansible/requirements.yml")
         mock_mkdirs.assert_called_once_with("/etc/kayobe/ansible/roles")
 
+    @mock.patch.object(utils, 'galaxy_collection_install', autospec=True)
+    @mock.patch.object(utils, 'is_readable_file', autospec=True)
+    @mock.patch.object(os, 'makedirs', autospec=True)
+    def test_install_galaxy_collections(self, mock_mkdirs, mock_is_readable,
+                                        mock_install):
+        parser = argparse.ArgumentParser()
+        ansible.add_args(parser)
+        parsed_args = parser.parse_args([])
+        mock_is_readable.return_value = {"result": False}
+
+        ansible.install_galaxy_collections(parsed_args)
+
+        mock_install.assert_called_once_with(utils.get_data_files_path(
+            "requirements.yml"), utils.get_data_files_path(
+            "ansible", "collections"), force=False)
+        mock_is_readable.assert_called_once_with(
+            "/etc/kayobe/ansible/requirements.yml")
+        self.assertFalse(mock_mkdirs.called)
+
+    @mock.patch.object(utils, 'galaxy_collection_install', autospec=True)
+    @mock.patch.object(utils, 'is_readable_file', autospec=True)
+    @mock.patch.object(os, 'makedirs', autospec=True)
+    def test_install_galaxy_collections_with_kayobe_config(
+            self, mock_mkdirs, mock_is_readable, mock_install):
+        parser = argparse.ArgumentParser()
+        ansible.add_args(parser)
+        parsed_args = parser.parse_args([])
+        mock_is_readable.return_value = {"result": True}
+
+        ansible.install_galaxy_collections(parsed_args)
+
+        expected_calls = [
+            mock.call(utils.get_data_files_path("requirements.yml"),
+                      utils.get_data_files_path("ansible", "collections"),
+                      force=False),
+            mock.call("/etc/kayobe/ansible/requirements.yml",
+                      "/etc/kayobe/ansible/collections", force=False)]
+        self.assertEqual(expected_calls, mock_install.call_args_list)
+        mock_is_readable.assert_called_once_with(
+            "/etc/kayobe/ansible/requirements.yml")
+        mock_mkdirs.assert_called_once_with("/etc/kayobe/ansible/collections")
+
+    @mock.patch.object(utils, 'galaxy_collection_install', autospec=True)
+    @mock.patch.object(utils, 'is_readable_file', autospec=True)
+    @mock.patch.object(os, 'makedirs', autospec=True)
+    def test_install_galaxy_collections_with_kayobe_config_forced(
+            self, mock_mkdirs, mock_is_readable, mock_install):
+        parser = argparse.ArgumentParser()
+        ansible.add_args(parser)
+        parsed_args = parser.parse_args([])
+        mock_is_readable.return_value = {"result": True}
+
+        ansible.install_galaxy_collections(parsed_args, force=True)
+
+        expected_calls = [
+            mock.call(utils.get_data_files_path("requirements.yml"),
+                      utils.get_data_files_path("ansible", "collections"),
+                      force=True),
+            mock.call("/etc/kayobe/ansible/requirements.yml",
+                      "/etc/kayobe/ansible/collections", force=True)]
+        self.assertEqual(expected_calls, mock_install.call_args_list)
+        mock_is_readable.assert_called_once_with(
+            "/etc/kayobe/ansible/requirements.yml")
+        mock_mkdirs.assert_called_once_with("/etc/kayobe/ansible/collections")
+
+    @mock.patch.object(utils, 'galaxy_collection_install', autospec=True)
+    @mock.patch.object(utils, 'is_readable_file', autospec=True)
+    @mock.patch.object(os, 'makedirs', autospec=True)
+    def test_install_galaxy_collections_with_kayobe_config_mkdirs_failure(
+            self, mock_mkdirs, mock_is_readable, mock_install):
+        parser = argparse.ArgumentParser()
+        ansible.add_args(parser)
+        parsed_args = parser.parse_args([])
+        mock_is_readable.return_value = {"result": True}
+        mock_mkdirs.side_effect = OSError(errno.EPERM)
+
+        self.assertRaises(exception.Error,
+                          ansible.install_galaxy_collections, parsed_args)
+
+        mock_install.assert_called_once_with(
+            utils.get_data_files_path("requirements.yml"),
+            utils.get_data_files_path("ansible", "collections"), force=False)
+        mock_is_readable.assert_called_once_with(
+            "/etc/kayobe/ansible/requirements.yml")
+        mock_mkdirs.assert_called_once_with("/etc/kayobe/ansible/collections")
+
     @mock.patch.object(utils, 'galaxy_remove', autospec=True)
     def test_prune_galaxy_roles(self, mock_remove):
         parser = argparse.ArgumentParser()
diff --git a/kayobe/tests/unit/test_utils.py b/kayobe/tests/unit/test_utils.py
index 9afe62da..85e98337 100644
--- a/kayobe/tests/unit/test_utils.py
+++ b/kayobe/tests/unit/test_utils.py
@@ -26,23 +26,72 @@ from kayobe import utils
 class TestCase(unittest.TestCase):
 
     @mock.patch.object(utils, "run_command")
-    def test_galaxy_install(self, mock_run):
-        utils.galaxy_install("/path/to/role/file", "/path/to/roles")
-        mock_run.assert_called_once_with(["ansible-galaxy", "install",
+    def test_galaxy_role_install(self, mock_run):
+        utils.galaxy_role_install("/path/to/role/file", "/path/to/roles")
+        mock_run.assert_called_once_with(["ansible-galaxy", "role", "install",
                                           "--roles-path", "/path/to/roles",
                                           "--role-file", "/path/to/role/file"])
 
     @mock.patch.object(utils, "run_command")
-    def test_galaxy_install_failure(self, mock_run):
+    def test_galaxy_role_install_failure(self, mock_run):
         mock_run.side_effect = subprocess.CalledProcessError(1, "command")
         self.assertRaises(SystemExit,
-                          utils.galaxy_install, "/path/to/role/file",
+                          utils.galaxy_role_install, "/path/to/role/file",
                           "/path/to/roles")
 
+    @mock.patch.object(utils, "run_command")
+    @mock.patch.object(utils, "read_yaml_file")
+    def test_galaxy_collection_install(self, mock_read, mock_run):
+        mock_read.return_value = {"collections": []}
+        utils.galaxy_collection_install("/path/to/collection/file",
+                                        "/path/to/collections")
+        mock_run.assert_called_once_with(["ansible-galaxy", "collection",
+                                          "install", "--collections-path",
+                                          "/path/to/collections",
+                                          "--requirements-file",
+                                          "/path/to/collection/file"])
+
+    @mock.patch.object(utils, "run_command")
+    @mock.patch.object(utils, "read_yaml_file")
+    def test_galaxy_collection_install_failure(self, mock_read, mock_run):
+        mock_read.return_value = {"collections": []}
+        mock_run.side_effect = subprocess.CalledProcessError(1, "command")
+        self.assertRaises(SystemExit,
+                          utils.galaxy_collection_install,
+                          "/path/to/collection/file", "/path/to/collections")
+
+    @mock.patch.object(utils, "run_command")
+    @mock.patch.object(utils, "read_file")
+    def test_galaxy_collection_read_failure(self, mock_read, mock_run):
+        mock_read.side_effect = IOError
+        self.assertRaises(SystemExit,
+                          utils.galaxy_collection_install,
+                          "/path/to/collection/file", "/path/to/collections")
+
+    @mock.patch.object(utils, "run_command")
+    @mock.patch.object(utils, "read_yaml_file")
+    def test_galaxy_collection_no_collections(self, mock_read, mock_run):
+        mock_read.return_value = {"roles": []}
+        utils.galaxy_collection_install("/path/to/collection/file",
+                                        "/path/to/collections")
+        mock_run.assert_called_once_with(["ansible-galaxy", "collection",
+                                          "install", "--collections-path",
+                                          "/path/to/collections",
+                                          "--requirements-file",
+                                          "/path/to/collection/file"])
+
+    @mock.patch.object(utils, "run_command")
+    @mock.patch.object(utils, "read_yaml_file")
+    def test_galaxy_collection_legacy_format(self, mock_read, mock_run):
+        mock_read.return_value = []
+        utils.galaxy_collection_install("/path/to/collection/file",
+                                        "/path/to/collections")
+        self.assertFalse(mock_run.called)
+
     @mock.patch.object(utils, "run_command")
     def test_galaxy_remove(self, mock_run):
         utils.galaxy_remove(["role1", "role2"], "/path/to/roles")
-        mock_run.assert_called_once_with(["ansible-galaxy", "remove",
+        mock_run.assert_called_once_with(["ansible-galaxy", "role", "remove",
                                           "--roles-path", "/path/to/roles",
                                           "role1", "role2"])
 
@@ -50,7 +99,7 @@ class TestCase(unittest.TestCase):
     def test_galaxy_remove_failure(self, mock_run):
         mock_run.side_effect = subprocess.CalledProcessError(1, "command")
         self.assertRaises(SystemExit,
-                          utils.galaxy_install, ["role1", "role2"],
+                          utils.galaxy_remove, ["role1", "role2"],
                           "/path/to/roles")
 
     @mock.patch.object(utils, "read_file")
diff --git a/kayobe/utils.py b/kayobe/utils.py
index deaac12c..2ded367c 100644
--- a/kayobe/utils.py
+++ b/kayobe/utils.py
@@ -72,9 +72,9 @@ def _get_base_path():
     return os.path.join(os.path.realpath(__file__), "..")
 
 
-def galaxy_install(role_file, roles_path, force=False):
+def galaxy_role_install(role_file, roles_path, force=False):
     """Install Ansible roles via Ansible Galaxy."""
-    cmd = ["ansible-galaxy", "install"]
+    cmd = ["ansible-galaxy", "role", "install"]
     cmd += ["--roles-path", roles_path]
     cmd += ["--role-file", role_file]
     if force:
@@ -87,10 +87,29 @@ def galaxy_install(role_file, roles_path, force=False):
         sys.exit(e.returncode)
 
 
+def galaxy_collection_install(requirements_file, collections_path,
+                              force=False):
+    requirements = read_yaml_file(requirements_file)
+    if not isinstance(requirements, dict):
+        # Handle legacy role list format, which causes the command to fail.
+        return
+    cmd = ["ansible-galaxy", "collection", "install"]
+    cmd += ["--collections-path", collections_path]
+    cmd += ["--requirements-file", requirements_file]
+    if force:
+        cmd += ["--force"]
+    try:
+        run_command(cmd)
+    except subprocess.CalledProcessError as e:
+        LOG.error("Failed to install Ansible collections from %s via Ansible "
+                  "Galaxy: returncode %d", requirements_file, e.returncode)
+        sys.exit(e.returncode)
+
+
 def galaxy_remove(roles_to_remove, roles_path):
 
     """Remove Ansible roles via Ansible Galaxy."""
-    cmd = ["ansible-galaxy", "remove"]
+    cmd = ["ansible-galaxy", "role", "remove"]
     cmd += ["--roles-path", roles_path]
     cmd += roles_to_remove
     try:
diff --git a/releasenotes/notes/collections-b1b9a017c843dc1c.yaml b/releasenotes/notes/collections-b1b9a017c843dc1c.yaml
new file mode 100644
index 00000000..b235a604
--- /dev/null
+++ b/releasenotes/notes/collections-b1b9a017c843dc1c.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    Adds support for installing Ansible collections. See `story 2008391
+    <https://storyboard.openstack.org/#!/story/2008391>`__ for details.
diff --git a/requirements.yml b/requirements.yml
index de29f104..2c6e8707 100644
--- a/requirements.yml
+++ b/requirements.yml
@@ -1,46 +1,47 @@
 ---
-- src: ahuffman.resolv
-  version: 1.3.1
-- src: stackhpc.systemd_networkd
-  version: v1.0.1
-- src: jriguera.configdrive
-  # There are no versioned releases of this role.
-  version: e12d38378ae127c9c61d170fa4ba4729f2c5f2ad
-- src: MichaelRigart.interfaces
-  version: v1.12.0
-- src: mrlesmithjr.chrony
-  version: v0.1.1
-- src: mrlesmithjr.manage-lvm
-  version: v0.2.2
-- src: mrlesmithjr.mdadm
-  version: v0.1.1
-- src: singleplatform-eng.users
-  version: v1.2.5
-- src: stackhpc.dell-powerconnect-switch
-  version: v1.1.0
-- src: stackhpc.drac
-  version: 1.1.5
-- src: stackhpc.drac-facts
-  version: 1.0.0
-- src: stackhpc.grafana-conf
-  version: 1.1.1
-- src: stackhpc.libvirt-host
-  version: v1.8.3
-- src: stackhpc.libvirt-vm
-  version: v1.14.2
-- src: stackhpc.luks
-  version: 0.4.1
-- src: stackhpc.mellanox-switch
-  version: v1.0.0
-- src: stackhpc.os-images
-  version: v1.10.7
-- src: stackhpc.os-ironic-state
-  version: v1.3.1
-- src: stackhpc.os-networks
-  version: v1.5.3
-- src: stackhpc.os-openstackclient
-  version: v1.4.1
-- src: stackhpc.os_openstacksdk
-  version: v1.0.1
-- src: stackhpc.timezone
-  version: 1.2.1
+roles:
+  - src: ahuffman.resolv
+    version: 1.3.1
+  - src: stackhpc.systemd_networkd
+    version: v1.0.1
+  - src: jriguera.configdrive
+    # There are no versioned releases of this role.
+    version: e12d38378ae127c9c61d170fa4ba4729f2c5f2ad
+  - src: MichaelRigart.interfaces
+    version: v1.12.0
+  - src: mrlesmithjr.chrony
+    version: v0.1.1
+  - src: mrlesmithjr.manage-lvm
+    version: v0.2.2
+  - src: mrlesmithjr.mdadm
+    version: v0.1.1
+  - src: singleplatform-eng.users
+    version: v1.2.5
+  - src: stackhpc.dell-powerconnect-switch
+    version: v1.1.0
+  - src: stackhpc.drac
+    version: 1.1.5
+  - src: stackhpc.drac-facts
+    version: 1.0.0
+  - src: stackhpc.grafana-conf
+    version: 1.1.1
+  - src: stackhpc.libvirt-host
+    version: v1.8.3
+  - src: stackhpc.libvirt-vm
+    version: v1.14.2
+  - src: stackhpc.luks
+    version: 0.4.1
+  - src: stackhpc.mellanox-switch
+    version: v1.0.0
+  - src: stackhpc.os-images
+    version: v1.10.7
+  - src: stackhpc.os-ironic-state
+    version: v1.3.1
+  - src: stackhpc.os-networks
+    version: v1.5.3
+  - src: stackhpc.os-openstackclient
+    version: v1.4.1
+  - src: stackhpc.os_openstacksdk
+    version: v1.0.1
+  - src: stackhpc.timezone
+    version: 1.2.1
-- 
GitLab