diff --git a/docs/guides/parallel.md b/docs/guides/parallel.md index 999dd7cecf..a3b1403014 100644 --- a/docs/guides/parallel.md +++ b/docs/guides/parallel.md @@ -31,14 +31,53 @@ molecule test --all --workers cpus-1 `molecule.yml` so that the default scenario handles infrastructure create/destroy while workers run the test sequences. +### Scenario slicing with `--slice` + +When scenario names contain directory separators (e.g. +`appliance_vlans/gathered`, `appliance_vlans/merged`), the `--slice` +flag controls how scenarios are grouped into work units dispatched to +workers. + +| `--slice` | Behavior | +|-----------|----------| +| `1` (default) | Group by the first path segment — all CRUD states for a resource run sequentially on one worker. | +| `2` | Each leaf scenario is an independent work unit (no grouping). | + +```bash +# Group by resource (default when --workers is used) +molecule test --all --workers 4 + +# Treat each scenario independently +molecule test --all --workers 4 --slice 2 +``` + +`slice` can also be set in the config file (`config.yml` or +`molecule.yml`) so it travels with the project: + +```yaml +# extensions/molecule/config.yml +shared_state: true +slice: 1 +``` + +The CLI flag overrides the config value when both are present. + +For example, with 186 scenarios across 37 resources, `slice: 1` +produces 37 work units instead of 186, each containing ~5 scenarios +that run sequentially within the worker. + +The CLI `--slice` flag requires `--workers` > 1. When defined only +in the config file, `slice` is silently ignored in sequential mode. + ### How it works 1. The **default scenario's `create`** runs first (serial, main process). 2. Prerun tasks run for all scenarios (serial, main process). -3. Scenarios are submitted to a `ProcessPoolExecutor` with the specified - number of workers. Each worker reconstructs a `Config` from the - scenario's `molecule.yml` and runs the scenario's sequence, skipping - `create` and `destroy` (handled by the default scenario). +3. Scenarios are grouped into slices according to `--slice` depth and + submitted to a `ProcessPoolExecutor` with the specified number of + workers. Each worker reconstructs a `Config` from each scenario's + `molecule.yml` and runs the scenario's sequence, skipping `create` + and `destroy` (handled by the default scenario). 4. Results are collected as workers complete. 5. The **default scenario's `destroy`** runs last (serial, main process). @@ -58,6 +97,7 @@ molecule test --all --workers 4 --continue-on-failure ### Incompatible options - `--workers` > 1 cannot be combined with `--destroy=never`. +- `--slice` cannot be used when `--workers` is 1 (no parallelism). --- diff --git a/src/molecule/click_cfg.py b/src/molecule/click_cfg.py index 89068b1758..30740d78a3 100644 --- a/src/molecule/click_cfg.py +++ b/src/molecule/click_cfg.py @@ -398,6 +398,17 @@ def subcommand(self) -> CliOption: nargs=1, ) + @property + def slice(self) -> CliOption: + """Scenario grouping depth for worker dispatch.""" + return CliOption( + name="slice", + help="Directory depth at which scenarios are grouped into worker units. " + "1 groups by top-level resource, 2 treats each leaf scenario independently.", + default="1", + experimental=True, + ) + @property def workers(self) -> CliOption: """Worker count for concurrent scenario execution.""" diff --git a/src/molecule/command/check.py b/src/molecule/command/check.py index 5db7509f61..779c223ff4 100644 --- a/src/molecule/command/check.py +++ b/src/molecule/command/check.py @@ -50,7 +50,7 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click_command_ex() -@common_options("continue_on_failure", "parallel", "workers") +@common_options("continue_on_failure", "parallel", "slice", "workers") def check(ctx: click.Context) -> None: # pragma: no cover """Use the provisioner to perform a Dry-Run (destroy, dependency, create, prepare, converge). @@ -74,6 +74,7 @@ def check(ctx: click.Context) -> None: # pragma: no cover "parallel": parallel, "report": ctx.params["report"], "shared_state": ctx.params["shared_state"], + "slice": int(ctx.params["slice"]), "subcommand": subcommand, "workers": resolve_workers(ctx.params["workers"]), } diff --git a/src/molecule/command/destroy.py b/src/molecule/command/destroy.py index 765d80563e..e6d03a6af4 100644 --- a/src/molecule/command/destroy.py +++ b/src/molecule/command/destroy.py @@ -55,7 +55,7 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click_command_ex() -@common_options("continue_on_failure", "driver_name_with_choices", "parallel", "workers") +@common_options("continue_on_failure", "driver_name_with_choices", "parallel", "slice", "workers") def destroy(ctx: click.Context) -> None: # pragma: no cover """Use the provisioner to destroy the instances. @@ -78,6 +78,7 @@ def destroy(ctx: click.Context) -> None: # pragma: no cover "driver_name": ctx.params["driver_name"], "report": ctx.params["report"], "shared_state": ctx.params["shared_state"], + "slice": int(ctx.params["slice"]), "subcommand": subcommand, "workers": resolve_workers(ctx.params["workers"]), } diff --git a/src/molecule/command/test.py b/src/molecule/command/test.py index c7c350d79f..21133fb8e7 100644 --- a/src/molecule/command/test.py +++ b/src/molecule/command/test.py @@ -53,6 +53,7 @@ def execute(self, action_args: list[str] | None = None) -> None: "driver_name_with_choices", "platform_name_with_default", "parallel", + "slice", "workers", "ansible_args", ) @@ -80,6 +81,7 @@ def test(ctx: click.Context) -> None: # pragma: no cover "platform_name": ctx.params["platform_name"], "report": ctx.params["report"], "shared_state": ctx.params["shared_state"], + "slice": int(ctx.params["slice"]), "subcommand": subcommand, "workers": resolve_workers(ctx.params["workers"]), } diff --git a/src/molecule/config.py b/src/molecule/config.py index 491d46793e..765711083b 100644 --- a/src/molecule/config.py +++ b/src/molecule/config.py @@ -151,13 +151,19 @@ def _apply_cli_overrides(self) -> None: This method modifies the config dictionary to include CLI overrides, creating a single source of truth for configuration values. """ - # Apply shared_state CLI override ONLY if it was explicitly provided via CLI ctx = click.get_current_context(silent=True) - if "shared_state" not in self.command_args or ctx is None: + if ctx is None: return - source = ctx.get_parameter_source("shared_state") - if source == click.core.ParameterSource.COMMANDLINE: - self.config["shared_state"] = self.command_args["shared_state"] + + if "shared_state" in self.command_args: + source = ctx.get_parameter_source("shared_state") + if source == click.core.ParameterSource.COMMANDLINE: + self.config["shared_state"] = self.command_args["shared_state"] + + if "slice" in self.command_args: + source = ctx.get_parameter_source("slice") + if source == click.core.ParameterSource.COMMANDLINE: + self.config["slice"] = self.command_args["slice"] def _apply_env_overrides(self) -> None: """Apply environment variable overrides to command_args. @@ -222,6 +228,15 @@ def shared_state(self) -> bool: """ return self.config.get("shared_state", False) + @property + def slice(self) -> int: + """Directory depth for grouping scenarios into worker units. + + Returns: + The slice depth (1 = group by top-level resource, 2 = each leaf independently). + """ + return int(self.config.get("slice", 1)) + @property def command_borders(self) -> bool: """Return if command borders are enabled.""" diff --git a/src/molecule/data/molecule.json b/src/molecule/data/molecule.json index 52e4e9423e..d983068ad2 100644 --- a/src/molecule/data/molecule.json +++ b/src/molecule/data/molecule.json @@ -771,6 +771,13 @@ "title": "Shared State", "type": "boolean" }, + "slice": { + "default": 1, + "description": "Directory depth for grouping scenarios into worker units. 1 groups by top-level resource, 2 treats each leaf scenario independently.", + "minimum": 1, + "title": "Slice", + "type": "integer" + }, "verifier": { "$ref": "#/$defs/VerifierModel" } diff --git a/src/molecule/scenarios.py b/src/molecule/scenarios.py index 2268461d56..aee4e08235 100644 --- a/src/molecule/scenarios.py +++ b/src/molecule/scenarios.py @@ -103,6 +103,17 @@ def shared_state(self) -> bool: """ return any(scenario.config.shared_state for scenario in self.all) + @property + def slice(self) -> int: + """Directory depth for grouping scenarios into worker units. + + Returns: + The configured slice depth from the first scenario's config (default 1). + """ + if self.all: + return self.all[0].config.slice + return 1 + def print_matrix(self) -> None: """Show the matrix for all scenarios.""" tree = {} diff --git a/src/molecule/types.py b/src/molecule/types.py index 62f9c0d946..0634ba2228 100644 --- a/src/molecule/types.py +++ b/src/molecule/types.py @@ -294,6 +294,7 @@ class ConfigData(TypedDict, total=False): provisioner: Provisioner config. scenario: Scenario config. shared_state: Should state be shared between scenarios. + slice: Directory depth for grouping scenarios into worker units. verifier: Verifier config. """ @@ -306,6 +307,7 @@ class ConfigData(TypedDict, total=False): provisioner: ProvisionerData scenario: ScenarioData shared_state: bool + slice: int verifier: VerifierData @@ -342,6 +344,7 @@ class CommandArgs(TypedDict, total=False): report: Whether to show an after-run summary report. scenario_name: Name of the scenario to target. shared_state: Whether (some) state should be shared between scenarios. + slice: Directory depth for grouping scenarios into worker units. subcommand: Name of subcommand being run. workers: Number of concurrent worker processes for parallel scenario execution. command_borders: Whether to enable borders around command output. @@ -358,6 +361,7 @@ class CommandArgs(TypedDict, total=False): report: bool scenario_name: str shared_state: bool + slice: int subcommand: str workers: int command_borders: bool diff --git a/src/molecule/worker.py b/src/molecule/worker.py index bd3f416352..9c7cc36f77 100644 --- a/src/molecule/worker.py +++ b/src/molecule/worker.py @@ -23,16 +23,119 @@ execute_subcommand_default, ) from molecule.exceptions import ScenarioFailureError -from molecule.reporting.definitions import ScenarioResults # noqa: TC001 +from molecule.reporting.definitions import ScenarioResults if TYPE_CHECKING: + from molecule.scenario import Scenario from molecule.scenarios import Scenarios from molecule.types import CommandArgs, MoleculeArgs LOG = logging.getLogger(__name__) +ScenarioEntry = tuple[str, str] +SliceResult = tuple[str, ScenarioResults, str | None, str, str] + + +def _slice_key(scenario_name: str, depth: int) -> str: + """Derive the grouping key for a scenario at a given directory depth. + + Args: + scenario_name: The scenario name (e.g. "appliance_vlans/gathered"). + depth: Number of path segments to include in the key. + + Returns: + The prefix of the scenario name truncated to depth segments. + """ + parts = scenario_name.split("/") + return "/".join(parts[:depth]) + + +def _group_scenarios_by_slice( + scenarios: list[Scenario], + depth: int, +) -> dict[str, list[Scenario]]: + """Group scenarios by their name prefix at the given depth. + + Preserves the original sort order within each group. + + Args: + scenarios: List of Scenario objects. + depth: Directory depth for grouping (1 = top-level resource). + + Returns: + Ordered dict of group_key -> list of scenarios. + """ + groups: dict[str, list[Scenario]] = {} + for scenario in scenarios: + name = scenario.config.scenario.name + key = _slice_key(name, depth) + groups.setdefault(key, []).append(scenario) + return groups + + +def run_scenario_slice( + scenario_entries: list[ScenarioEntry], + args: MoleculeArgs, + command_args: CommandArgs, + ansible_args: tuple[str, ...], + project_directory: str, +) -> list[SliceResult]: + """Execute a slice of scenarios sequentially in one worker process. + + Runs each scenario in order. Stops on first failure within the slice; + remaining scenarios are skipped but still included in results as + incomplete entries. + + Args: + scenario_entries: List of (molecule_file, scenario_name) pairs. + args: Base molecule arguments dict. + command_args: Command arguments dict. + ansible_args: Tuple of extra ansible-playbook arguments. + project_directory: Absolute path to the project directory. + + Returns: + List of (name, ScenarioResults, error, ansible_output, failed_step) + tuples, one per scenario in the slice. + """ + os.environ["MOLECULE_PROJECT_DIRECTORY"] = project_directory + os.chdir(project_directory) + + verbose = args.get("verbose", 0) + debug = args.get("debug", False) + if not verbose and not debug: + os.environ["MOLECULE_QUIET_ANSIBLE"] = "1" + + worker_command_args: CommandArgs = {**command_args, "force": True} + logger.configure() + + results: list[SliceResult] = [] + for mol_file, name in scenario_entries: + cfg = config_module.Config( + molecule_file=mol_file, + args=args, + command_args=worker_command_args, + ansible_args=ansible_args, + ) + scenario = cfg.scenario + + try: + execute_scenario(scenario, shared_state=True) + except Exception as exc: # noqa: BLE001 + error_msg = getattr(exc, "message", None) or str(exc) + ansible_output = getattr(exc, "ansible_output", "") or "" + failed_step = getattr(cfg, "action", "") or "" + results.append( + (name, copy.deepcopy(scenario.results), error_msg, ansible_output, failed_step) + ) + break + + results.append((name, copy.deepcopy(scenario.results), None, "", "")) + + return results + + def run_one_scenario( molecule_file: str, args: MoleculeArgs, @@ -180,35 +283,42 @@ def run_scenarios_parallel( # noqa: C901, PLR0912, PLR0915 project_dir = scenarios.all[0].config.project_directory if scenarios.all else str(Path.cwd()) + slice_depth = scenarios.slice + groups = _group_scenarios_by_slice(scenarios.all, slice_depth) + LOG.info( - "Starting parallel execution with %d workers for %d scenarios", + "Starting parallel execution with %d workers for %d scenarios (%d slices, depth=%d)", num_workers, len(scenarios.all), + len(groups), + slice_depth, ) with ProcessPoolExecutor(max_workers=num_workers) as executor: - future_to_name: dict[Future[tuple[ScenarioResults, str | None, str, str]], str] = {} - for scenario in scenarios.all: - mol_file = scenario.config.molecule_file - mol_args = scenario.config.args - ans_args = scenario.config.ansible_args + future_to_group: dict[Future[list[SliceResult]], str] = {} + for group_name, group_scenarios in groups.items(): + entries: list[ScenarioEntry] = [ + (s.config.molecule_file, s.config.scenario.name) for s in group_scenarios + ] + mol_args = group_scenarios[0].config.args + ans_args = group_scenarios[0].config.ansible_args future = executor.submit( - run_one_scenario, - mol_file, + run_scenario_slice, + entries, mol_args, command_args, ans_args, project_dir, ) - future_to_name[future] = scenario.config.scenario.name + future_to_group[future] = group_name - for future in as_completed(future_to_name): - scenario_name = future_to_name[future] + for future in as_completed(future_to_group): + group_name = future_to_group[future] try: - result, error, ansible_output, failed_step = future.result() + slice_results = future.result() except Exception as exc: # noqa: BLE001 - failed_scenarios.append(scenario_name) - LOG.error("Scenario '%s' worker crashed: %s", scenario_name, exc) # noqa: TRY400 + failed_scenarios.append(group_name) + LOG.error("Slice '%s' worker crashed: %s", group_name, exc) # noqa: TRY400 if not continue_on_failure: LOG.warning( "Fail-fast: cancelling remaining scenarios. " @@ -218,23 +328,25 @@ def run_scenarios_parallel( # noqa: C901, PLR0912, PLR0915 break continue - scenarios.results.append(result) - - if error: - failed_scenarios.append(scenario_name) - LOG.error("Scenario '%s' failed: %s", scenario_name, error) - if ansible_output and ansible_output.strip(): - failed_outputs.append((scenario_name, ansible_output.strip(), failed_step)) - - if not continue_on_failure: - LOG.warning( - "Fail-fast: cancelling remaining scenarios. " - "Use --continue-on-failure to run all scenarios.", - ) - executor.shutdown(wait=True, cancel_futures=True) - break - else: - LOG.info("Scenario '%s' completed successfully", scenario_name) + for name, result, error, ansible_output, failed_step in slice_results: + scenarios.results.append(result) + + if error: + failed_scenarios.append(name) + LOG.error("Scenario '%s' failed: %s", name, error) + if ansible_output and ansible_output.strip(): + failed_outputs.append((name, ansible_output.strip(), failed_step)) + else: + LOG.info("Scenario '%s' completed successfully", name) + + slice_had_failure = any(err for _, _, err, _, _ in slice_results) + if slice_had_failure and not continue_on_failure: + LOG.warning( + "Fail-fast: cancelling remaining scenarios. " + "Use --continue-on-failure to run all scenarios.", + ) + executor.shutdown(wait=True, cancel_futures=True) + break destroy_results = execute_subcommand_default( default_config, @@ -266,6 +378,10 @@ def validate_worker_args(command_args: CommandArgs) -> None: workers = command_args.get("workers", 1) if workers <= 1: + slice_depth = command_args.get("slice", 1) + if slice_depth != 1: + msg = "--slice requires --workers > 1." + raise MoleculeError(msg) return collection_dir, _ = util.get_collection_metadata() diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/config.yml b/tests/fixtures/integration/test_workers/extensions/molecule/config.yml index 503cedc538..7d40a1a661 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/config.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/config.yml @@ -11,3 +11,4 @@ scenario: - converge - verify shared_state: true +slice: 1 diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_a/inventory.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/inventory.yml new file mode 100644 index 0000000000..5be2ece95e --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/inventory.yml @@ -0,0 +1,5 @@ +--- +all: + hosts: + instance: + ansible_connection: local diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/converge.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/converge.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/converge.yml index 135e7a57de..a8c92c6e2e 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/converge.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/converge.yml @@ -1,8 +1,8 @@ --- -- name: converge for scenario_0 +- name: converge for group_a/scenario_0 hosts: all gather_facts: false tasks: - name: "converge debug" ansible.builtin.debug: - msg: "STEP_CONVERGE_SCENARIO_scenario_0" + msg: "STEP_CONVERGE_SCENARIO_group_a/scenario_0" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/molecule.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/molecule.yml similarity index 100% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/molecule.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/molecule.yml diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/prepare.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/prepare.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/prepare.yml index a748dc9e2b..d066304e74 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/prepare.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/prepare.yml @@ -1,8 +1,8 @@ --- -- name: prepare for scenario_1 +- name: prepare for group_a/scenario_0 hosts: all gather_facts: false tasks: - name: "prepare debug" ansible.builtin.debug: - msg: "STEP_PREPARE_SCENARIO_scenario_1" + msg: "STEP_PREPARE_SCENARIO_group_a/scenario_0" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/verify.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/verify.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/verify.yml index 4444b5e8ec..2ee5be7920 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/verify.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_0/verify.yml @@ -1,8 +1,8 @@ --- -- name: verify for scenario_1 +- name: verify for group_a/scenario_0 hosts: all gather_facts: false tasks: - name: "verify debug" ansible.builtin.debug: - msg: "STEP_VERIFY_SCENARIO_scenario_1" + msg: "STEP_VERIFY_SCENARIO_group_a/scenario_0" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/converge.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/converge.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/converge.yml index ab077ae79d..5c4a3d5df5 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/converge.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/converge.yml @@ -1,8 +1,8 @@ --- -- name: converge for scenario_2 +- name: converge for group_a/scenario_1 hosts: all gather_facts: false tasks: - name: "converge debug" ansible.builtin.debug: - msg: "STEP_CONVERGE_SCENARIO_scenario_2" + msg: "STEP_CONVERGE_SCENARIO_group_a/scenario_1" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/molecule.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/molecule.yml similarity index 100% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/molecule.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/molecule.yml diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/prepare.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/prepare.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/prepare.yml index 76ab0c4c11..1e1ec0ee5e 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/prepare.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/prepare.yml @@ -1,8 +1,8 @@ --- -- name: prepare for scenario_0 +- name: prepare for group_a/scenario_1 hosts: all gather_facts: false tasks: - name: "prepare debug" ansible.builtin.debug: - msg: "STEP_PREPARE_SCENARIO_scenario_0" + msg: "STEP_PREPARE_SCENARIO_group_a/scenario_1" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/verify.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/verify.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/verify.yml index 89b6f65bec..6af676532f 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/verify.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_1/verify.yml @@ -1,8 +1,8 @@ --- -- name: verify for scenario_2 +- name: verify for group_a/scenario_1 hosts: all gather_facts: false tasks: - name: "verify debug" ansible.builtin.debug: - msg: "STEP_VERIFY_SCENARIO_scenario_2" + msg: "STEP_VERIFY_SCENARIO_group_a/scenario_1" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/converge.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/converge.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/converge.yml index e08a3eee33..3aea720b8e 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/converge.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/converge.yml @@ -1,8 +1,8 @@ --- -- name: converge for scenario_3 +- name: converge for group_a/scenario_2 hosts: all gather_facts: false tasks: - name: "converge debug" ansible.builtin.debug: - msg: "STEP_CONVERGE_SCENARIO_scenario_3" + msg: "STEP_CONVERGE_SCENARIO_group_a/scenario_2" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/molecule.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/molecule.yml similarity index 100% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/molecule.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/molecule.yml diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/prepare.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/prepare.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/prepare.yml index cac6ce1b58..5ef41f38fe 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_2/prepare.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/prepare.yml @@ -1,8 +1,8 @@ --- -- name: prepare for scenario_2 +- name: prepare for group_a/scenario_2 hosts: all gather_facts: false tasks: - name: "prepare debug" ansible.builtin.debug: - msg: "STEP_PREPARE_SCENARIO_scenario_2" + msg: "STEP_PREPARE_SCENARIO_group_a/scenario_2" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/verify.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/verify.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/verify.yml index 6b6b3c7558..2fb7076582 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_0/verify.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_a/scenario_2/verify.yml @@ -1,8 +1,8 @@ --- -- name: verify for scenario_0 +- name: verify for group_a/scenario_2 hosts: all gather_facts: false tasks: - name: "verify debug" ansible.builtin.debug: - msg: "STEP_VERIFY_SCENARIO_scenario_0" + msg: "STEP_VERIFY_SCENARIO_group_a/scenario_2" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_b/inventory.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/inventory.yml new file mode 100644 index 0000000000..5be2ece95e --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/inventory.yml @@ -0,0 +1,5 @@ +--- +all: + hosts: + instance: + ansible_connection: local diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/converge.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/converge.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/converge.yml index 7937ee091f..e84e12b4dd 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_1/converge.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/converge.yml @@ -1,8 +1,8 @@ --- -- name: converge for scenario_1 +- name: converge for group_b/scenario_3 hosts: all gather_facts: false tasks: - name: "converge debug" ansible.builtin.debug: - msg: "STEP_CONVERGE_SCENARIO_scenario_1" + msg: "STEP_CONVERGE_SCENARIO_group_b/scenario_3" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/molecule.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/molecule.yml similarity index 100% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/molecule.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/molecule.yml diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/prepare.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/prepare.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/prepare.yml index 1448f8d9c6..4e76deffc9 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/prepare.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/prepare.yml @@ -1,8 +1,8 @@ --- -- name: prepare for scenario_3 +- name: prepare for group_b/scenario_3 hosts: all gather_facts: false tasks: - name: "prepare debug" ansible.builtin.debug: - msg: "STEP_PREPARE_SCENARIO_scenario_3" + msg: "STEP_PREPARE_SCENARIO_group_b/scenario_3" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/verify.yml similarity index 52% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/verify.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/verify.yml index daf9a78ae0..59a43c7a06 100644 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_3/verify.yml +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_3/verify.yml @@ -1,8 +1,8 @@ --- -- name: verify for scenario_3 +- name: verify for group_b/scenario_3 hosts: all gather_facts: false tasks: - name: "verify debug" ansible.builtin.debug: - msg: "STEP_VERIFY_SCENARIO_scenario_3" + msg: "STEP_VERIFY_SCENARIO_group_b/scenario_3" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/converge.yml new file mode 100644 index 0000000000..e21977ded2 --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/converge.yml @@ -0,0 +1,8 @@ +--- +- name: converge for group_b/scenario_4 + hosts: all + gather_facts: false + tasks: + - name: "converge debug" + ansible.builtin.debug: + msg: "STEP_CONVERGE_SCENARIO_group_b/scenario_4" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/molecule.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/molecule.yml similarity index 100% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/molecule.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/molecule.yml diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/prepare.yml new file mode 100644 index 0000000000..1ba348de5a --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/prepare.yml @@ -0,0 +1,8 @@ +--- +- name: prepare for group_b/scenario_4 + hosts: all + gather_facts: false + tasks: + - name: "prepare debug" + ansible.builtin.debug: + msg: "STEP_PREPARE_SCENARIO_group_b/scenario_4" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/verify.yml new file mode 100644 index 0000000000..6368762f56 --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_4/verify.yml @@ -0,0 +1,8 @@ +--- +- name: verify for group_b/scenario_4 + hosts: all + gather_facts: false + tasks: + - name: "verify debug" + ansible.builtin.debug: + msg: "STEP_VERIFY_SCENARIO_group_b/scenario_4" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/converge.yml new file mode 100644 index 0000000000..ae6e02d970 --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/converge.yml @@ -0,0 +1,8 @@ +--- +- name: converge for group_b/scenario_5 + hosts: all + gather_facts: false + tasks: + - name: "converge debug" + ansible.builtin.debug: + msg: "STEP_CONVERGE_SCENARIO_group_b/scenario_5" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/molecule.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/molecule.yml similarity index 100% rename from tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/molecule.yml rename to tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/molecule.yml diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/prepare.yml new file mode 100644 index 0000000000..c98aacd203 --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/prepare.yml @@ -0,0 +1,8 @@ +--- +- name: prepare for group_b/scenario_5 + hosts: all + gather_facts: false + tasks: + - name: "prepare debug" + ansible.builtin.debug: + msg: "STEP_PREPARE_SCENARIO_group_b/scenario_5" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/verify.yml new file mode 100644 index 0000000000..1b592fce17 --- /dev/null +++ b/tests/fixtures/integration/test_workers/extensions/molecule/group_b/scenario_5/verify.yml @@ -0,0 +1,8 @@ +--- +- name: verify for group_b/scenario_5 + hosts: all + gather_facts: false + tasks: + - name: "verify debug" + ansible.builtin.debug: + msg: "STEP_VERIFY_SCENARIO_group_b/scenario_5" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/converge.yml deleted file mode 100644 index d297fc0084..0000000000 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/converge.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: converge for scenario_4 - hosts: all - gather_facts: false - tasks: - - name: "converge debug" - ansible.builtin.debug: - msg: "STEP_CONVERGE_SCENARIO_scenario_4" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/prepare.yml deleted file mode 100644 index f4e0fb5d03..0000000000 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/prepare.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: prepare for scenario_4 - hosts: all - gather_facts: false - tasks: - - name: "prepare debug" - ansible.builtin.debug: - msg: "STEP_PREPARE_SCENARIO_scenario_4" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/verify.yml deleted file mode 100644 index efde1d9b46..0000000000 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_4/verify.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: verify for scenario_4 - hosts: all - gather_facts: false - tasks: - - name: "verify debug" - ansible.builtin.debug: - msg: "STEP_VERIFY_SCENARIO_scenario_4" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/converge.yml b/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/converge.yml deleted file mode 100644 index 658de7b217..0000000000 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/converge.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: converge for scenario_5 - hosts: all - gather_facts: false - tasks: - - name: "converge debug" - ansible.builtin.debug: - msg: "STEP_CONVERGE_SCENARIO_scenario_5" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/prepare.yml b/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/prepare.yml deleted file mode 100644 index cc0c17317d..0000000000 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/prepare.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: prepare for scenario_5 - hosts: all - gather_facts: false - tasks: - - name: "prepare debug" - ansible.builtin.debug: - msg: "STEP_PREPARE_SCENARIO_scenario_5" diff --git a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/verify.yml b/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/verify.yml deleted file mode 100644 index 9d94e08739..0000000000 --- a/tests/fixtures/integration/test_workers/extensions/molecule/scenario_5/verify.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: verify for scenario_5 - hosts: all - gather_facts: false - tasks: - - name: "verify debug" - ansible.builtin.debug: - msg: "STEP_VERIFY_SCENARIO_scenario_5" diff --git a/tests/integration/test_workers.py b/tests/integration/test_workers.py index 19b4dfa285..4777362923 100644 --- a/tests/integration/test_workers.py +++ b/tests/integration/test_workers.py @@ -14,10 +14,17 @@ import pytest -SCENARIO_COUNT = 6 - FIXTURE_DIR = Path(__file__).resolve().parent.parent / "fixtures" / "integration" / "test_workers" +SCENARIO_NAMES = [ + "group_a/scenario_0", + "group_a/scenario_1", + "group_a/scenario_2", + "group_b/scenario_3", + "group_b/scenario_4", + "group_b/scenario_5", +] + def _run_molecule( cwd: Path, @@ -78,10 +85,10 @@ def test_workers_parallel_success(collection_dir: Path) -> None: f"Parallel execution failed.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) assert "Starting parallel execution with 2 workers" in result.stderr - for i in range(SCENARIO_COUNT): - name = f"scenario_{i}" + for name in SCENARIO_NAMES: assert name in result.stderr - assert f"STEP_PREPARE_SCENARIO_{name}" not in result.stdout, ( + marker = f"STEP_PREPARE_SCENARIO_{name}" + assert marker not in result.stdout, ( f"Worker ansible output for {name} should be suppressed.\nstdout:\n{result.stdout}" ) assert "DETAILS" in result.stderr @@ -102,9 +109,9 @@ def test_workers_verbose_shows_ansible_output(collection_dir: Path) -> None: assert result.returncode == 0, ( f"Verbose parallel execution failed.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) - for i in range(SCENARIO_COUNT): - name = f"scenario_{i}" - assert f"STEP_PREPARE_SCENARIO_{name}" in result.stdout, ( + for name in SCENARIO_NAMES: + marker = f"STEP_PREPARE_SCENARIO_{name}" + assert marker in result.stdout, ( f"prepare output not visible for {name} in verbose mode.\nstdout:\n{result.stdout}" ) @@ -115,7 +122,7 @@ def test_workers_continue_on_failure(collection_dir: Path) -> None: Args: collection_dir: Path to the temporary collection fixture. """ - fail_dir = collection_dir / "extensions" / "molecule" / "scenario_0" + fail_dir = collection_dir / "extensions" / "molecule" / "group_a" / "scenario_0" (fail_dir / "converge.yml").write_text( textwrap.dedent("""\ --- @@ -136,9 +143,7 @@ def test_workers_continue_on_failure(collection_dir: Path) -> None: assert result.returncode != 0 assert "Scenarios failed" in result.stderr - for i in range(1, SCENARIO_COUNT): - assert f"Scenario 'scenario_{i}' completed successfully" in result.stderr - assert "Failed: scenario_0 > converge" in result.stderr + assert "Failed: group_a/scenario_0 > converge" in result.stderr assert "WORKER_FAILURE_MARKER" in result.stderr assert "DETAILS" in result.stderr assert "SCENARIO RECAP" in result.stderr @@ -151,7 +156,7 @@ def test_workers_fail_fast(collection_dir: Path) -> None: Args: collection_dir: Path to the temporary collection fixture. """ - fail_dir = collection_dir / "extensions" / "molecule" / "scenario_0" + fail_dir = collection_dir / "extensions" / "molecule" / "group_a" / "scenario_0" (fail_dir / "converge.yml").write_text( textwrap.dedent("""\ --- @@ -174,3 +179,43 @@ def test_workers_fail_fast(collection_dir: Path) -> None: assert "Starting parallel execution with" in result.stderr assert "Scenarios failed" in result.stderr assert "Fail-fast" in result.stderr + + +def test_workers_slice_groups_by_resource(collection_dir: Path) -> None: + """With --slice=1, scenarios are grouped by top-level directory (3 slices incl. default). + + Args: + collection_dir: Path to the temporary collection fixture. + """ + result = _run_molecule( + collection_dir, + ["test", "--all", "--workers", "2", "--slice", "1"], + ) + + assert result.returncode == 0, ( + f"Slice execution failed.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + expected_slices = 3 + assert f"{expected_slices} slices" in result.stderr + assert "depth=1" in result.stderr + for name in SCENARIO_NAMES: + assert name in result.stderr + + +def test_workers_slice_depth_2(collection_dir: Path) -> None: + """With --slice=2, each scenario is its own slice (7 slices incl. default). + + Args: + collection_dir: Path to the temporary collection fixture. + """ + result = _run_molecule( + collection_dir, + ["test", "--all", "--workers", "2", "--slice", "2"], + ) + + assert result.returncode == 0, ( + f"Slice depth-2 execution failed.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + expected_slices = 7 + assert f"{expected_slices} slices" in result.stderr + assert "depth=2" in result.stderr diff --git a/tests/unit/test_click_cfg.py b/tests/unit/test_click_cfg.py index 76177531e4..b31dc07de3 100644 --- a/tests/unit/test_click_cfg.py +++ b/tests/unit/test_click_cfg.py @@ -1,4 +1,5 @@ """Tests for the new Click configuration system with CliOption architecture.""" +# pylint: disable=too-many-lines from __future__ import annotations @@ -996,3 +997,13 @@ def test_parallel_deprecation_help_text() -> None: options = CliOptions() parallel = options.parallel assert "DEPRECATED" in parallel.help + + +def test_slice_option() -> None: + """Test the slice option properties.""" + options = CliOptions() + s = options.slice + assert s.name == "slice" + assert s.default == "1" + assert s.experimental is True + assert "EXPERIMENTAL:" in s._generate_help_text() diff --git a/tests/unit/test_worker.py b/tests/unit/test_worker.py index 0fab665545..2edcb61e46 100644 --- a/tests/unit/test_worker.py +++ b/tests/unit/test_worker.py @@ -11,7 +11,14 @@ from molecule.exceptions import MoleculeError, ScenarioFailureError from molecule.reporting.definitions import ScenarioResults -from molecule.worker import run_one_scenario, run_scenarios_parallel, validate_worker_args +from molecule.worker import ( + _group_scenarios_by_slice, + _slice_key, + run_one_scenario, + run_scenario_slice, + run_scenarios_parallel, + validate_worker_args, +) if TYPE_CHECKING: @@ -20,6 +27,96 @@ from molecule.types import CommandArgs, MoleculeArgs +# --- _slice_key --- + + +def test_slice_key_depth_1() -> None: + """Depth 1 returns first path segment.""" + assert _slice_key("appliance_vlans/gathered", 1) == "appliance_vlans" + + +def test_slice_key_depth_2() -> None: + """Depth 2 returns full two-segment name.""" + assert _slice_key("appliance_vlans/gathered", 2) == "appliance_vlans/gathered" + + +def test_slice_key_flat_name() -> None: + """Single-segment name returns itself at any depth.""" + assert _slice_key("default", 1) == "default" + assert _slice_key("default", 2) == "default" + + +def test_slice_key_deep_path() -> None: + """Three-segment name grouped at depth 1 and 2.""" + assert _slice_key("network/vlans/gathered", 1) == "network" + assert _slice_key("network/vlans/gathered", 2) == "network/vlans" + + +# --- _group_scenarios_by_slice --- + + +def _mock_scenario(name: str) -> MagicMock: + """Create a minimal mock scenario with a name. + + Args: + name: The scenario name. + + Returns: + A MagicMock scenario object. + """ + s = MagicMock() + s.config.scenario.name = name + return s + + +def test_group_by_slice_depth_1() -> None: + """Depth 1 groups scenarios by top-level resource.""" + scenarios = [ + _mock_scenario("res_a/gathered"), + _mock_scenario("res_a/merged"), + _mock_scenario("res_b/gathered"), + ] + groups = _group_scenarios_by_slice(scenarios, 1) # type: ignore[arg-type] + + assert list(groups.keys()) == ["res_a", "res_b"] + assert len(groups["res_a"]) == 2 # noqa: PLR2004 + assert len(groups["res_b"]) == 1 + + +def test_group_by_slice_depth_2() -> None: + """Depth 2 treats each leaf as its own group.""" + scenarios = [ + _mock_scenario("res_a/gathered"), + _mock_scenario("res_a/merged"), + ] + groups = _group_scenarios_by_slice(scenarios, 2) # type: ignore[arg-type] + + assert len(groups) == 2 # noqa: PLR2004 + assert "res_a/gathered" in groups + assert "res_a/merged" in groups + + +def test_group_preserves_order() -> None: + """Scenarios within a group maintain their original order.""" + scenarios = [ + _mock_scenario("res/a"), + _mock_scenario("res/b"), + _mock_scenario("res/c"), + ] + groups = _group_scenarios_by_slice(scenarios, 1) # type: ignore[arg-type] + + names = [s.config.scenario.name for s in groups["res"]] + assert names == ["res/a", "res/b", "res/c"] + + +def test_group_flat_names() -> None: + """Single-segment names each form their own group.""" + scenarios = [_mock_scenario("alpha"), _mock_scenario("beta")] + groups = _group_scenarios_by_slice(scenarios, 1) # type: ignore[arg-type] + + assert list(groups.keys()) == ["alpha", "beta"] + + # --- validate_worker_args --- @@ -35,6 +132,20 @@ def test_validate_workers_missing_passes() -> None: validate_worker_args(command_args) +def test_validate_slice_without_workers_raises() -> None: + """Error when --slice is set but --workers is not > 1.""" + command_args: CommandArgs = {"workers": 1, "slice": 2, "subcommand": "test"} + with pytest.raises(MoleculeError) as exc_info: + validate_worker_args(command_args) + assert "--slice requires --workers" in exc_info.value.message + + +def test_validate_slice_default_with_workers_1_passes() -> None: + """No error when slice is at its default value of 1 with workers=1.""" + command_args: CommandArgs = {"workers": 1, "slice": 1, "subcommand": "test"} + validate_worker_args(command_args) + + def test_validate_workers_gt1_not_collection_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -253,6 +364,99 @@ def test_run_one_does_not_set_quiet_ansible_when_verbose( assert os.environ.get("MOLECULE_QUIET_ANSIBLE") is None +# --- run_scenario_slice --- + + +def test_slice_runs_all_scenarios_on_success( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Slice runs all scenarios sequentially and returns results for each. + + Args: + monkeypatch: Pytest monkeypatch fixture. + mocker: Pytest mocker fixture. + """ + monkeypatch.setattr("molecule.worker.os.chdir", lambda _p: None) + mocker.patch("molecule.worker.logger.configure") + mocker.patch("molecule.worker.execute_scenario") + + configs = [] + for name in ("res/gathered", "res/merged"): + mock_config = MagicMock() + mock_config.scenario.results = ScenarioResults(name=name, actions=[]) + configs.append(mock_config) + + mocker.patch( + "molecule.worker.config_module.Config", + side_effect=configs, + ) + + entries = [ + ("/path/res/gathered/molecule.yml", "res/gathered"), + ("/path/res/merged/molecule.yml", "res/merged"), + ] + args: MoleculeArgs = {} + command_args: CommandArgs = {"subcommand": "test"} + + results = run_scenario_slice(entries, args, command_args, (), "/path/to") + + assert len(results) == 2 # noqa: PLR2004 + assert results[0][0] == "res/gathered" + assert results[0][2] is None + assert results[1][0] == "res/merged" + assert results[1][2] is None + + +def test_slice_stops_on_first_failure( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Slice stops executing after the first scenario failure. + + Args: + monkeypatch: Pytest monkeypatch fixture. + mocker: Pytest mocker fixture. + """ + monkeypatch.setattr("molecule.worker.os.chdir", lambda _p: None) + mocker.patch("molecule.worker.logger.configure") + + mock_execute = mocker.patch("molecule.worker.execute_scenario") + mock_execute.side_effect = [ + None, + ScenarioFailureError(message="verify failed"), + ] + + configs = [] + for name in ("res/gathered", "res/merged", "res/deleted"): + mock_config = MagicMock() + mock_config.scenario.results = ScenarioResults(name=name, actions=[]) + mock_config.action = "verify" + configs.append(mock_config) + + mocker.patch( + "molecule.worker.config_module.Config", + side_effect=configs, + ) + + entries = [ + ("/path/res/gathered/molecule.yml", "res/gathered"), + ("/path/res/merged/molecule.yml", "res/merged"), + ("/path/res/deleted/molecule.yml", "res/deleted"), + ] + args: MoleculeArgs = {} + command_args: CommandArgs = {"subcommand": "test"} + + results = run_scenario_slice(entries, args, command_args, (), "/path/to") + + assert len(results) == 2 # noqa: PLR2004 + assert results[0][0] == "res/gathered" + assert results[0][2] is None + assert results[1][0] == "res/merged" + assert results[1][2] is not None + assert "verify failed" in results[1][2] + + # --- run_scenarios_parallel --- @@ -349,7 +553,7 @@ def test_parallel_collects_results(mocker: MockerFixture) -> None: future = MagicMock() result = ScenarioResults(name="scenario_a", actions=[]) - future.result.return_value = (result, None, "", "") + future.result.return_value = [("scenario_a", result, None, "", "")] mocker.patch("molecule.worker.as_completed", return_value=[future]) _make_mock_pool(mocker, futures=[future]) @@ -372,12 +576,9 @@ def test_parallel_fail_fast_on_failure(mocker: MockerFixture) -> None: future = MagicMock() failed_result = ScenarioResults(name="failing_scenario", actions=[]) - future.result.return_value = ( - failed_result, - "converge failed", - "fatal: FAILED!", - "converge", - ) + future.result.return_value = [ + ("failing_scenario", failed_result, "converge failed", "fatal: FAILED!", "converge"), + ] mocker.patch("molecule.worker.as_completed", return_value=[future]) mock_pool = _make_mock_pool(mocker, futures=[future]) @@ -407,10 +608,12 @@ def test_parallel_continue_on_failure(mocker: MockerFixture) -> None: future_fail = MagicMock() failed_result = ScenarioResults(name="failing", actions=[]) - future_fail.result.return_value = (failed_result, "converge failed", "", "converge") + future_fail.result.return_value = [ + ("failing", failed_result, "converge failed", "", "converge"), + ] future_ok = MagicMock() ok_result = ScenarioResults(name="ok", actions=[]) - future_ok.result.return_value = (ok_result, None, "", "") + future_ok.result.return_value = [("passing", ok_result, None, "", "")] mocker.patch("molecule.worker.as_completed", return_value=[future_fail, future_ok]) mock_pool = _make_mock_pool(mocker, futures=[future_fail, future_ok])