From 43fb57b91eabb978a26639561d2a3b4c00aed440 Mon Sep 17 00:00:00 2001 From: Jeff Pullen <9343691+jeffcpullen@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:03:12 -0400 Subject: [PATCH] fix(shared_state): give each scenario its own working directory Under shared_state, Scenario.ephemeral_directory returned the shared root for every scenario, so inventory/, molecule.yml, and ansible.cfg were shared. Under --workers the provisioner's per-action rmtree of inventory/group_vars deletes a sibling's inventory and crashes when two scenarios reach it at once, and the whole-file rewrites of molecule.yml and ansible.cfg let a scenario read a sibling's config and run against the wrong hosts. Nest each scenario's ephemeral directory under the shared root (shared_ephemeral_directory / ) so inventory/, molecule.yml, and ansible.cfg are private per scenario. Keep state.yml and instance_config.yml at the shared root, where they are shared on purpose: instance_config.yml is how a sibling reaches the default scenario's instances (the #4588 goal), and run state is shared by design. Apply the --shared-state CLI override before the paths are first resolved, so a CLI-only --shared-state lands the ephemeral directory, the state file, and the instance config on one layout. Export MOLECULE_SHARED_EPHEMERAL_DIRECTORY (and molecule_shared_ephemeral_directory to plays) as the supported location for the cross-scenario data-sharing pattern that previously relied on molecule_ephemeral_directory resolving the same path for every scenario. Document which files are shared and which are per scenario, and add unit tests for each failure, each file's location, and the worker sequence under shared_state. Fixes: #4666 Assisted-by: Claude (Anthropic) Signed-off-by: Jeff Pullen <9343691+jeffcpullen@users.noreply.github.com> --- docs/ansible-native.md | 11 +- docs/configuration.md | 105 +++++++++++---- docs/getting-started-collections.md | 2 +- docs/philosophy.md | 16 ++- src/molecule/config.py | 10 ++ src/molecule/driver/base.py | 10 ++ src/molecule/provisioner/ansible.py | 1 + src/molecule/scenario.py | 7 +- src/molecule/state.py | 10 ++ tests/unit/command/test_base.py | 45 ++++++- tests/unit/command/test_reset.py | 59 ++++++++- tests/unit/driver/test_delegated.py | 38 +++++- tests/unit/provisioner/test_ansible.py | 175 ++++++++++++++++++++++++- tests/unit/test_config.py | 120 ++++++++++++++++- tests/unit/test_scenario.py | 65 ++++++++- tests/unit/test_state.py | 121 ++++++++++++++++- tests/unit/test_worker.py | 5 +- 17 files changed, 734 insertions(+), 66 deletions(-) diff --git a/docs/ansible-native.md b/docs/ansible-native.md index 722593d849..239d2593df 100644 --- a/docs/ansible-native.md +++ b/docs/ansible-native.md @@ -279,31 +279,36 @@ Shared state enables scenarios to share ephemeral state and testing resources. ### Configuration +Enable shared state in a base `config.yml` that every scenario inherits, or in each scenario's own `molecule.yml`. Every scenario in the run must have it set. The [configuration reference](configuration.md#shared-state) lists the exact file locations and shows where the shared and per-scenario files are created on disk. + ```yaml +# config.yml, or each scenario's molecule.yml shared_state: true ``` When enabled: -- All scenarios share the same ephemeral state directory +- All scenarios share one state directory (`state.yml` and `instance_config.yml`), and each scenario keeps its own inventory and configuration files - Default scenario manages testing resource lifecycle - Component scenarios access shared resources - State persists between scenario executions ### Resource Lifecycle Management -**Default scenario** (testing resource management): +**Default scenario** (testing resource management), in `molecule/default/molecule.yml`: ```yaml +# molecule/default/molecule.yml scenario: test_sequence: - create - destroy ``` -**Component scenarios** (testing only): +**Component scenarios** (testing only), one `molecule.yml` per scenario: ```yaml +# molecule//molecule.yml scenario: test_sequence: - prepare diff --git a/docs/configuration.md b/docs/configuration.md index 26f79dcc99..97d18aaa14 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,32 +41,61 @@ By default, Molecule runs each scenario independently with its own isolated stat This is particularly useful for multi-scenario testing where one scenario manages testing resource lifecycle while other scenarios perform testing against those resources. -To enable shared state, add `shared_state: true` to your configuration file: - -```yaml ---- -shared_state: true -# ... rest of configuration -``` - **Effects of enabling shared state:** -- All scenarios share the same ephemeral state directory +- All scenarios share one state directory (`state.yml` and `instance_config.yml`), and each scenario keeps its own inventory and configuration files - The default scenario handles create/destroy actions for all scenarios - Component scenarios can access resources created by the default scenario - Scenarios skip their own create/destroy actions when shared resources are managed elsewhere - Faster execution with single infrastructure lifecycle instead of per-scenario setup/teardown -**Configuration locations:** +### Where to set `shared_state` when you run several scenarios + +`molecule test --all` configures each scenario on its own, so `shared_state` must be true for every scenario in the run, not only the default one. There are two ways to do that. -You can add this setting to: +Set it once in a base `config.yml` that every scenario inherits. Molecule looks for a base config in these places: -- `.config/molecule/config.yml` file in your `$HOME` directory (global default) -- Base `config.yml` file at the project root (project default) -- Collection molecule directory `extensions/molecule/config.yml` -- Individual scenario `molecule.yml` files (scenario-specific override) +- `.config/molecule/config.yml` in your `$HOME` directory (global default) +- `config.yml` at the root of your project (project default) +- `extensions/molecule/config.yml` in a collection + +```yaml +# config.yml, inherited by every scenario +shared_state: true +``` + +If you do not keep a base `config.yml`, put the same line in each scenario's own `molecule.yml`: + +```yaml +# molecule/default/molecule.yml, and the same line in every other scenario's molecule.yml +shared_state: true +``` -**Alternative:** The `--shared-state` command-line flag can also enable this behavior temporarily, but configuration file approach is recommended for consistent usage. +To turn the behavior on for a single run without editing any file, pass the flag. It applies only to that one invocation. + +```bash +molecule test --all --shared-state +``` + +### Where the files live + +With `shared_state` enabled, one shared directory holds the state the scenarios have in common, and each scenario keeps its own directory for the files it must not share with its siblings. + +```text +/ # MOLECULE_SHARED_EPHEMERAL_DIRECTORY +├── state.yml # shared, what has been created so far +├── instance_config.yml # shared, the instances every scenario connects to +├── default/ # this scenario's MOLECULE_EPHEMERAL_DIRECTORY +│ ├── molecule.yml +│ ├── ansible.cfg +│ └── inventory/ +└── role1/ # another scenario's MOLECULE_EPHEMERAL_DIRECTORY + ├── molecule.yml + ├── ansible.cfg + └── inventory/ +``` + +These are generated paths, so read them at run time rather than hard-coding them. Run a command with `--debug` to print the resolved directories, or read them inside a playbook from the `MOLECULE_SHARED_EPHEMERAL_DIRECTORY` and `MOLECULE_EPHEMERAL_DIRECTORY` variables described below. ## Variable Substitution @@ -108,14 +137,26 @@ will read variables when rendering `molecule.yml`. See command usage. Following are the environment variables available in `molecule.yml`: +!!! note + + The ephemeral paths below are generated for each run. Molecule creates them + under its cache directory, by default `~/.ansible/tmp/`, or `$ANSIBLE_HOME/tmp/` + when `ANSIBLE_HOME` is set, with a + generated name of the form `molecule..`, and + `molecule.` for the shared directory. Treat the + examples as the shape of the value, not a literal you can hard-code, and run + a command with `--debug` to print the resolved paths. Inside a playbook the + same directories are available as the lowercase variables + `molecule_ephemeral_directory` and `molecule_shared_ephemeral_directory`. + MOLECULE_DEBUG : If debug is turned on or off MOLECULE_FILE -: Path to molecule config file, usually -`~/.cache/molecule///molecule.yml` +: Path to the generated molecule config file, `molecule.yml` inside the +scenario's ephemeral directory MOLECULE_ENV_FILE @@ -123,19 +164,26 @@ MOLECULE_ENV_FILE MOLECULE_STATE_FILE -: The path to molecule state file contains the state of the instances -(created, converged, etc.). Usually -`~/.cache/molecule///state.yml` +: The molecule state file, which holds the state of the instances (created, +converged, etc.). It is `state.yml` inside the scenario's ephemeral directory, +or inside the shared ephemeral directory when `shared_state` is enabled MOLECULE_INVENTORY_FILE -: Path to generated inventory file, usually -`~/.cache/molecule///inventory/ansible_inventory.yml` +: Path to the generated inventory file, +`inventory/ansible_inventory.yml` inside the scenario's ephemeral directory MOLECULE_EPHEMERAL_DIRECTORY -: Path to generated directory, usually -`~/.cache/molecule//` +: The scenario's generated working directory. See the note above for its +location + +MOLECULE_SHARED_EPHEMERAL_DIRECTORY + +: Path to the directory all scenarios share when `shared_state` is enabled, +where `state.yml` and `instance_config.yml` live. Each scenario's own +`MOLECULE_EPHEMERAL_DIRECTORY` is created beneath it. Without `shared_state`, +the same value as `MOLECULE_EPHEMERAL_DIRECTORY`. MOLECULE_SCENARIO_DIRECTORY @@ -148,9 +196,10 @@ MOLECULE_PROJECT_DIRECTORY MOLECULE_INSTANCE_CONFIG -: Path to the instance config file, contains instance name, -connection, user, port, etc. (populated from driver). Usually -`~/.cache/molecule///instance_config.yml` +: Path to the instance config file, which holds instance name, connection, +user, port, etc. (populated from the driver). It is `instance_config.yml` +inside the scenario's ephemeral directory, or inside the shared ephemeral +directory when `shared_state` is enabled MOLECULE_ANSIBLE_ARGS_STRICT_MODE diff --git a/docs/getting-started-collections.md b/docs/getting-started-collections.md index 9236c8118c..e34e10fda7 100644 --- a/docs/getting-started-collections.md +++ b/docs/getting-started-collections.md @@ -450,7 +450,7 @@ With `shared_state` enabled, the **default scenario becomes the lifecycle manage - **Default scenario handles create/destroy**: The default scenario's `create` and `destroy` actions manage the infrastructure lifecycle for ALL scenarios - **Component scenarios skip create/destroy**: Individual scenarios (role1, role2, role3) only run their test sequence (prepare, converge, verify, etc.) - they do not create or destroy their own resources -- **Shared ephemeral state**: All scenarios share the same state directory, allowing them to access resources created by the default scenario +- **Shared ephemeral state**: All scenarios share one state directory (`state.yml` and `instance_config.yml`), allowing them to access resources created by the default scenario, and each scenario keeps its own inventory and configuration files **Why this approach is required for this configuration:** diff --git a/docs/philosophy.md b/docs/philosophy.md index d05b937437..15ea83e191 100644 --- a/docs/philosophy.md +++ b/docs/philosophy.md @@ -415,8 +415,14 @@ This pattern directly demonstrates several [testing framework requirements](#ess - **Multi-platform support**: Abstract enterprise infrastructure complexity while enabling test-specific instance definitions - **Extensibility and integration**: Clean integration with existing enterprise toolchains and governance policies -**Multi-scenario/multi-action data sharing** -When using native inventory patterns, teams often need to share host-specific data between different Molecule actions (create, converge, verify, destroy). This is especially valuable when using `--shared-state`, where the `default` scenario's create action provisions infrastructure and other scenarios need access to resource-specific data captured during that initial provisioning. A simple and effective approach uses temporary files to pass data from one action to subsequent actions: +**Sharing one environment across scenarios** +Real environments are built once and used many times, and a test suite should work the same way. Standing up infrastructure, preparing data, and connecting services is costly, so repeating that work for every check is slow and unlike how the system really runs. + +Molecule lets a single setup serve the whole suite. One part of a run creates the shared environment and records what it produced. The checks that follow run against that same environment and build on those results instead of starting over, and when the run ends the environment is torn down once. + +Molecule calls this shared state. One scenario, the default, creates and destroys the environment for the whole run, and the others skip setup and teardown to test against it. Whatever the default scenario captures while provisioning is kept where every scenario can reach it, so a later scenario reads what an earlier one produced rather than rediscovering it. + +The following playbooks show one way to do this, writing data as the default scenario provisions, reading it back in a later scenario, and removing it at teardown: ```yaml # Example: Sharing infrastructure and host-specific data between actions @@ -426,7 +432,7 @@ When using native inventory patterns, teams often need to share host-specific da hosts: localhost gather_facts: false vars: - execution_vars: "{% raw %}{{ molecule_ephemeral_directory }}{% endraw %}/execution_vars/" + execution_vars: "{% raw %}{{ molecule_shared_ephemeral_directory }}{% endraw %}/execution_vars/" tasks: - name: Ensure execution vars directory exists ansible.builtin.file: @@ -455,7 +461,7 @@ When using native inventory patterns, teams often need to share host-specific da hosts: molecule gather_facts: false vars: - execution_vars: "{% raw %}{{ molecule_ephemeral_directory }}{% endraw %}/execution_vars/" + execution_vars: "{% raw %}{{ molecule_shared_ephemeral_directory }}{% endraw %}/execution_vars/" vars_files: - "{% raw %}{{ execution_vars }}{% endraw %}host_{% raw %}{{ inventory_hostname }}{% endraw %}.yml" tasks: @@ -469,7 +475,7 @@ When using native inventory patterns, teams often need to share host-specific da hosts: localhost gather_facts: false vars: - execution_vars: "{% raw %}{{ molecule_ephemeral_directory }}{% endraw %}/execution_vars/" + execution_vars: "{% raw %}{{ molecule_shared_ephemeral_directory }}{% endraw %}/execution_vars/" tasks: - name: Destroying resources ansible.builtin.debug: diff --git a/src/molecule/config.py b/src/molecule/config.py index 5f2b2e7888..0d403575ab 100644 --- a/src/molecule/config.py +++ b/src/molecule/config.py @@ -124,6 +124,11 @@ def __init__( self.command_args: CommandArgs = command_args if command_args is not None else {} self.ansible_args = ansible_args self.config_data = self._get_config() + # Apply the CLI shared_state override before env first evaluates the + # scenario's ephemeral directory and the state file path (both cached + # on first use); _reget_config replaces config_data, so the override + # is applied again below. + self._apply_cli_overrides() self._action: str | None = None self._run_uuid = str(uuid4()) self.project_directory = os.getenv( @@ -379,6 +384,11 @@ def env(self) -> dict[str, str]: "MOLECULE_STATE_FILE": self.state.state_file, "MOLECULE_INVENTORY_FILE": self.provisioner.inventory_file, # type: ignore[union-attr] "MOLECULE_EPHEMERAL_DIRECTORY": self.scenario.ephemeral_directory, + "MOLECULE_SHARED_EPHEMERAL_DIRECTORY": ( + self.scenario.shared_ephemeral_directory + if self.shared_state + else self.scenario.ephemeral_directory + ), "MOLECULE_SCENARIO_DIRECTORY": self.scenario.directory, "MOLECULE_PROJECT_DIRECTORY": self.project_directory, "MOLECULE_INSTANCE_CONFIG": self.driver.instance_config, diff --git a/src/molecule/driver/base.py b/src/molecule/driver/base.py index b74c90ba5c..775b7a13f2 100644 --- a/src/molecule/driver/base.py +++ b/src/molecule/driver/base.py @@ -169,9 +169,19 @@ def options(self) -> DriverOptions: def instance_config(self) -> str: """Instance config file location. + When shared_state is enabled, this file lives at the shared ephemeral + directory so sibling scenarios read the same instance list. + Returns: Path to instance_config.yml. """ + if self._config.shared_state: + return str( + Path( + self._config.scenario.shared_ephemeral_directory, + "instance_config.yml", + ), + ) return str( Path( self._config.scenario.ephemeral_directory, diff --git a/src/molecule/provisioner/ansible.py b/src/molecule/provisioner/ansible.py index 903cea0227..c07a684f22 100644 --- a/src/molecule/provisioner/ansible.py +++ b/src/molecule/provisioner/ansible.py @@ -246,6 +246,7 @@ def inventory(self) -> dict[str, Any]: molecule_vars = { "molecule_file": "{{ lookup('env', 'MOLECULE_FILE') }}", "molecule_ephemeral_directory": "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}", + "molecule_shared_ephemeral_directory": "{{ lookup('env', 'MOLECULE_SHARED_EPHEMERAL_DIRECTORY') }}", "molecule_scenario_directory": "{{ lookup('env', 'MOLECULE_SCENARIO_DIRECTORY') }}", "molecule_yml": "{{ lookup('file', molecule_file) | from_yaml }}", "molecule_instance_config": "{{ lookup('env', 'MOLECULE_INSTANCE_CONFIG') }}", diff --git a/src/molecule/scenario.py b/src/molecule/scenario.py index 839ac0fe99..7676dd0502 100644 --- a/src/molecule/scenario.py +++ b/src/molecule/scenario.py @@ -127,8 +127,9 @@ def directory(self) -> str: def ephemeral_directory(self) -> str: """Acquire the ephemeral directory. - When shared_state is enabled, returns the shared ephemeral directory - so all scenarios use the same working directory. + When shared_state is enabled and MOLECULE_EPHEMERAL_DIRECTORY is unset, + returns a per-scenario directory nested under the shared ephemeral + directory. Returns: The ephemeral directory for this scenario. @@ -149,7 +150,7 @@ def ephemeral_directory(self) -> str: path = self.config.runtime.cache_dir / "tmp" / project_scenario_directory if self.config.shared_state: - path = Path(self.shared_ephemeral_directory) + path = Path(self.shared_ephemeral_directory) / safe_name else: path = Path(os.getenv("MOLECULE_EPHEMERAL_DIRECTORY", "")) path.mkdir(parents=True, exist_ok=True) diff --git a/src/molecule/state.py b/src/molecule/state.py index 68b14b7e72..626c6d4842 100644 --- a/src/molecule/state.py +++ b/src/molecule/state.py @@ -238,4 +238,14 @@ def _write_state_file(self) -> None: util.atomic_write_file(self.state_file, util.safe_dump(self._data)) def _get_state_file(self) -> Path: + """Resolve the path to the scenario's state file. + + When shared_state is enabled, the state file lives at the shared + ephemeral directory so scenarios share one run state. + + Returns: + Path to state.yml. + """ + if self._config.shared_state: + return Path(self._config.scenario.shared_ephemeral_directory) / "state.yml" return Path(self._config.scenario.ephemeral_directory) / "state.yml" diff --git a/tests/unit/command/test_base.py b/tests/unit/command/test_base.py index 5d798c3d37..4a683746da 100644 --- a/tests/unit/command/test_base.py +++ b/tests/unit/command/test_base.py @@ -690,8 +690,21 @@ def test_apply_cli_overrides_comprehensive( original_apply_cli_overrides = config.Config._apply_cli_overrides def mock_apply_cli_overrides(self: config.Config) -> None: + """Apply the CLI overrides and stop main after the final override pass. + + Config.__init__ applies the CLI overrides before and after the env + overrides; the first pass records the Config, the second exits. + + Args: + self: The Config under construction. + + Raises: + ImmediateExit: On the final override pass, to stop main. + """ original_apply_cli_overrides(self) - captured_configs.append(self) + if self not in captured_configs: + captured_configs.append(self) + return msg = "Test capture complete" raise ImmediateExit(msg, 0) @@ -798,8 +811,21 @@ def test_apply_env_overrides_comprehensive( # noqa: PLR0913 original_apply_cli_overrides = config.Config._apply_cli_overrides def mock_apply_cli_overrides(self: config.Config) -> None: + """Apply the CLI overrides and stop main after the final override pass. + + Config.__init__ applies the CLI overrides before and after the env + overrides; the first pass records the Config, the second exits. + + Args: + self: The Config under construction. + + Raises: + ImmediateExit: On the final override pass, to stop main. + """ original_apply_cli_overrides(self) - captured_configs.append(self) + if self not in captured_configs: + captured_configs.append(self) + return msg = "Test capture complete" raise ImmediateExit(msg, 0) @@ -903,8 +929,21 @@ def test_env_var_cli_precedence( # noqa: PLR0913 original_apply_cli_overrides = config.Config._apply_cli_overrides def mock_apply_cli_overrides(self: config.Config) -> None: + """Apply the CLI overrides and stop main after the final override pass. + + Config.__init__ applies the CLI overrides before and after the env + overrides; the first pass records the Config, the second exits. + + Args: + self: The Config under construction. + + Raises: + ImmediateExit: On the final override pass, to stop main. + """ original_apply_cli_overrides(self) - captured_configs.append(self) + if self not in captured_configs: + captured_configs.append(self) + return msg = "Test capture complete" raise ImmediateExit(msg, 0) diff --git a/tests/unit/command/test_reset.py b/tests/unit/command/test_reset.py index e9bd6ad7af..aa40927a8c 100644 --- a/tests/unit/command/test_reset.py +++ b/tests/unit/command/test_reset.py @@ -7,12 +7,14 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import click import pytest from molecule import logger from molecule.api import drivers from molecule.command import base from molecule.command.base import _run_scenarios +from molecule.config import Config LOG = logger.get_scenario_logger(__name__, "reset", "test") @@ -21,7 +23,6 @@ if TYPE_CHECKING: from collections.abc import Callable - from molecule.config import Config from molecule.types import CommandArgs, MoleculeArgs @@ -516,3 +517,59 @@ def test_reset_does_not_clean_shared_without_all_flag( # Shared directory should NOT be removed when --all is not used assert str(tmp_path / "shared") not in removed_directories + + +def test_reset_under_shared_state_removes_only_the_scenario_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + patched_execute_subcommand: Callable[..., None], +) -> None: + """Under shared_state a single-scenario reset leaves the shared root's files in place. + + Uses a real Config and the real shutil.rmtree so the directory removed is + the one the scenario actually resolves, not a stand-in path. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + patched_execute_subcommand: Patched execute_subcommand_default function. + """ + monkeypatch.delenv("MOLECULE_EPHEMERAL_DIRECTORY", raising=False) + monkeypatch.setenv("ANSIBLE_HOME", str(tmp_path / ".ansible")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "molecule.command.base.execute_subcommand_default", + patched_execute_subcommand, + ) + + # Config.__init__ caches the scenario and state paths, so shared_state + # has to be in force before construction: give it the way the CLI does. + with click.Context(click.Command("reset")) as ctx: + ctx.set_parameter_source("shared_state", click.core.ParameterSource.COMMANDLINE) + cfg = Config("", command_args={"shared_state": True}) + cfg.config_data["prerun"] = False + scenario = cfg.scenario + shared_root = Path(scenario.shared_ephemeral_directory) + scenario_dir = Path(scenario.ephemeral_directory) + state_file = Path(cfg.state.state_file) + instance_config = Path(cfg.driver.instance_config) + instance_config.write_text("[]\n") + + assert scenario_dir.parent == shared_root + assert scenario_dir.is_dir() + assert state_file.parent == shared_root + assert instance_config.parent == shared_root + + scenarios: Any = type( + "Scenarios", + (), + {"all": [scenario], "results": [], "shared_state": True}, + )() + command_args: CommandArgs = {"subcommand": "reset"} + + _run_scenarios(scenarios, command_args, None) + + assert not scenario_dir.exists() + assert shared_root.is_dir() + assert state_file.is_file() + assert instance_config.is_file() diff --git a/tests/unit/driver/test_delegated.py b/tests/unit/driver/test_delegated.py index e1468ec61a..eaa84cfcc3 100644 --- a/tests/unit/driver/test_delegated.py +++ b/tests/unit/driver/test_delegated.py @@ -331,17 +331,30 @@ def test_ansible_connection_options_handles_missing_instance_config_managed( # assert _instance.ansible_connection_options("foo") == {} -def test_ansible_connection_options_handles_missing_results_key_when_managed( # type: ignore[no-untyped-def] # noqa: ANN201, D103 +def test_ansible_connection_options_handles_missing_results_key_when_managed( mocker: MockerFixture, - _instance, # noqa: PT019 -): + _instance: delegated.Delegated, # noqa: PT019 +) -> None: + """Return empty connection options when the instance config has no results. + + Args: + mocker: Pytest mocker fixture. + _instance: A delegated driver instance. + """ m = mocker.patch("molecule.util.safe_load_file") m.side_effect = StopIteration assert _instance.ansible_connection_options("foo") == {} -def test_instance_config_property(_instance): # type: ignore[no-untyped-def] # noqa: ANN201, PT019, D103 +def test_instance_config_property( + _instance: delegated.Delegated, # noqa: PT019 +) -> None: + """Without shared_state instance_config resolves under the scenario's ephemeral dir. + + Args: + _instance: A delegated driver instance. + """ x = os.path.join( # noqa: PTH118 _instance._config.scenario.ephemeral_directory, "instance_config.yml", @@ -350,6 +363,23 @@ def test_instance_config_property(_instance): # type: ignore[no-untyped-def] # assert x == _instance.instance_config +def test_instance_config_property_shared_state( + _instance: delegated.Delegated, # noqa: PT019 +) -> None: + """Under shared_state instance_config resolves to the shared root, not a scenario dir. + + Args: + _instance: A delegated driver instance. + """ + _instance._config.config_data["shared_state"] = True + x = os.path.join( # noqa: PTH118 + _instance._config.scenario.shared_ephemeral_directory, + "instance_config.yml", + ) + + assert x == _instance.instance_config + + @pytest.mark.parametrize( "config_instance", ["_driver_unmanaged_section_data"], # noqa: PT007 diff --git a/tests/unit/provisioner/test_ansible.py b/tests/unit/provisioner/test_ansible.py index 40ad64cb60..0ed3c1a8b3 100644 --- a/tests/unit/provisioner/test_ansible.py +++ b/tests/unit/provisioner/test_ansible.py @@ -17,9 +17,13 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. + +# pylint: disable=too-many-lines + from __future__ import annotations import collections +import configparser import os from pathlib import Path @@ -767,7 +771,12 @@ def test_get_plugin_directory(instance): # type: ignore[no-untyped-def] # noqa assert parts[-4:] == ("molecule", "provisioner", "ansible", "plugins") -def test_absolute_path_for(instance): # type: ignore[no-untyped-def] # noqa: ANN201, D103 +def test_absolute_path_for(instance: ansible.Ansible) -> None: + """_absolute_path_for resolves each colon-separated entry under the scenario directory. + + Args: + instance: Ansible provisioner instance. + """ env = {"foo": "foo:bar"} x = ":".join( [ @@ -779,7 +788,12 @@ def test_absolute_path_for(instance): # type: ignore[no-untyped-def] # noqa: A assert x == instance._absolute_path_for(env, "foo") -def test_absolute_path_for_raises_with_missing_key(instance): # type: ignore[no-untyped-def] # noqa: ANN201, D103 +def test_absolute_path_for_raises_with_missing_key(instance: ansible.Ansible) -> None: + """_absolute_path_for raises KeyError for a key absent from the env mapping. + + Args: + instance: Ansible provisioner instance. + """ env = {"foo": "foo:bar"} with pytest.raises(KeyError): @@ -789,6 +803,163 @@ def test_absolute_path_for_raises_with_missing_key(instance): # type: ignore[no # Test ansible section integration with provisioner +# Regression tests for shared_state working-directory isolation (issue #4666). + + +def _shared_state_provisioner( + scenario_name: str, + inventory: dict[str, Any], +) -> ansible.Ansible: + """Build a shared_state Ansible provisioner for one scenario. + + Args: + scenario_name: The molecule scenario name. + inventory: Provisioner inventory keys for this scenario. + + Returns: + An Ansible provisioner bound to a shared_state config. + """ + c = config.Config(molecule_file="") + c.config_data["shared_state"] = True + c.config_data["scenario"]["name"] = scenario_name + c.config_data["provisioner"]["inventory"].update(inventory) # type: ignore[typeddict-item] + # Config caches its scenario at init with the defaults; rebuild it so the + # shared_state and name set above drive the ephemeral directory. + del c.scenario + return ansible.Ansible(c) + + +def _redirect_ephemeral_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Redirect the ansible-compat runtime cache under tmp_path. + + The shared ephemeral root derives from the runtime cache, so this keeps the + filesystem writes inside tmp_path rather than the repo's .ansible or the + user cache. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + monkeypatch.setenv("ANSIBLE_HOME", str(tmp_path / ".ansible")) + monkeypatch.chdir(tmp_path) + + +def test_shared_state_group_vars_survive_sibling_action( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A sibling scenario's manage_inventory must not delete our group_vars (#4666). + + manage_inventory removes (rmtree) and rewrites the group_vars tree under its + inventory_directory. Once each scenario owns its own inventory directory a + sibling's rewrite cannot reach ours, so the surviving group_vars file is the + behavioral check. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + _redirect_ephemeral_root(monkeypatch, tmp_path) + alpha = _shared_state_provisioner( + "alpha", + {"group_vars": {"alpha_group": [{"owner": "alpha"}]}}, + ) + beta = _shared_state_provisioner( + "beta", + {"group_vars": {"beta_group": [{"owner": "beta"}]}}, + ) + + alpha.manage_inventory() + beta.manage_inventory() + + assert alpha.inventory_directory != beta.inventory_directory + assert str(tmp_path) in alpha.inventory_directory + alpha_group_vars = Path(alpha.inventory_directory) / "group_vars" / "alpha_group" + assert alpha_group_vars.is_file() + assert util.safe_load_file(alpha_group_vars) == [{"owner": "alpha"}] + + +def test_shared_state_hosts_survive_sibling_action( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Sibling scenarios must own distinct hosts and exported inventory files (#4666). + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + _redirect_ephemeral_root(monkeypatch, tmp_path) + alpha = _shared_state_provisioner( + "alpha", + {"hosts": {"all": {"hosts": {"alpha-host": {}}}}}, + ) + beta = _shared_state_provisioner( + "beta", + {"hosts": {"all": {"hosts": {"beta-host": {}}}}}, + ) + alpha._config.config_data["platforms"] = [{"name": "alpha-instance"}] + beta._config.config_data["platforms"] = [{"name": "beta-instance"}] + + alpha.manage_inventory() + beta.manage_inventory() + + assert alpha.inventory_directory != beta.inventory_directory + assert str(tmp_path) in alpha.inventory_directory + alpha_hosts = Path(alpha.inventory_directory) / "hosts" + assert util.safe_load_file(alpha_hosts)["all"]["hosts"] == {"alpha-host": {}} + # inventory_file is the ansible_inventory.yml molecule exports as + # MOLECULE_INVENTORY_FILE; it must carry only this scenario's platform host. + exported = util.safe_load_file(alpha.inventory_file) + assert "alpha-instance" in exported["all"]["hosts"] + assert "beta-instance" not in exported["all"]["hosts"] + + +def test_shared_state_ansible_cfg_survives_sibling_write( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A sibling's write_config must not overwrite our ansible.cfg (#4666). + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + _redirect_ephemeral_root(monkeypatch, tmp_path) + alpha = _shared_state_provisioner("alpha", {}) + beta = _shared_state_provisioner("beta", {}) + alpha._config.config_data["ansible"]["cfg"] = {"defaults": {"forks": 11}} + beta._config.config_data["ansible"]["cfg"] = {"defaults": {"forks": 22}} + + alpha.write_config() + beta.write_config() + + assert alpha.config_file != beta.config_file + cp = configparser.ConfigParser() + cp.read(alpha.config_file) + assert cp["defaults"]["forks"] == "11" + + +def test_inventory_exports_ephemeral_directory_vars(instance: Ansible) -> None: + """The rendered inventory exposes both ephemeral directories to plays. + + Args: + instance: Ansible provisioner instance. + """ + all_vars = instance.inventory["all"]["vars"] + assert ( + all_vars["molecule_ephemeral_directory"] + == "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}" + ) + assert ( + all_vars["molecule_shared_ephemeral_directory"] + == "{{ lookup('env', 'MOLECULE_SHARED_EPHEMERAL_DIRECTORY') }}" + ) + + def test_ansible_args_property_with_ansible_playbook_backend() -> None: """Test ansible_args property with ansible-playbook backend (forward-looking config).""" c = config.Config(molecule_file="") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 41fa72982d..5b5e64a9e4 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal +import click import pytest from molecule import config, platforms, scenario, state, util @@ -214,10 +215,20 @@ def test_dependency_property_is_shell(config_instance: config.Config) -> None: @pytest.fixture def _config_driver_delegated_section_data() -> dict[Literal["driver"], DriverData]: + """Driver section data for an unmanaged default driver. + + Returns: + A config fragment with the driver section. + """ return {"driver": {"name": "default", "options": {"managed": False}}} -def test_env(config_instance: config.Config) -> None: # noqa: D103 +def test_env(config_instance: config.Config) -> None: + """Export the full set of MOLECULE_* environment variables. + + Args: + config_instance: Instance of Config. + """ config_instance.args = {"env_file": ".env"} env_file = config_instance.args.get("env_file") assert isinstance(env_file, str) @@ -227,6 +238,8 @@ def test_env(config_instance: config.Config) -> None: # noqa: D103 "MOLECULE_ENV_FILE": util.abs_path(env_file), "MOLECULE_INVENTORY_FILE": config_instance.provisioner.inventory_file, # type: ignore[union-attr] "MOLECULE_EPHEMERAL_DIRECTORY": config_instance.scenario.ephemeral_directory, + # Without shared_state the shared directory is the scenario's own. + "MOLECULE_SHARED_EPHEMERAL_DIRECTORY": config_instance.scenario.ephemeral_directory, "MOLECULE_SCENARIO_DIRECTORY": config_instance.scenario.directory, "MOLECULE_PROJECT_DIRECTORY": config_instance.project_directory, "MOLECULE_INSTANCE_CONFIG": config_instance.driver.instance_config, @@ -491,15 +504,25 @@ def test_set_env_from_file(config_instance: config.Config) -> None: # noqa: D10 assert contents == env -def test_set_env_from_file_returns_original_env_when_env_file_not_found( # noqa: D103 +def test_set_env_from_file_returns_original_env_when_env_file_not_found( config_instance: config.Config, ) -> None: + """set_env_from_file returns the given env unchanged when the file is missing. + + Args: + config_instance: Instance of Config. + """ env = config.set_env_from_file({}, "file-not-found") assert env == {} -def test_write_config(config_instance: config.Config) -> None: # noqa: D103 +def test_write_config(config_instance: config.Config) -> None: + """write() creates the molecule.yml at config_file. + + Args: + config_instance: Instance of Config. + """ config_instance.write() assert os.path.isfile(config_instance.config_file) # noqa: PTH113 @@ -508,6 +531,97 @@ def test_write_config(config_instance: config.Config) -> None: # noqa: D103 # Test ansible section functionality +def test_shared_state_molecule_yml_survives_sibling_write( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A sibling scenario's write() must not overwrite our molecule.yml (#4666). + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + # Redirect the ansible-compat runtime cache under tmp_path so the + # filesystem writes stay hermetic, out of the repo's .ansible. + monkeypatch.setenv("ANSIBLE_HOME", str(tmp_path / ".ansible")) + monkeypatch.chdir(tmp_path) + alpha = config.Config("") + alpha.config_data["shared_state"] = True + alpha.config_data["scenario"]["name"] = "alpha" + beta = config.Config("") + beta.config_data["shared_state"] = True + beta.config_data["scenario"]["name"] = "beta" + # Config caches its scenario at init with the defaults; rebuild it so the + # shared_state and name set above drive the ephemeral directory. + del alpha.scenario + del beta.scenario + + alpha.write() + beta.write() + + assert alpha.config_file != beta.config_file + assert str(tmp_path) in alpha.config_file + assert util.safe_load_file(alpha.config_file)["scenario"]["name"] == "alpha" + + +def test_shared_state_cli_flag_applies_before_paths_are_cached( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """--shared-state given only on the CLI drives every cached path at init. + + Config.__init__ evaluates env (and so caches the scenario's ephemeral + directory and the state file path) before the final CLI override; the CLI + flag must already be in config_data by then so state.yml, + instance_config.yml (a live driver property) and the scenario directory + agree on the shared layout. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + monkeypatch.delenv("MOLECULE_EPHEMERAL_DIRECTORY", raising=False) + monkeypatch.setenv("ANSIBLE_HOME", str(tmp_path / ".ansible")) + monkeypatch.chdir(tmp_path) + + with click.Context(click.Command("test")) as ctx: + ctx.set_parameter_source("shared_state", click.core.ParameterSource.COMMANDLINE) + cfg = config.Config("", command_args={"shared_state": True}) + + shared_root = Path(cfg.scenario.shared_ephemeral_directory) + + assert cfg.shared_state is True + assert Path(cfg.scenario.ephemeral_directory).parent == shared_root + assert cfg.state.state_file == str(shared_root / "state.yml") + assert Path(cfg.driver.instance_config).parent == shared_root + assert Path(cfg.driver.instance_config).parent.is_dir() + + +def test_env_shared_directory_under_shared_state( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Under shared_state the exported shared directory is the shared root. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + monkeypatch.delenv("MOLECULE_EPHEMERAL_DIRECTORY", raising=False) + monkeypatch.setenv("ANSIBLE_HOME", str(tmp_path / ".ansible")) + monkeypatch.chdir(tmp_path) + cfg = config.Config("") + cfg.config_data["shared_state"] = True + # Config caches its scenario at init with the defaults; rebuild it so + # shared_state drives the ephemeral directory. + del cfg.scenario + + env = cfg.env + + assert env["MOLECULE_SHARED_EPHEMERAL_DIRECTORY"] == cfg.scenario.shared_ephemeral_directory + assert env["MOLECULE_SHARED_EPHEMERAL_DIRECTORY"] != env["MOLECULE_EPHEMERAL_DIRECTORY"] + + def test_ansible_section_defaults() -> None: """Test that ansible section gets proper defaults.""" config_instance = config.Config(molecule_file="") diff --git a/tests/unit/test_scenario.py b/tests/unit/test_scenario.py index d7dc31116c..99feb5ff61 100644 --- a/tests/unit/test_scenario.py +++ b/tests/unit/test_scenario.py @@ -217,17 +217,27 @@ def test_verify_sequence_property( # noqa: D103 assert _instance.verify_sequence == ["verify"] -def test_sequence_property_with_invalid_subcommand( # noqa: D103 +def test_sequence_property_with_invalid_subcommand( _instance: Scenario, # noqa: PT019 ) -> None: + """The sequence property is empty for an unknown subcommand. + + Args: + _instance: Scenario instance. + """ _instance.config.command_args = {"subcommand": "invalid"} assert _instance.sequence == [] -def test_setup_creates_ephemeral_and_inventory_directories( # noqa: D103 +def test_setup_creates_ephemeral_and_inventory_directories( _instance: Scenario, # noqa: PT019 ) -> None: + """_setup recreates a writable ephemeral directory and its inventory directory. + + Args: + _instance: Scenario instance. + """ ephemeral_dir = _instance.config.scenario.ephemeral_directory inventory_dir = _instance.config.scenario.inventory_directory shutil.rmtree(ephemeral_dir) @@ -239,19 +249,60 @@ def test_setup_creates_ephemeral_and_inventory_directories( # noqa: D103 assert os.access(ephemeral_dir, os.W_OK) -def test_shared_ephemeral_directory_with_shared_state( # noqa: D103 -) -> None: +def test_shared_ephemeral_directory_with_shared_state() -> None: + """Under shared_state each scenario gets its own dir under the shared root.""" cfg = config.Config("") cfg.config_data["shared_state"] = True scenario = Scenario(cfg) assert scenario.config.shared_state - assert scenario.ephemeral_directory == scenario.shared_ephemeral_directory + safe_name = scenario.name.replace("/", "--") + expected = str(Path(scenario.shared_ephemeral_directory) / safe_name) + assert scenario.ephemeral_directory == expected + + +def test_shared_state_scenarios_get_distinct_directories( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Two scenarios under shared_state get distinct dirs under one shared root. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + # Redirect the ansible-compat runtime cache under tmp_path so the + # filesystem writes stay hermetic, out of the repo's .ansible. + monkeypatch.setenv("ANSIBLE_HOME", str(tmp_path / ".ansible")) + monkeypatch.chdir(tmp_path) + cfg_a = config.Config("") + cfg_a.config_data["shared_state"] = True + cfg_a.config_data["scenario"]["name"] = "alpha" + cfg_b = config.Config("") + cfg_b.config_data["shared_state"] = True + cfg_b.config_data["scenario"]["name"] = "beta" + + scenario_a = Scenario(cfg_a) + scenario_b = Scenario(cfg_b) + + assert scenario_a.ephemeral_directory != scenario_b.ephemeral_directory + assert scenario_a.shared_ephemeral_directory == scenario_b.shared_ephemeral_directory + assert str(tmp_path) in scenario_a.shared_ephemeral_directory + assert Path(scenario_a.ephemeral_directory).parent == Path( + scenario_a.shared_ephemeral_directory, + ) -def test_shared_state_respects_env_var( # noqa: D103 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path +def test_shared_state_respects_env_var( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: + """MOLECULE_EPHEMERAL_DIRECTORY overrides the shared_state ephemeral directory. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ monkeypatch.setenv("MOLECULE_EPHEMERAL_DIRECTORY", str(tmp_path / "custom")) cfg = config.Config("") cfg.config_data["shared_state"] = True diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index af6a90ed01..1f7a7bb781 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -21,8 +21,10 @@ import os +from pathlib import Path from typing import TYPE_CHECKING +import click import pytest from molecule import config, state, util @@ -31,19 +33,81 @@ if TYPE_CHECKING: from typing import Any + from pytest_mock import MockerFixture + @pytest.fixture def _instance(config_instance: config.Config) -> state.State: + """Build a State instance from the config fixture. + + Args: + config_instance: Instance of Config. + + Returns: + A molecule State bound to config_instance. + """ return state.State(config_instance) -def test_state_file_property(_instance: state.State) -> None: # noqa: PT019, D103 +def test_state_file_property(_instance: state.State) -> None: # noqa: PT019 + """Without shared_state the state file resolves under the scenario's ephemeral dir. + + Args: + _instance: A molecule State instance. + """ x = os.path.join(_instance._config.scenario.ephemeral_directory, "state.yml") # noqa: PTH118 assert x == _instance.state_file -def test_converged(_instance: state.State) -> None: # noqa: PT019, D103 +def test_shared_state_state_file_at_shared_root( + _instance: state.State, # noqa: PT019 +) -> None: + """Under shared_state the state file resolves to the shared root, not a scenario dir. + + Args: + _instance: A molecule State instance. + """ + _instance._config.config_data["shared_state"] = True + scenario = _instance._config.scenario + expected = Path(scenario.shared_ephemeral_directory) / "state.yml" + + assert _instance._get_state_file() == expected + + +def test_shared_state_env_directory_places_every_path_in_it( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """With MOLECULE_EPHEMERAL_DIRECTORY set, shared_state uses that one directory for everything. + + Args: + monkeypatch: Pytest monkeypatch fixture. + tmp_path: Pytest tmp_path fixture. + """ + one_directory = tmp_path / "one" + monkeypatch.setenv("MOLECULE_EPHEMERAL_DIRECTORY", str(one_directory)) + monkeypatch.setenv("ANSIBLE_HOME", str(tmp_path / ".ansible")) + monkeypatch.chdir(tmp_path) + # Config.__init__ caches the scenario and state paths, so shared_state + # has to be in force before construction: give it the way the CLI does. + with click.Context(click.Command("test")) as ctx: + ctx.set_parameter_source("shared_state", click.core.ParameterSource.COMMANDLINE) + cfg = config.Config("", command_args={"shared_state": True}) + + assert cfg.shared_state is True + assert Path(cfg.scenario.ephemeral_directory) == one_directory + assert Path(cfg.scenario.shared_ephemeral_directory) == one_directory + assert Path(cfg.state.state_file).parent == one_directory + assert Path(cfg.driver.instance_config).parent == one_directory + + +def test_converged(_instance: state.State) -> None: # noqa: PT019 + """A fresh State reports converged as False. + + Args: + _instance: A molecule State instance. + """ assert not _instance.converged @@ -106,15 +170,26 @@ def test_change_state_prepared(_instance: state.State) -> None: # noqa: PT019, assert _instance.prepared -def test_change_state_raises(_instance: state.State) -> None: # noqa: PT019, D103 +def test_change_state_raises(_instance: state.State) -> None: # noqa: PT019 + """change_state rejects an unknown state key. + + Args: + _instance: A molecule State instance. + """ with pytest.raises(state.InvalidState): _instance.change_state("invalid-state", True) # noqa: FBT003 -def test_change_state_does_not_clobber_a_sibling_states_write( # noqa: D103 +def test_change_state_does_not_clobber_a_sibling_states_write( _instance: state.State, # noqa: PT019 config_instance: config.Config, ) -> None: + """change_state merges into state.yml on disk instead of overwriting a sibling's write. + + Args: + _instance: A molecule State instance. + config_instance: Instance of Config. + """ # `_instance` is constructed first, so its in-memory snapshot predates the # write below, the same way a scenario's own State object under # shared_state is constructed before default_config's create step runs. @@ -128,11 +203,47 @@ def test_change_state_does_not_clobber_a_sibling_states_write( # noqa: D103 assert on_disk["prepared"] is True -def test_get_data_loads_existing_state_file( # noqa: D103 +def test_write_state_file_routes_through_atomic_write( + config_instance: config.Config, + mocker: MockerFixture, +) -> None: + """change_state must persist state.yml via util.atomic_write_file (#4667/#4666). + + State.__init__ already writes through util.atomic_write_file, so the spy + count is taken after construction. Asserts that change_state adds exactly + one util.atomic_write_file call, that the call targets the state file, and + that the state file's inode changes across the write, which an in-place + write_text would keep and a mkstemp-then-replace changes. + + Args: + config_instance: Instance of Config. + mocker: pytest-mock fixture. + """ + atomic_spy = mocker.spy(util, "atomic_write_file") + + s = state.State(config_instance) + calls_after_init = atomic_spy.call_count + before_inode = Path(s.state_file).stat().st_ino + + s.change_state("converged", True) # noqa: FBT003 + + assert atomic_spy.call_count == calls_after_init + 1 + assert atomic_spy.call_args.args[0] == s.state_file + assert Path(s.state_file).stat().st_ino != before_inode + + +def test_get_data_loads_existing_state_file( _instance: state.State, # noqa: PT019 molecule_data: dict[str, Any], config_instance: config.Config, ) -> None: + """A fresh State reads back the data already written to the state file. + + Args: + _instance: A molecule State instance. + molecule_data: Baseline molecule config data fixture. + config_instance: Instance of Config. + """ data = {"converged": False, "created": True, "driver": None, "prepared": None} util.write_file(_instance._state_file, util.safe_dump(data)) diff --git a/tests/unit/test_worker.py b/tests/unit/test_worker.py index 21b87609ab..36c298bbc1 100644 --- a/tests/unit/test_worker.py +++ b/tests/unit/test_worker.py @@ -167,7 +167,10 @@ def test_run_one_returns_results_on_success( command_args={**command_args, "force": True}, ansible_args=(), ) - mock_execute.assert_called_once() + mock_execute.assert_called_once_with( + mock_config_cls.return_value.scenario, + shared_state=True, + ) assert result.name == "test_scenario" assert error is None assert ansible_output == ""