Repository: ansible/molecule

Issue:
RFE: Shared inventory directory
As a user I want to specify the parent inventory directory for a scenario.

Molecule allows for this currently:

```
    provisioner:
      name: ansible
      inventory:
        links:
          hosts: ../../../inventory/hosts
          group_vars: ../../../inventory/group_vars/
          host_vars: ../../../inventory/host_vars/
```
it also sets MOLECULE_INVENTORY_FILE


Use cases:
- Allow the user to specify a list of inventory locations that will override current molecule invetory logic and be apssed to the ansible-playbook command.
- When using ansible as the provisioner, the invetory is constructed and would otherwise be put in the scneario specific inventory directory.  This would allow for the sharing of an inventory between scenarios
- Current use of seperate keywords (hosts/group_vars/host_vars) is confusing as ansible will resolve everything within an inventory directory if passed with -i

Related: #4000 would allow for a location to store a shared inventory.


Discussion:

Patch:
diff --git a/src/molecule/command/base.py b/src/molecule/command/base.py
index 180766834..bda058b92 100644
--- a/src/molecule/command/base.py
+++ b/src/molecule/command/base.py
@@ -435,6 +435,12 @@ def click_command_options(func: Callable[..., None]) -> Callable[..., None]:
     Returns:
         Function with click options for scenario_name, exclude, all, and report added.
     """
+    # NOTE: because click.option is a decorator, options applied this way will appear in the opposite order.
+    func = click.option(
+        "--shared-inventory/--no-shared-inventory",
+        default=False,
+        help="EXPERIMENTAL: Enable or disable sharing inventory between scenarios. Default is disabled.",
+    )(func)
     func = click.option(
         "--report/--no-report",
         default=False,
diff --git a/src/molecule/command/check.py b/src/molecule/command/check.py
index 0c35d80e8..38700942c 100644
--- a/src/molecule/command/check.py
+++ b/src/molecule/command/check.py
@@ -60,16 +60,17 @@ def execute(self, action_args: list[str] | None = None) -> None:  # noqa: ARG002
     default=MOLECULE_PARALLEL,
     help="Enable or disable parallel mode. Default is disabled.",
 )
-def check(  # pragma: no cover
+def check(  # noqa: PLR0913
     ctx: click.Context,
     /,
     scenario_name: list[str] | None,
     exclude: list[str],
     __all: bool,  # noqa: FBT001
     *,
-    report: bool,
     parallel: bool,
-) -> None:
+    report: bool,
+    shared_inventory: bool,
+) -> None:  # pragma: no cover
     """Use the provisioner to perform a Dry-Run (destroy, dependency, create, prepare, converge).
 
     Args:
@@ -77,8 +78,9 @@ def check(  # pragma: no cover
         scenario_name: Name of the scenario to target.
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
-        report: Whether to show an after-run summary report.
         parallel: Whether the scenario(s) should be run in parallel.
+        report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
@@ -86,6 +88,7 @@ def check(  # pragma: no cover
         "parallel": parallel,
         "subcommand": subcommand,
         "report": report,
+        "shared_inventory": shared_inventory,
     }
 
     if __all:
diff --git a/src/molecule/command/cleanup.py b/src/molecule/command/cleanup.py
index 30d77f913..7c3dbfe42 100644
--- a/src/molecule/command/cleanup.py
+++ b/src/molecule/command/cleanup.py
@@ -66,6 +66,7 @@ def cleanup(
     *,
     __all: bool,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to cleanup any changes.
 
@@ -77,10 +78,15 @@ def cleanup(
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
-    command_args: CommandArgs = {"subcommand": subcommand, "report": report}
+    command_args: CommandArgs = {
+        "subcommand": subcommand,
+        "report": report,
+        "shared_inventory": shared_inventory,
+    }
 
     if __all:
         scenario_name = None
diff --git a/src/molecule/command/converge.py b/src/molecule/command/converge.py
index 2af4ec4f7..a499250f4 100644
--- a/src/molecule/command/converge.py
+++ b/src/molecule/command/converge.py
@@ -55,15 +55,16 @@ def execute(self, action_args: list[str] | None = None) -> None:  # noqa: ARG002
 @click.pass_context
 @base.click_command_options
 @click.argument("ansible_args", nargs=-1, type=click.UNPROCESSED)
-def converge(
+def converge(  # noqa: PLR0913
     ctx: click.Context,
     /,
     scenario_name: list[str] | None,
     exclude: list[str],
     __all: bool,  # noqa: FBT001
     *,
-    report: bool,
     ansible_args: tuple[str],
+    report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to configure instances (dependency, create, prepare converge).
 
@@ -72,12 +73,17 @@ def converge(
         scenario_name: Name of the scenario to target.
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
-        report: Whether to show an after-run summary report.
         ansible_args: Arguments to forward to Ansible.
+        report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
-    command_args: CommandArgs = {"subcommand": subcommand, "report": report}
+    command_args: CommandArgs = {
+        "subcommand": subcommand,
+        "report": report,
+        "shared_inventory": shared_inventory,
+    }
 
     if __all:
         scenario_name = None
diff --git a/src/molecule/command/create.py b/src/molecule/command/create.py
index c42bb01f7..167373bbc 100644
--- a/src/molecule/command/create.py
+++ b/src/molecule/command/create.py
@@ -70,7 +70,7 @@ def execute(self, action_args: list[str] | None = None) -> None:  # noqa: ARG002
     type=click.Choice([str(s) for s in drivers()]),
     help=f"Name of driver to use. ({DEFAULT_DRIVER})",
 )
-def create(
+def create(  # noqa: PLR0913
     ctx: click.Context,
     /,
     scenario_name: list[str] | None,
@@ -79,6 +79,7 @@ def create(
     __all: bool,  # noqa: FBT001
     *,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to start the instances.
 
@@ -89,6 +90,7 @@ def create(
         driver_name: Name of the Molecule driver to use.
         __all: Whether molecule should target scenario_name or all scenarios.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
@@ -96,6 +98,7 @@ def create(
         "subcommand": subcommand,
         "driver_name": driver_name,
         "report": report,
+        "shared_inventory": shared_inventory,
     }
 
     if __all:
diff --git a/src/molecule/command/dependency.py b/src/molecule/command/dependency.py
index 974e17270..63407d918 100644
--- a/src/molecule/command/dependency.py
+++ b/src/molecule/command/dependency.py
@@ -61,6 +61,7 @@ def dependency(
     *,
     __all: bool,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Manage the role's dependencies.
 
@@ -70,10 +71,15 @@ def dependency(
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
-    command_args: CommandArgs = {"subcommand": subcommand, "report": report}
+    command_args: CommandArgs = {
+        "subcommand": subcommand,
+        "report": report,
+        "shared_inventory": shared_inventory,
+    }
 
     if __all:
         scenario_name = None
diff --git a/src/molecule/command/destroy.py b/src/molecule/command/destroy.py
index dbc713998..72c5994e2 100644
--- a/src/molecule/command/destroy.py
+++ b/src/molecule/command/destroy.py
@@ -83,6 +83,7 @@ def destroy(  # noqa: PLR0913
     *,
     parallel: bool,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to destroy the instances.
 
@@ -94,6 +95,7 @@ def destroy(  # noqa: PLR0913
         __all: Whether molecule should target scenario_name or all scenarios.
         parallel: Whether the scenario(s) should be run in parallel mode.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
@@ -102,6 +104,7 @@ def destroy(  # noqa: PLR0913
         "subcommand": subcommand,
         "driver_name": driver_name,
         "report": report,
+        "shared_inventory": shared_inventory,
     }
 
     if __all:
diff --git a/src/molecule/command/idempotence.py b/src/molecule/command/idempotence.py
index 063dbf487..89c683805 100644
--- a/src/molecule/command/idempotence.py
+++ b/src/molecule/command/idempotence.py
@@ -124,13 +124,15 @@ def _non_idempotent_tasks(self, output: str) -> list[str]:
 @click.pass_context
 @base.click_command_options
 @click.argument("ansible_args", nargs=-1, type=click.UNPROCESSED)
-def idempotence(
+def idempotence(  # noqa: PLR0913
     ctx: click.Context,
     scenario_name: list[str] | None,
     exclude: list[str],
     __all: bool,  # noqa: FBT001
-    report: bool,  # noqa: FBT001
+    *,
     ansible_args: tuple[str, ...],
+    report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to configure the instances.
 
@@ -141,12 +143,17 @@ def idempotence(
         scenario_name: Name of the scenario to target.
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
-        report: Whether to show an after-run summary report.
         ansible_args: Arguments to forward to Ansible.
+        report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
-    command_args: CommandArgs = {"subcommand": subcommand, "report": report}
+    command_args: CommandArgs = {
+        "subcommand": subcommand,
+        "report": report,
+        "shared_inventory": shared_inventory,
+    }
 
     if __all:
         scenario_name = None
diff --git a/src/molecule/command/prepare.py b/src/molecule/command/prepare.py
index 1043e4231..66553c851 100644
--- a/src/molecule/command/prepare.py
+++ b/src/molecule/command/prepare.py
@@ -138,6 +138,7 @@ def prepare(  # noqa: PLR0913
     *,
     force: bool,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to prepare the instances into a particular starting state.
 
@@ -149,6 +150,7 @@ def prepare(  # noqa: PLR0913
         __all: Whether molecule should target scenario_name or all scenarios.
         force: Whether to use force mode.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
@@ -157,6 +159,7 @@ def prepare(  # noqa: PLR0913
         "driver_name": driver_name,
         "force": force,
         "report": report,
+        "shared_inventory": shared_inventory,
     }
 
     if __all:
diff --git a/src/molecule/command/side_effect.py b/src/molecule/command/side_effect.py
index 381a3be31..b45f76fa7 100644
--- a/src/molecule/command/side_effect.py
+++ b/src/molecule/command/side_effect.py
@@ -69,6 +69,7 @@ def side_effect(
     *,
     __all: bool,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to perform side-effects to the instances.
 
@@ -78,10 +79,15 @@ def side_effect(
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
-    command_args: CommandArgs = {"subcommand": subcommand, "report": report}
+    command_args: CommandArgs = {
+        "subcommand": subcommand,
+        "report": report,
+        "shared_inventory": shared_inventory,
+    }
 
     if __all:
         scenario_name = None
diff --git a/src/molecule/command/syntax.py b/src/molecule/command/syntax.py
index c4d4beb35..5f1a7df71 100644
--- a/src/molecule/command/syntax.py
+++ b/src/molecule/command/syntax.py
@@ -61,6 +61,7 @@ def syntax(
     *,
     __all: bool,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Use the provisioner to syntax check the role.
 
@@ -70,10 +71,15 @@ def syntax(
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
-    command_args: CommandArgs = {"subcommand": subcommand, "report": report}
+    command_args: CommandArgs = {
+        "subcommand": subcommand,
+        "report": report,
+        "shared_inventory": shared_inventory,
+    }
 
     if __all:
         scenario_name = None
diff --git a/src/molecule/command/verify.py b/src/molecule/command/verify.py
index 77445b4ce..484f712c1 100644
--- a/src/molecule/command/verify.py
+++ b/src/molecule/command/verify.py
@@ -60,6 +60,7 @@ def verify(
     *,
     __all: bool,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Run automated tests against instances.
 
@@ -69,10 +70,15 @@ def verify(
         exclude: Name of the scenarios to avoid targeting.
         __all: Whether molecule should target scenario_name or all scenarios.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
-    command_args: CommandArgs = {"subcommand": subcommand, "report": report}
+    command_args: CommandArgs = {
+        "subcommand": subcommand,
+        "report": report,
+        "shared_inventory": shared_inventory,
+    }
 
     if __all:
         scenario_name = None
diff --git a/src/molecule/config.py b/src/molecule/config.py
index 8ee7807cd..53e66aa4a 100644
--- a/src/molecule/config.py
+++ b/src/molecule/config.py
@@ -157,6 +157,15 @@ def is_parallel(self) -> bool:
         """
         return self.command_args.get("parallel", False)
 
+    @property
+    def shared_inventory(self) -> bool:
+        """Should molecule share ephemeral data.
+
+        Returns:
+            Whether molecule should share ephemeral data.
+        """
+        return self.command_args.get("shared_inventory", False)
+
     @property
     def platform_name(self) -> str | None:
         """Configured platform.
diff --git a/src/molecule/scenario.py b/src/molecule/scenario.py
index 438dd2869..45604d02d 100644
--- a/src/molecule/scenario.py
+++ b/src/molecule/scenario.py
@@ -168,6 +168,24 @@ def ephemeral_directory(self) -> str:
 
         return path.absolute().as_posix()
 
+    @cached_property
+    def shared_ephemeral_directory(self) -> str:
+        """Acquire the shared ephemeral directory.
+
+        Returns:
+            The common ephemeral directory for all scenarios.
+        """
+        path: Path
+        if "MOLECULE_EPHEMERAL_DIRECTORY" not in os.environ:
+            project_directory = Path(self.config.project_directory).name
+
+            project_scenario_directory = f"molecule.{checksum(project_directory, 4)}"
+            path = self.config.runtime.cache_dir / "tmp" / project_scenario_directory
+        else:
+            path = Path(os.getenv("MOLECULE_EPHEMERAL_DIRECTORY", ""))
+
+        return path.absolute().as_posix()
+
     @property
     def inventory_directory(self) -> str:
         """Inventory directory.
@@ -175,8 +193,12 @@ def inventory_directory(self) -> str:
         Returns:
             The directory containing the scenario's inventory.
         """
-        path = Path(self.ephemeral_directory) / "inventory"
-        return str(path)
+        if self.config.shared_inventory and not self.config.is_parallel:
+            ephemeral = Path(self.shared_ephemeral_directory)
+        else:
+            ephemeral = Path(self.ephemeral_directory)
+
+        return str(ephemeral / "inventory")
 
     @property
     def check_sequence(self) -> list[str]:
diff --git a/src/molecule/types.py b/src/molecule/types.py
index 9e9bfcc2d..42f7b5ed5 100644
--- a/src/molecule/types.py
+++ b/src/molecule/types.py
@@ -265,6 +265,7 @@ class CommandArgs(TypedDict, total=False):
         platform_name: Name of the platform to target.
         report: Whether to show an after-run summary report.
         scenario_name: Name of the scenario to target.
+        shared_inventory: Whether inventory should be shared between scenarios.
         subcommand: Name of subcommand being run.
     """
 
@@ -277,6 +278,7 @@ class CommandArgs(TypedDict, total=False):
     platform_name: str
     report: bool
     scenario_name: str
+    shared_inventory: bool
     subcommand: str
 
 


Test patch:
diff --git a/src/molecule/command/test.py b/src/molecule/command/test.py
index 2c6db2482..55ddaae90 100644
--- a/src/molecule/command/test.py
+++ b/src/molecule/command/test.py
@@ -95,6 +95,7 @@ def test(  # noqa: PLR0913
     ansible_args: tuple[str, ...],
     platform_name: str,
     report: bool,
+    shared_inventory: bool,
 ) -> None:  # pragma: no cover
     """Test (dependency, cleanup, destroy, syntax, create, prepare, converge, idempotence, side_effect, verify, cleanup, destroy).
 
@@ -109,6 +110,7 @@ def test(  # noqa: PLR0913
         ansible_args: Arguments to forward to Ansible.
         platform_name: Name of the platform to use.
         report: Whether to show an after-run summary report.
+        shared_inventory: Whether the inventory should be shared between scenarios.
     """
     args: MoleculeArgs = ctx.obj.get("args")
     subcommand = base._get_subcommand(__name__)  # noqa: SLF001
@@ -119,6 +121,7 @@ def test(  # noqa: PLR0913
         "driver_name": driver_name,
         "platform_name": platform_name,
         "report": report,
+        "shared_inventory": shared_inventory,
     }
 
     if __all:
diff --git a/tests/unit/provisioner/test_ansible.py b/tests/unit/provisioner/test_ansible.py
index e7e6a5df2..dee87541e 100644
--- a/tests/unit/provisioner/test_ansible.py
+++ b/tests/unit/provisioner/test_ansible.py
@@ -22,6 +22,7 @@
 import collections
 import os
 
+from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
 import pytest
@@ -37,6 +38,8 @@
 
     from pytest_mock import MockerFixture
 
+    from molecule.provisioner.ansible import Ansible
+
 
 @pytest.fixture
 def _patched_ansible_playbook(mocker: MockerFixture) -> MagicMock:
@@ -260,9 +263,38 @@ def test_links_property(instance):  # type: ignore[no-untyped-def]  # noqa: ANN2
     assert instance.links == {}
 
 
-def test_inventory_directory_property(instance):  # type: ignore[no-untyped-def]  # noqa: ANN201, D103
-    x = os.path.join(instance._config.scenario.ephemeral_directory, "inventory")  # noqa: PTH118
-    assert x == instance.inventory_directory
+def test_inventory_directory_property(instance: Ansible) -> None:
+    """Test the inventory_directory property.
+
+    Args:
+        instance: Ansible provisioner instance.
+    """
+    x = Path(instance._config.scenario.ephemeral_directory, "inventory")
+    assert str(x) == instance.inventory_directory
+
+
+def test_inventory_directory_property_shared(instance: Ansible) -> None:
+    """Test the shared_inventory_directory property.
+
+    Args:
+        instance: Ansible provisioner instance.
+    """
+    instance._config.command_args["shared_inventory"] = True
+    x = Path(instance._config.scenario.shared_ephemeral_directory, "inventory")
+    assert str(x) == instance.inventory_directory
+
+
+def test_inventory_directory_property_shared_parallel(instance: Ansible) -> None:
+    """Test the shared_inventory_directory property with parallel mode on.
+
+    Args:
+        instance: Ansible provisioner instance.
+    """
+    instance._config.command_args["shared_inventory"] = True
+    instance._config.command_args["parallel"] = True
+    # Parallel disables shared ephemeral directory
+    x = Path(instance._config.scenario.ephemeral_directory, "inventory")
+    assert str(x) == instance.inventory_directory
 
 
 def test_inventory_file_property(instance):  # type: ignore[no-untyped-def]  # noqa: ANN201, D103