diff --git a/README.md b/README.md index 9a7819e..3d03809 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ Os comandos que alteram estado são `migrate`, `update`, `pipeline` e `resume`. `migrate --replace-existing` cria uma cópia de segurança da cópia de teste existente; em seguida, exige que todas as proteções de caminhos destrutivos sejam aprovadas. `diagnose`, `inventory` e `plan` são somente leitura. Todos os comandos aceitam `--json`; `--dry-run` é uma opção global e -garante que as portas que alteram estado não sejam chamadas. +garante que o alvo não seja alterado. Etapas somente leitura ficam `VALIDATED`, etapas mutáveis sem +simulação segura ficam `PLANNED`, e apenas dry-runs nativos revisados explicitamente podem validar +uma etapa mutável. Execuções reais bem-sucedidas ficam `EXECUTED`. Consulte [operações](docs/operations.md), [configuração](docs/configuration.md), [arquitetura](docs/architecture.md) e [recuperação](docs/recovery.md). diff --git a/docs/operations.md b/docs/operations.md index 5b8cd38..525790c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -11,10 +11,25 @@ - `resume` carrega uma execução, compara a impressão digital da instalação e só prossegue se ela for consistente. -`--dry-run`, no nível global ou do comando, impede que qualquer etapa mutável invoque seu -adaptador. Uma simulação ainda sonda capacidades e grava seu manifesto externo de execução, para -que o trabalho proposto possa ser auditado. Intencionalmente, não existe comando de publicação -em produção. +`--dry-run`, no nível global ou do comando, nunca altera a instalação, seus arquivos ou seu banco. +Cada etapa declara uma capacidade revisada explicitamente: + +- `READ_ONLY`: a leitura é executada e o resultado fica `VALIDATED`; +- `MUTABLE_WITHOUT_SAFE_DRY_RUN`: nenhuma porta operacional é chamada e o resultado fica + `PLANNED`; +- `MUTABLE_WITH_NATIVE_DRY_RUN`: somente a entrada de validação separada e autorizada é chamada, + e o resultado fica `VALIDATED`. + +Uma validação também declara suas capacidades mínimas. Se elas não estiverem disponíveis, o +adapter não é chamado e a etapa permanece `PLANNED`; ausência de infraestrutura nunca transforma +uma tentativa de validação em execução implícita. + +Uma execução real bem-sucedida fica `EXECUTED`. Hoje, `wp search-replace --dry-run` é a única +simulação nativa autorizada; o adapter usa bootstrap reduzido e não marca a operação pendente como +concluída. Ter uma opção chamada `--dry-run` não é suficiente para autorizar outro comando: cada +nova operação precisa ser classificada e adaptada explicitamente. A simulação ainda sonda +capacidades e grava seu manifesto externo de auditoria. Intencionalmente, não existe comando de +publicação em produção. O adaptador público de execução delega cópias à porta de transporte remoto. Um roteador usa SSH/rsync para autenticação por chave e SSH/SFTP (Paramiko) para autenticação por senha. A diff --git a/src/wp_modernizer/application/ports.py b/src/wp_modernizer/application/ports.py index 861b41d..43d15e0 100644 --- a/src/wp_modernizer/application/ports.py +++ b/src/wp_modernizer/application/ports.py @@ -163,3 +163,5 @@ def new(self) -> str: ... class MutableOperations(Protocol): def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: ... + + def validate(self, step_name: str, context: Dict[str, Any]) -> StepResult: ... diff --git a/src/wp_modernizer/cli/main.py b/src/wp_modernizer/cli/main.py index daf8a5d..ca9b9ce 100644 --- a/src/wp_modernizer/cli/main.py +++ b/src/wp_modernizer/cli/main.py @@ -103,7 +103,7 @@ def __init__(self, config_path: Path, dry_run: bool) -> None: @click.option( "--dry-run", is_flag=True, - help="Garante que etapas mutáveis sejam planejadas, mas não executadas.", + help="Não altera o alvo; valida somente leituras e dry-runs nativos autorizados.", ) @click.version_option() @click.pass_context @@ -167,7 +167,7 @@ def _mutable_command(operation: Operation) -> Any: "--dry-run", "command_dry_run", is_flag=True, - help="Planeja sem alterar estado (também disponível globalmente).", + help="Não altera o alvo e valida apenas operações seguras (também disponível globalmente).", ) @click.option( "--replace-existing", diff --git a/src/wp_modernizer/domain/enums.py b/src/wp_modernizer/domain/enums.py index e82fc55..789290d 100644 --- a/src/wp_modernizer/domain/enums.py +++ b/src/wp_modernizer/domain/enums.py @@ -41,10 +41,24 @@ class DatabaseAvailabilityStatus(str, Enum): class StepStatus(str, Enum): PENDING = "PENDING" RUNNING = "RUNNING" - SUCCEEDED = "SUCCEEDED" + EXECUTED = "EXECUTED" + SUCCEEDED = "EXECUTED" FAILED = "FAILED" SKIPPED = "SKIPPED" PLANNED = "PLANNED" + VALIDATED = "VALIDATED" + + @classmethod + def _missing_(cls, value: object) -> "StepStatus | None": + if value == "SUCCEEDED": + return cls.EXECUTED + return None + + +class StepCapability(str, Enum): + READ_ONLY = "READ_ONLY" + MUTABLE_WITHOUT_SAFE_DRY_RUN = "MUTABLE_WITHOUT_SAFE_DRY_RUN" + MUTABLE_WITH_NATIVE_DRY_RUN = "MUTABLE_WITH_NATIVE_DRY_RUN" class ManagedPluginStatus(str, Enum): @@ -56,12 +70,20 @@ class ManagedPluginStatus(str, Enum): class RunStatus(str, Enum): PLANNED = "PLANNED" + VALIDATED = "VALIDATED" RUNNING = "RUNNING" - SUCCEEDED = "SUCCEEDED" + EXECUTED = "EXECUTED" + SUCCEEDED = "EXECUTED" UPDATE_FAILED_PRESERVED = "UPDATE_FAILED_PRESERVED" PAUSED_FOR_MANUAL_REPAIR = "PAUSED_FOR_MANUAL_REPAIR" INCONSISTENT_AFTER_INTERVENTION = "INCONSISTENT_AFTER_INTERVENTION" + @classmethod + def _missing_(cls, value: object) -> "RunStatus | None": + if value == "SUCCEEDED": + return cls.EXECUTED + return None + class Operation(str, Enum): MIGRATE = "migrate" diff --git a/src/wp_modernizer/domain/models.py b/src/wp_modernizer/domain/models.py index 0c43af7..3c92dc7 100644 --- a/src/wp_modernizer/domain/models.py +++ b/src/wp_modernizer/domain/models.py @@ -13,6 +13,7 @@ Operation, PendingOperationType, RunStatus, + StepCapability, StepStatus, ) from .errors import UnsafeOperationError @@ -92,6 +93,20 @@ class PlannedStep: partial_recovery: str installation_id: str excludes: Tuple[Path, ...] = () + capability: Optional[StepCapability] = None + dry_run_requirements: Tuple[Capability, ...] = () + + def __post_init__(self) -> None: + capability = self.capability + if capability is None: + capability = ( + StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN + if self.mutable + else StepCapability.READ_ONLY + ) + object.__setattr__(self, "capability", capability) + if self.mutable is (capability is StepCapability.READ_ONLY): + raise ValueError("mutable e capability descrevem capacidades incompatíveis") @dataclass(frozen=True) diff --git a/src/wp_modernizer/domain/planning.py b/src/wp_modernizer/domain/planning.py index e88c910..b6665eb 100644 --- a/src/wp_modernizer/domain/planning.py +++ b/src/wp_modernizer/domain/planning.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Iterable, Tuple -from .enums import Environment, PendingOperationType +from .enums import Capability, Environment, PendingOperationType, StepCapability from .models import MigrationPlan, PendingOperation, PlannedStep, WordPressInstallation @@ -35,6 +35,7 @@ def build( completion_probe="o manifesto e o resumo de conteúdo da cópia existem", partial_recovery="criar uma nova cópia de segurança imutável", installation_id=node.installation_id, + capability=StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN, ) ) steps.append( @@ -48,17 +49,29 @@ def build( ), installation_id=node.installation_id, excludes=(*descendants, Path("*.sql"), Path(".wp-modernizer")), + capability=StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN, ) ) for name in ("snapshot_source_database", "copy_database", "write_test_db_config"): + capability = ( + StepCapability.READ_ONLY + if name == "snapshot_source_database" + else StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN + ) steps.append( PlannedStep( name, - True, + capability is not StepCapability.READ_ONLY, True, "ponto de controle mais estado inspecionado", "repetir com segurança", node.installation_id, + capability=capability, + dry_run_requirements=( + (Capability.WPCLI_AVAILABLE, Capability.DATABASE_AVAILABLE) + if capability is StepCapability.READ_ONLY + else () + ), ) ) if any( @@ -74,6 +87,11 @@ def build( completion_probe="a URL de origem não permanece no banco de TESTE", partial_recovery="preservar a cópia e repetir o search-replace com WP-CLI", installation_id=installation_id, + capability=StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN, + dry_run_requirements=( + Capability.WPCLI_REDUCED_BOOTSTRAP, + Capability.DATABASE_AVAILABLE, + ), ) ) return MigrationPlan( diff --git a/src/wp_modernizer/infrastructure/runtime_operations.py b/src/wp_modernizer/infrastructure/runtime_operations.py index 2a62978..f21572b 100644 --- a/src/wp_modernizer/infrastructure/runtime_operations.py +++ b/src/wp_modernizer/infrastructure/runtime_operations.py @@ -16,6 +16,7 @@ Environment, ManagedPluginStatus, PendingOperationType, + StepCapability, StepStatus, ) from wp_modernizer.domain.errors import ( @@ -143,14 +144,14 @@ def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: context.get("recovery_data", {}), ) - if step_name in {"preflight", "final_health_check"}: + if step_name == "preflight": return self._ok(step_name, False, "ponto de controle de diagnóstico concluído") if step_name == "snapshot": return self._snapshot_widgets(step_name, installation, path, context, run_id) if step_name == "widget_validation": return self._validate_widgets(step_name, installation, path, context, run_id) if step_name == "pending_search_replace": - return self._search_replace(step_name, path, context, run_id) + return self._search_replace(step_name, path, context, run_id, dry_run=False) if step_name == "managed_plugin_refresh": return self._refresh_managed_plugins( step_name, planned_step.installation_id, installation, path, context, run_id @@ -165,6 +166,28 @@ def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: return StepResult(step_name, StepStatus.SUCCEEDED, True, output) raise UnsafeOperationError(f"Etapa mutável desconhecida: {step_name}") + def validate(self, step_name: str, context: Dict[str, Any]) -> StepResult: + """Execute only native dry-runs whose safety was explicitly reviewed here.""" + planned_step = context.get("planned_step") + if not isinstance(planned_step, PlannedStep): + raise UnsafeOperationError(f"Etapa {step_name} não possui plano de validação") + if planned_step.capability is not StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN: + raise UnsafeOperationError(f"Etapa {step_name} não possui dry-run nativo autorizado") + installation = context.get("installations", {}).get( + planned_step.installation_id, context["installation"] + ) + if installation.destination_environment is not Environment.TEST: + raise UnsafeOperationError("Validações operacionais são proibidas fora de TESTE") + if step_name != "pending_search_replace": + raise UnsafeOperationError(f"Dry-run nativo desconhecido: {step_name}") + return self._search_replace( + step_name, + Path(installation.destination_path), + context, + str(context["run_id"]), + dry_run=True, + ) + def _snapshot_source_database( self, step_name: str, @@ -430,7 +453,13 @@ def _write_test_db_config( return self._ok(step_name, True, "wp-config aponta para o banco do ambiente de teste") def _search_replace( - self, step_name: str, path: Path, context: Dict[str, Any], run_id: str + self, + step_name: str, + path: Path, + context: Dict[str, Any], + run_id: str, + *, + dry_run: bool, ) -> StepResult: plan = context.get("migration_plan") pending = next( @@ -442,6 +471,13 @@ def _search_replace( None, ) if pending is None: + if dry_run: + return StepResult( + step_name, + StepStatus.VALIDATED, + False, + "nenhum search-replace pendente", + ) return self._ok(step_name, False, "nenhum search-replace pendente") explicit_url = pending.parameters.get("test_url") or None try: @@ -459,7 +495,7 @@ def _search_replace( ) multisite = self._wordpress.is_multisite(path, run_id) changed_count = self._wordpress.search_replace( - path, old_url, new_url, dry_run=False, multisite=multisite, run_id=run_id + path, old_url, new_url, dry_run=dry_run, multisite=multisite, run_id=run_id ) except ConfigurationError as exc: return self._failed(step_name, str(exc)) @@ -471,7 +507,7 @@ def _search_replace( return self._failed(step_name, "search-replace falhou; a cópia de TESTE foi preservada") manifest = context.get("manifest") - if manifest is not None: + if manifest is not None and not dry_run: for index, operation in enumerate(manifest.pending_operations): if ( operation.operation_type is pending.operation_type @@ -480,9 +516,17 @@ def _search_replace( ): manifest.pending_operations[index] = replace(operation, completed=True) break + if dry_run: + return StepResult( + step_name, + StepStatus.VALIDATED, + False, + f"search-replace validado pelo dry-run nativo: {changed_count} substituições", + {"potential_replacements": float(changed_count)}, + ) return StepResult( step_name, - StepStatus.SUCCEEDED, + StepStatus.EXECUTED, changed_count > 0, f"search-replace concluído: {changed_count} substituições", {"replacements": float(changed_count)}, diff --git a/src/wp_modernizer/infrastructure/state.py b/src/wp_modernizer/infrastructure/state.py index a086727..7b9def4 100644 --- a/src/wp_modernizer/infrastructure/state.py +++ b/src/wp_modernizer/infrastructure/state.py @@ -6,12 +6,14 @@ from typing import Any, Dict from wp_modernizer.domain.enums import ( + Capability, Environment, HealthStatus, ManagedPluginStatus, Operation, PendingOperationType, RunStatus, + StepCapability, StepStatus, ) from wp_modernizer.domain.models import ( @@ -128,6 +130,10 @@ def _deserialize_planned_step(raw: Dict[str, Any]) -> PlannedStep: partial_recovery=raw["partial_recovery"], installation_id=raw["installation_id"], excludes=tuple(Path(item) for item in raw.get("excludes", [])), + capability=(StepCapability(raw["capability"]) if raw.get("capability") else None), + dry_run_requirements=tuple( + Capability(item) for item in raw.get("dry_run_requirements", []) + ), ) @staticmethod diff --git a/src/wp_modernizer/pipeline/runner.py b/src/wp_modernizer/pipeline/runner.py index 5b46662..38b0da0 100644 --- a/src/wp_modernizer/pipeline/runner.py +++ b/src/wp_modernizer/pipeline/runner.py @@ -5,7 +5,13 @@ from typing import Any, Dict, Iterable from wp_modernizer.application.ports import CapabilityProbePort, Clock, FileSystem, StateStore -from wp_modernizer.domain.enums import Capability, HealthStatus, RunStatus, StepStatus +from wp_modernizer.domain.enums import ( + Capability, + HealthStatus, + RunStatus, + StepCapability, + StepStatus, +) from wp_modernizer.domain.errors import ResumeConsistencyError from wp_modernizer.domain.models import CapabilityReport, RunManifest, StepResult @@ -40,7 +46,22 @@ def run( manifest.status = RunStatus.RUNNING self._state.create_run(manifest) for step in steps: - if manifest.dry_run and step.mutable: + missing_requirements = tuple( + capability for capability in step.dry_run_requirements if not before.has(capability) + ) + if manifest.dry_run and missing_requirements: + missing = ", ".join(item.value for item in missing_requirements) + manifest.steps.append( + StepResult( + step.name, + StepStatus.PLANNED, + False, + f"dry-run: validação indisponível; capacidades ausentes: {missing}", + installation_id=step.installation_id or manifest.installation_id, + ) + ) + continue + if manifest.dry_run and step.capability is StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN: result = StepResult( step.name, StepStatus.PLANNED, @@ -50,12 +71,28 @@ def run( ) manifest.steps.append(result) continue - result = step.execute(context) + if manifest.dry_run and step.capability is StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN: + result = step.validate(context) + if result.changed or result.status is StepStatus.EXECUTED: + raise RuntimeError( + f"Validação nativa {step.name} declarou execução ou mutação em dry-run" + ) + else: + result = step.execute(context) + if manifest.dry_run and result.status is StepStatus.EXECUTED: + if result.changed: + raise RuntimeError( + f"Etapa somente leitura {step.name} declarou mutação em dry-run" + ) + result = replace(result, status=StepStatus.VALIDATED) if result.installation_id is None: result = replace( result, installation_id=step.installation_id or manifest.installation_id ) manifest.steps.append(result) + # This post-step probe is also the final validation when ``step`` is the + # last executable step. Keep it here instead of representing that same + # probe as a separate, no-op health-check step in plans and manifests. after = self._probe.probe(installation_path) manifest.health_after = after.health self._record_diagnostics(manifest, after) @@ -64,9 +101,8 @@ def run( # In particular, the widget reference snapshot must survive an interruption in # any subsequent WordPress update, not only a normally handled pipeline failure. self._state.save_manifest(manifest) - if result.status is not StepStatus.SUCCEEDED or self._regressed( - before.health, after.health - ): + expected_status = StepStatus.VALIDATED if manifest.dry_run else StepStatus.EXECUTED + if result.status is not expected_status or self._regressed(before.health, after.health): manifest.failed_step = step.name manifest.status = RunStatus.UPDATE_FAILED_PRESERVED manifest.finished_at = self._clock.now_iso() @@ -75,7 +111,12 @@ def run( return manifest manifest.last_successful_step = step.name before = after - manifest.status = RunStatus.SUCCEEDED if not manifest.dry_run else RunStatus.PLANNED + if not manifest.dry_run: + manifest.status = RunStatus.EXECUTED + elif all(step.status is StepStatus.VALIDATED for step in manifest.steps): + manifest.status = RunStatus.VALIDATED + else: + manifest.status = RunStatus.PLANNED manifest.finished_at = self._clock.now_iso() manifest.filesystem_fingerprint = self._filesystem.fingerprint(installation_path) self._state.save_manifest(manifest) diff --git a/src/wp_modernizer/pipeline/steps.py b/src/wp_modernizer/pipeline/steps.py index 1cc15f1..5e0d9d7 100644 --- a/src/wp_modernizer/pipeline/steps.py +++ b/src/wp_modernizer/pipeline/steps.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Protocol from wp_modernizer.application.ports import MutableOperations +from wp_modernizer.domain.enums import Capability, StepCapability from wp_modernizer.domain.models import PlannedStep, StepResult @@ -13,6 +14,12 @@ def name(self) -> str: ... @property def mutable(self) -> bool: ... + @property + def capability(self) -> StepCapability: ... + + @property + def dry_run_requirements(self) -> tuple[Capability, ...]: ... + @property def idempotent(self) -> bool: ... @@ -27,6 +34,8 @@ def installation_id(self) -> str: ... def execute(self, context: Dict[str, Any]) -> StepResult: ... + def validate(self, context: Dict[str, Any]) -> StepResult: ... + class OperationStep: """Executable wrapper that keeps the domain plan attached to the operation. @@ -49,6 +58,15 @@ def name(self) -> str: def mutable(self) -> bool: return self.planned_step.mutable + @property + def capability(self) -> StepCapability: + assert self.planned_step.capability is not None + return self.planned_step.capability + + @property + def dry_run_requirements(self) -> tuple[Capability, ...]: + return self.planned_step.dry_run_requirements + @property def idempotent(self) -> bool: return self.planned_step.idempotent @@ -70,15 +88,34 @@ def execute(self, context: Dict[str, Any]) -> StepResult: step_context["planned_step"] = self.planned_step return self.operations.execute(self.name, step_context) + def validate(self, context: Dict[str, Any]) -> StepResult: + step_context = dict(context) + step_context["planned_step"] = self.planned_step + return self.operations.validate(self.name, step_context) + def planned_update_step(name: str, installation_id: str) -> PlannedStep: + capability = { + "preflight": StepCapability.READ_ONLY, + "snapshot": StepCapability.READ_ONLY, + "pending_search_replace": StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN, + }.get(name, StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN) + requirements = { + "snapshot": (Capability.WPCLI_AVAILABLE, Capability.DATABASE_AVAILABLE), + "pending_search_replace": ( + Capability.WPCLI_REDUCED_BOOTSTRAP, + Capability.DATABASE_AVAILABLE, + ), + }.get(name, ()) return PlannedStep( name=name, - mutable=True, + mutable=capability is not StepCapability.READ_ONLY, idempotent=True, completion_probe="ponto de controle e estado do destino inspecionado", partial_recovery="inspecionar e depois repetir ou pausar", installation_id=installation_id, + capability=capability, + dry_run_requirements=requirements, ) @@ -99,5 +136,4 @@ def planned_update_steps(installation_id: str) -> tuple[PlannedStep, ...]: "plugin_languages", "theme_languages", "widget_validation", - "final_health_check", ) diff --git a/tests/fakes/core.py b/tests/fakes/core.py index 05298a3..de4e3a3 100644 --- a/tests/fakes/core.py +++ b/tests/fakes/core.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Sequence -from wp_modernizer.domain.enums import Capability, HealthStatus, StepStatus +from wp_modernizer.domain.enums import Capability, HealthStatus, StepCapability, StepStatus from wp_modernizer.domain.models import CapabilityReport, ProbeResult, RunManifest, StepResult @@ -66,8 +66,10 @@ def health(status: HealthStatus) -> CapabilityReport: class FakeProbe: def __init__(self, reports: List[CapabilityReport]) -> None: self.reports = reports + self.calls: List[Path] = [] def probe(self, installation_path: Path) -> CapabilityReport: + self.calls.append(installation_path) return self.reports.pop(0) if len(self.reports) > 1 else self.reports[0] @@ -105,10 +107,22 @@ class FakeOperations: def __init__(self, fail_at: Optional[str] = None) -> None: self.fail_at = fail_at self.calls: List[str] = [] + self.validation_calls: List[str] = [] self.contexts: List[Dict[str, Any]] = [] def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: self.calls.append(step_name) self.contexts.append(context) status = StepStatus.FAILED if step_name == self.fail_at else StepStatus.SUCCEEDED - return StepResult(step_name, status, status is StepStatus.SUCCEEDED, step_name) + planned = context.get("planned_step") + changed = ( + status is StepStatus.SUCCEEDED + and getattr(planned, "capability", None) is not StepCapability.READ_ONLY + ) + return StepResult(step_name, status, changed, step_name) + + def validate(self, step_name: str, context: Dict[str, Any]) -> StepResult: + self.validation_calls.append(step_name) + self.contexts.append(context) + status = StepStatus.FAILED if step_name == self.fail_at else StepStatus.VALIDATED + return StepResult(step_name, status, False, step_name) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 7d56a51..8f37a2c 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -61,7 +61,8 @@ def test_command_level_dry_run_executes_no_mutating_adapter(tmp_path: Path) -> N assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["status"] == "PLANNED" - assert all(step["status"] == "PLANNED" for step in payload["steps"]) + assert all(step["status"] in {"PLANNED", "VALIDATED"} for step in payload["steps"]) + assert "EXECUTED" not in {step["status"] for step in payload["steps"]} def test_unknown_installation_is_operational_error(tmp_path: Path) -> None: diff --git a/tests/unit/test_pending_search_replace.py b/tests/unit/test_pending_search_replace.py index e1c512a..3355dbf 100644 --- a/tests/unit/test_pending_search_replace.py +++ b/tests/unit/test_pending_search_replace.py @@ -11,6 +11,7 @@ Operation, PendingOperationType, RunStatus, + StepCapability, StepStatus, ) from wp_modernizer.domain.errors import UnsafeOperationError, WordPressUnavailableError @@ -208,3 +209,26 @@ def test_success_marks_pending_operation_complete() -> None: ] operation(wordpress).execute("pending_search_replace", context) assert manifest.pending_operations[0].completed is True + + +def test_native_dry_run_validates_without_completing_pending_operation() -> None: + manifest = RunManifest("run-1", "site", Operation.PIPELINE, RunStatus.RUNNING, "now", True) + wordpress = RecordingWordPress(replacements=7) + context = execution_context(manifest=manifest) + context["planned_step"] = PlannedStep( + "pending_search_replace", + True, + True, + "", + "", + "site", + capability=StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN, + ) + + result = operation(wordpress).validate("pending_search_replace", context) + + assert result.status is StepStatus.VALIDATED + assert result.changed is False + assert result.metrics == {"potential_replacements": 7.0} + assert wordpress.search_calls[0]["dry_run"] is True + assert manifest.pending_operations[0].completed is False diff --git a/tests/unit/test_pipeline.py b/tests/unit/test_pipeline.py index 18d8237..415cbc7 100644 --- a/tests/unit/test_pipeline.py +++ b/tests/unit/test_pipeline.py @@ -11,9 +11,15 @@ FakeStateStore, health, ) -from wp_modernizer.domain.enums import HealthStatus, Operation, RunStatus +from wp_modernizer.domain.enums import ( + HealthStatus, + Operation, + RunStatus, + StepCapability, + StepStatus, +) from wp_modernizer.domain.errors import ResumeConsistencyError -from wp_modernizer.domain.models import RunManifest +from wp_modernizer.domain.models import PlannedStep, RunManifest, StepResult from wp_modernizer.domain.widgets import WidgetOption, WidgetSnapshot from wp_modernizer.pipeline.runner import PipelineRunner from wp_modernizer.pipeline.steps import OperationStep @@ -26,9 +32,8 @@ def manifest(dry_run=False): def test_successful_pipeline_checkpoints_every_step() -> None: state = FakeStateStore() operations = FakeOperations() - runner = PipelineRunner( - FakeProbe([health(HealthStatus.HEALTHY)]), state, FakeFileSystem(), FakeClock() - ) + probe = FakeProbe([health(HealthStatus.HEALTHY)]) + runner = PipelineRunner(probe, state, FakeFileSystem(), FakeClock()) result = runner.run( manifest(), Path("/site"), @@ -37,7 +42,11 @@ def test_successful_pipeline_checkpoints_every_step() -> None: ) assert result.status is RunStatus.SUCCEEDED assert result.last_successful_step == "two" + assert result.health_after is HealthStatus.HEALTHY assert state.checkpoints == ["one", "two"] + # One initial probe plus one post-step probe. The latter probe is the final + # validation; there is no synthetic final-health-check step. + assert probe.calls == [Path("/site"), Path("/site"), Path("/site")] def test_step_recovery_state_is_persisted_before_the_next_mutation() -> None: @@ -111,6 +120,77 @@ def test_dry_run_never_calls_mutable_adapter() -> None: assert result.steps[0].message.startswith("dry-run") +def test_dry_run_executes_read_only_step_as_validation() -> None: + operations = FakeOperations() + planned = PlannedStep( + "inspect", + False, + True, + "", + "", + "site", + capability=StepCapability.READ_ONLY, + ) + result = PipelineRunner( + FakeProbe([health(HealthStatus.HEALTHY)]), + FakeStateStore(), + FakeFileSystem(), + FakeClock(), + ).run(manifest(True), Path("/site"), [OperationStep(planned, operations)], {}) + + assert operations.calls == ["inspect"] + assert result.steps[0].status is StepStatus.VALIDATED + assert result.status is RunStatus.VALIDATED + + +def test_dry_run_uses_separate_native_validation_entrypoint() -> None: + operations = FakeOperations() + planned = PlannedStep( + "native", + True, + True, + "", + "", + "site", + capability=StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN, + ) + result = PipelineRunner( + FakeProbe([health(HealthStatus.HEALTHY)]), + FakeStateStore(), + FakeFileSystem(), + FakeClock(), + ).run(manifest(True), Path("/site"), [OperationStep(planned, operations)], {}) + + assert operations.calls == [] + assert operations.validation_calls == ["native"] + assert result.steps[0].status is StepStatus.VALIDATED + + +def test_dry_run_rejects_native_validation_that_claims_mutation() -> None: + class UnsafeValidation(FakeOperations): + def validate(self, step_name, context): + return StepResult(step_name, StepStatus.VALIDATED, True, "unsafe") + + operations = UnsafeValidation() + planned = PlannedStep( + "native", + True, + True, + "", + "", + "site", + capability=StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN, + ) + + with pytest.raises(RuntimeError, match="mutação em dry-run"): + PipelineRunner( + FakeProbe([health(HealthStatus.HEALTHY)]), + FakeStateStore(), + FakeFileSystem(), + FakeClock(), + ).run(manifest(True), Path("/site"), [OperationStep(planned, operations)], {}) + + def test_resume_detects_manual_intervention() -> None: filesystem = FakeFileSystem(fingerprint="changed") runner = PipelineRunner( diff --git a/tests/unit/test_planning.py b/tests/unit/test_planning.py index 7eb3153..00a710c 100644 --- a/tests/unit/test_planning.py +++ b/tests/unit/test_planning.py @@ -1,6 +1,6 @@ from pathlib import Path -from wp_modernizer.domain.enums import Environment, PendingOperationType +from wp_modernizer.domain.enums import Environment, PendingOperationType, StepCapability from wp_modernizer.domain.models import PendingOperation from wp_modernizer.domain.path_parser import InstallationPathParser from wp_modernizer.domain.planning import MigrationPlanner @@ -54,3 +54,7 @@ def test_pending_search_replace_runs_after_test_database_is_prepared() -> None: ) names = [step.name for step in plan.steps] assert names[-2:] == ["write_test_db_config", "pending_search_replace"] + capabilities = {step.name: step.capability for step in plan.steps} + assert capabilities["snapshot_source_database"] is StepCapability.READ_ONLY + assert capabilities["copy_database"] is StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN + assert capabilities["pending_search_replace"] is StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 1d309a5..8588ec6 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -89,15 +89,16 @@ def test_pipeline_does_not_execute_pending_search_replace_twice() -> None: assert [step.name for step in plan.planned_steps].count("pending_search_replace") == 1 -def test_pipeline_dry_run_calls_no_operations() -> None: +def test_pipeline_dry_run_calls_only_read_and_native_validation_operations() -> None: operations = FakeOperations() result = service(operations).execute(Operation.PIPELINE, "parent", dry_run=True) assert result.status is RunStatus.PLANNED - assert operations.calls == [] + assert operations.calls == ["preflight"] + assert operations.validation_calls == [] assert len(result.steps) > 10 -def test_update_dry_run_records_managed_plugin_plan_without_calling_operations() -> None: +def test_update_dry_run_records_managed_plugin_plan_without_mutating_operations() -> None: operations = FakeOperations() app = service(operations) app.config.managed_plugins = [ @@ -112,7 +113,8 @@ def test_update_dry_run_records_managed_plugin_plan_without_calling_operations() result = app.execute(Operation.UPDATE, "parent", dry_run=True) - assert operations.calls == [] + assert operations.calls == ["preflight"] + assert operations.validation_calls == [] assert result.managed_plugins[0].branch == "stable" assert result.managed_plugin_results[0].status is ManagedPluginStatus.PLANNED assert result.managed_plugin_results[0].dirty_policy == "skip" @@ -123,7 +125,10 @@ def test_update_executes_declared_pipeline() -> None: result = service(operations).execute(Operation.UPDATE, "parent", dry_run=False) assert result.status is RunStatus.SUCCEEDED assert operations.calls[0] == "preflight" - assert operations.calls[-1] == "final_health_check" + assert operations.calls[-1] == "widget_validation" + assert "final_health_check" not in operations.calls + assert "final_health_check" not in [step.name for step in result.planned_steps] + assert "final_health_check" not in [step.name for step in result.steps] snapshot_index = operations.calls.index("snapshot") for update_step in ( "core_update",