From 8678a34fcf55f39845cdab70f3ac01e9b11c7efb Mon Sep 17 00:00:00 2001 From: apendindan Date: Sun, 30 Aug 2026 20:48:43 -0300 Subject: [PATCH 1/6] fix(resume): restore original operation plan when resuming --- src/wp_modernizer/application/service.py | 55 +++++++++++---- src/wp_modernizer/domain/models.py | 3 + .../infrastructure/runtime_operations.py | 13 ++-- src/wp_modernizer/infrastructure/state.py | 62 +++++++++++++++++ src/wp_modernizer/pipeline/runner.py | 13 +++- src/wp_modernizer/pipeline/steps.py | 67 ++++++++++++++++--- tests/fakes/core.py | 2 + tests/unit/test_service.py | 42 ++++++++++++ tests/unit/test_state.py | 40 ++++++++++- 9 files changed, 268 insertions(+), 29 deletions(-) diff --git a/src/wp_modernizer/application/service.py b/src/wp_modernizer/application/service.py index cc251fe..7000205 100644 --- a/src/wp_modernizer/application/service.py +++ b/src/wp_modernizer/application/service.py @@ -15,11 +15,11 @@ from wp_modernizer.config.models import ApplicationConfig from wp_modernizer.domain.enums import Environment, Operation, PendingOperationType, RunStatus from wp_modernizer.domain.errors import ConfigurationError, UnsafeOperationError -from wp_modernizer.domain.models import PendingOperation, RunManifest +from wp_modernizer.domain.models import MigrationPlan, PendingOperation, RunManifest from wp_modernizer.domain.path_parser import InstallationPathParser from wp_modernizer.domain.planning import MigrationPlanner from wp_modernizer.pipeline.runner import PipelineRunner -from wp_modernizer.pipeline.steps import UPDATE_STEP_NAMES, OperationStep +from wp_modernizer.pipeline.steps import OperationStep, planned_update_steps class ModernizerService: @@ -85,6 +85,11 @@ def inventory(self, installation_id: str) -> Dict[str, Any]: return report def plan(self, installation_id: str) -> Dict[str, Any]: + return cast( + Dict[str, Any], self._serializable(asdict(self._migration_plan(installation_id))) + ) + + def _migration_plan(self, installation_id: str) -> MigrationPlan: item = self._installation(installation_id) installations = [] for key, candidate in self.config.installations.items(): @@ -104,7 +109,7 @@ def plan(self, installation_id: str) -> Dict[str, Any]: "executa somente após uma simulação bem-sucedida com WP-CLI reduzido", ), ) - plan = MigrationPlanner().build( + return MigrationPlanner().build( installation_id, item.source_environment, item.source_server, @@ -112,7 +117,6 @@ def plan(self, installation_id: str) -> Dict[str, Any]: installations, pending, ) - return cast(Dict[str, Any], self._serializable(asdict(plan))) def execute( self, @@ -125,18 +129,27 @@ def execute( ) -> RunManifest: item = self._installation(installation_id) path = item.destination_path - migration_names = tuple(step["name"] for step in self.plan(installation_id)["steps"]) + migration_plan = self._migration_plan(installation_id) + update_steps = planned_update_steps(installation_id) if operation is Operation.MIGRATE: - names = migration_names + planned_steps = migration_plan.steps elif operation is Operation.UPDATE: - names = UPDATE_STEP_NAMES + planned_steps = update_steps elif operation is Operation.PIPELINE: - names = migration_names + UPDATE_STEP_NAMES + planned_steps = migration_plan.steps + update_steps else: raise ConfigurationError(f"Operação de execução não suportada: {operation.value}") run_id = self._ids.new() manifest = RunManifest( - run_id, installation_id, operation, RunStatus.RUNNING, self._clock.now_iso(), dry_run + run_id, + installation_id, + operation, + RunStatus.RUNNING, + self._clock.now_iso(), + dry_run, + pending_operations=list(migration_plan.pending_operations), + planned_steps=list(planned_steps), + migration_plan=migration_plan, ) context = { "run_id": run_id, @@ -144,16 +157,27 @@ def execute( "installation": item, "replace_existing": replace_existing, "restore_widgets": restore_widgets, + "installations": self.config.installations, + "migration_plan": migration_plan, } - steps = tuple(OperationStep(name, self._operations) for name in names) + steps = tuple(OperationStep(step, self._operations) for step in planned_steps) return self._runner.run(manifest, path, steps, context) def resume(self, installation_id: str, run_id: str, dry_run: bool) -> RunManifest: old = self._state.load_manifest(installation_id, run_id) path = self._installation(installation_id).destination_path self._runner.assert_resume_consistent(old, path) - completed = {step.name for step in old.steps if step.status.value == "SUCCEEDED"} - remaining = [name for name in UPDATE_STEP_NAMES if name not in completed] + completed = { + (step.installation_id or installation_id, step.name) + for step in old.steps + if step.status.value == "SUCCEEDED" + } + original_steps = old.planned_steps or list(planned_update_steps(installation_id)) + remaining = [ + step + for step in original_steps + if (step.installation_id or installation_id, step.name) not in completed + ] new = RunManifest( self._ids.new(), installation_id, @@ -161,15 +185,20 @@ def resume(self, installation_id: str, run_id: str, dry_run: bool) -> RunManifes RunStatus.RUNNING, self._clock.now_iso(), dry_run, + pending_operations=list(old.pending_operations), + planned_steps=remaining, + migration_plan=old.migration_plan, ) return self._runner.run( new, path, - [OperationStep(name, self._operations) for name in remaining], + [OperationStep(step, self._operations) for step in remaining], { "run_id": new.run_id, "installation_id": installation_id, "installation": self._installation(installation_id), + "installations": self.config.installations, + "migration_plan": old.migration_plan, "resumed_from": run_id, }, ) diff --git a/src/wp_modernizer/domain/models.py b/src/wp_modernizer/domain/models.py index 7827f8c..399b3f0 100644 --- a/src/wp_modernizer/domain/models.py +++ b/src/wp_modernizer/domain/models.py @@ -104,6 +104,7 @@ class StepResult: changed: bool message: str metrics: Dict[str, float] = field(default_factory=dict) + installation_id: Optional[str] = None @dataclass @@ -126,3 +127,5 @@ class RunManifest: widget_diff: List[Dict[str, str]] = field(default_factory=list) filesystem_fingerprint: Optional[str] = None finished_at: Optional[str] = None + planned_steps: List[PlannedStep] = field(default_factory=list) + migration_plan: Optional[MigrationPlan] = None diff --git a/src/wp_modernizer/infrastructure/runtime_operations.py b/src/wp_modernizer/infrastructure/runtime_operations.py index 75930c4..cb5bcb3 100644 --- a/src/wp_modernizer/infrastructure/runtime_operations.py +++ b/src/wp_modernizer/infrastructure/runtime_operations.py @@ -6,7 +6,7 @@ from wp_modernizer.application.ports import CommandRunner from wp_modernizer.domain.enums import StepStatus from wp_modernizer.domain.errors import UnsafeOperationError -from wp_modernizer.domain.models import StepResult +from wp_modernizer.domain.models import PlannedStep, StepResult from wp_modernizer.domain.path_parser import InstallationPathParser @@ -28,7 +28,11 @@ def __init__(self, runner: CommandRunner, parser: InstallationPathParser) -> Non self._parser = parser def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: - installation = context["installation"] + planned_step = context.get("planned_step") + if not isinstance(planned_step, PlannedStep): + raise UnsafeOperationError(f"Etapa {step_name} não possui plano de execução") + installations = context.get("installations", {}) + installation = installations.get(planned_step.installation_id, context["installation"]) path = Path(installation.destination_path) if step_name == "backup_existing_test": if not path.exists(): @@ -53,12 +57,13 @@ def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: "o teste existente foi preservado", ) if step_name == "copy_files": + excluded = ", ".join(str(path) for path in planned_step.excludes) return StepResult( step_name, StepStatus.FAILED, False, - "o adaptador de origem SSH/rsync deve ser configurado explicitamente; " - "nenhum arquivo foi alterado", + "o adaptador de origem SSH/rsync deve ser configurado explicitamente com " + f"exclusions [{excluded}]; nenhum arquivo foi alterado", ) if step_name in {"snapshot_source_database", "copy_database", "write_test_db_config"}: return StepResult( diff --git a/src/wp_modernizer/infrastructure/state.py b/src/wp_modernizer/infrastructure/state.py index af85c31..0f8d361 100644 --- a/src/wp_modernizer/infrastructure/state.py +++ b/src/wp_modernizer/infrastructure/state.py @@ -6,6 +6,7 @@ from typing import Any, Dict from wp_modernizer.domain.enums import ( + Environment, HealthStatus, Operation, PendingOperationType, @@ -14,9 +15,12 @@ ) from wp_modernizer.domain.models import ( CapabilityReport, + MigrationPlan, PendingOperation, + PlannedStep, RunManifest, StepResult, + WordPressInstallation, ) @@ -51,6 +55,7 @@ def load_manifest(self, installation_id: str, run_id: str) -> RunManifest: item["changed"], item["message"], item.get("metrics", {}), + item.get("installation_id"), ) for item in raw.get("steps", []) ], @@ -73,6 +78,63 @@ def load_manifest(self, installation_id: str, run_id: str) -> RunManifest: widget_diff=raw.get("widget_diff", []), filesystem_fingerprint=raw.get("filesystem_fingerprint"), finished_at=raw.get("finished_at"), + planned_steps=[ + self._deserialize_planned_step(item) for item in raw.get("planned_steps", []) + ], + migration_plan=self._deserialize_migration_plan(raw.get("migration_plan")), + ) + + @staticmethod + def _deserialize_planned_step(raw: Dict[str, Any]) -> PlannedStep: + return PlannedStep( + name=raw["name"], + mutable=raw["mutable"], + idempotent=raw["idempotent"], + completion_probe=raw["completion_probe"], + partial_recovery=raw["partial_recovery"], + installation_id=raw["installation_id"], + excludes=tuple(Path(item) for item in raw.get("excludes", [])), + ) + + @classmethod + def _deserialize_migration_plan(cls, raw: Any) -> MigrationPlan | None: + if raw is None: + return None + installations = tuple( + WordPressInstallation( + installation_id=item["installation_id"], + path=Path(item["path"]), + app_root=Path(item["app_root"]), + domain=item["domain"], + instance_name=item["instance_name"], + document_root=Path(item["document_root"]), + environment=Environment(item["environment"]), + relative_nested_path=Path(item["relative_nested_path"]) + if item.get("relative_nested_path") + else None, + parent_installation=item.get("parent_installation"), + children=tuple(item.get("children", [])), + ) + for item in raw.get("installations", []) + ) + pending = tuple( + PendingOperation( + PendingOperationType(item["operation_type"]), + item["parameters"], + item["reason"], + item.get("completed", False), + ) + for item in raw.get("pending_operations", []) + ) + return MigrationPlan( + installation_id=raw["installation_id"], + source_environment=Environment(raw["source_environment"]), + destination_environment=Environment(raw["destination_environment"]), + source_server=raw["source_server"], + database_endpoint=raw.get("database_endpoint"), + installations=installations, + steps=tuple(cls._deserialize_planned_step(item) for item in raw.get("steps", [])), + pending_operations=pending, ) def save_checkpoint( diff --git a/src/wp_modernizer/pipeline/runner.py b/src/wp_modernizer/pipeline/runner.py index 5fa11e8..3b77244 100644 --- a/src/wp_modernizer/pipeline/runner.py +++ b/src/wp_modernizer/pipeline/runner.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from typing import Any, Dict, Iterable @@ -40,10 +41,20 @@ def run( self._state.create_run(manifest) for step in steps: if manifest.dry_run and step.mutable: - result = StepResult(step.name, StepStatus.PLANNED, False, "dry-run: sem alteração") + result = StepResult( + step.name, + StepStatus.PLANNED, + False, + "dry-run: sem alteração", + installation_id=step.installation_id or manifest.installation_id, + ) manifest.steps.append(result) continue result = step.execute(context) + if result.installation_id is None: + result = replace( + result, installation_id=step.installation_id or manifest.installation_id + ) manifest.steps.append(result) after = self._probe.probe(installation_path) manifest.health_after = after.health diff --git a/src/wp_modernizer/pipeline/steps.py b/src/wp_modernizer/pipeline/steps.py index f3b685f..3626d58 100644 --- a/src/wp_modernizer/pipeline/steps.py +++ b/src/wp_modernizer/pipeline/steps.py @@ -1,10 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any, Dict, Protocol from wp_modernizer.application.ports import MutableOperations -from wp_modernizer.domain.models import StepResult +from wp_modernizer.domain.models import PlannedStep, StepResult class Step(Protocol): @@ -23,20 +22,68 @@ def completion_probe(self) -> str: ... @property def partial_recovery(self) -> str: ... + @property + def installation_id(self) -> str: ... + def execute(self, context: Dict[str, Any]) -> StepResult: ... -@dataclass(frozen=True) class OperationStep: - name: str - operations: MutableOperations - mutable: bool = True - idempotent: bool = True - completion_probe: str = "ponto de controle e estado do destino inspecionado" - partial_recovery: str = "inspecionar e depois repetir ou pausar" + """Executable wrapper that keeps the domain plan attached to the operation. + + Accepting a name remains supported for small adapters and older tests, but service code + always supplies a complete PlannedStep. + """ + + def __init__(self, planned_step: PlannedStep | str, operations: MutableOperations) -> None: + if isinstance(planned_step, str): + planned_step = planned_update_step(planned_step, "") + self.planned_step = planned_step + self.operations = operations + + @property + def name(self) -> str: + return self.planned_step.name + + @property + def mutable(self) -> bool: + return self.planned_step.mutable + + @property + def idempotent(self) -> bool: + return self.planned_step.idempotent + + @property + def completion_probe(self) -> str: + return self.planned_step.completion_probe + + @property + def partial_recovery(self) -> str: + return self.planned_step.partial_recovery + + @property + def installation_id(self) -> str: + return self.planned_step.installation_id def execute(self, context: Dict[str, Any]) -> StepResult: - return self.operations.execute(self.name, context) + step_context = dict(context) + step_context["planned_step"] = self.planned_step + return self.operations.execute(self.name, step_context) + + +def planned_update_step(name: str, installation_id: str) -> PlannedStep: + return PlannedStep( + name=name, + mutable=True, + idempotent=True, + completion_probe="ponto de controle e estado do destino inspecionado", + partial_recovery="inspecionar e depois repetir ou pausar", + installation_id=installation_id, + ) + + +def planned_update_steps(installation_id: str) -> tuple[PlannedStep, ...]: + return tuple(planned_update_step(name, installation_id) for name in UPDATE_STEP_NAMES) UPDATE_STEP_NAMES = ( diff --git a/tests/fakes/core.py b/tests/fakes/core.py index ca7fbf0..fa386fa 100644 --- a/tests/fakes/core.py +++ b/tests/fakes/core.py @@ -93,8 +93,10 @@ class FakeOperations: def __init__(self, fail_at: Optional[str] = None) -> None: self.fail_at = fail_at self.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) diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 0c25540..96fedb6 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -10,6 +10,7 @@ from wp_modernizer.application.service import ModernizerService from wp_modernizer.config.models import ApplicationConfig from wp_modernizer.domain.enums import HealthStatus, Operation, RunStatus +from wp_modernizer.domain.models import PlannedStep def config() -> ApplicationConfig: @@ -95,6 +96,47 @@ def test_update_executes_declared_pipeline() -> None: assert operations.calls[-1] == "final_health_check" +def test_nested_wordpress_exclusion_reaches_executor_exactly_as_planned() -> None: + operations = FakeOperations() + app = service(operations) + public_plan = app.plan("parent") + expected = next( + step["excludes"] + for step in public_plan["steps"] + if step["installation_id"] == "parent" and step["name"] == "copy_files" + ) + + result = app.execute(Operation.MIGRATE, "parent", dry_run=False) + + copy_context = next( + context + for name, context in zip(operations.calls, operations.contexts, strict=True) + if name == "copy_files" and context["planned_step"].installation_id == "parent" + ) + received = copy_context["planned_step"] + assert isinstance(received, PlannedStep) + assert [str(item) for item in received.excludes] == expected + assert "/home/apps/example.org/wp-test/htdocs/child" in expected + assert result.planned_steps == list(result.migration_plan.steps) # type: ignore[union-attr] + + +def test_different_steps_keep_their_own_parameters_and_metadata() -> None: + operations = FakeOperations() + service(operations).execute(Operation.MIGRATE, "parent", dry_run=False) + planned = [context["planned_step"] for context in operations.contexts] + parent_backup = next( + step + for step in planned + if step.installation_id == "parent" and step.name == "backup_existing_test" + ) + parent_copy = next( + step for step in planned if step.installation_id == "parent" and step.name == "copy_files" + ) + assert parent_backup.excludes == () + assert parent_copy.excludes + assert parent_copy.partial_recovery != parent_backup.partial_recovery + + def test_resume_skips_successful_steps() -> None: state = FakeStateStore() app = service(state=state) diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 6855a4b..ee07b43 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -1,8 +1,10 @@ from pathlib import Path from tests.fakes.core import health -from wp_modernizer.domain.enums import HealthStatus, Operation, RunStatus, StepStatus +from wp_modernizer.domain.enums import Environment, HealthStatus, Operation, RunStatus, StepStatus from wp_modernizer.domain.models import RunManifest, StepResult +from wp_modernizer.domain.path_parser import InstallationPathParser +from wp_modernizer.domain.planning import MigrationPlanner from wp_modernizer.infrastructure.state import JsonStateStore @@ -17,3 +19,39 @@ def test_state_round_trip_and_layout(tmp_path: Path) -> None: assert loaded.operation is Operation.PIPELINE assert loaded.steps[0].status is StepStatus.SUCCEEDED assert (tmp_path / "site" / "runs" / "run-id" / "checkpoints").is_dir() + + +def test_migration_plan_round_trip_preserves_step_metadata(tmp_path: Path) -> None: + parser = InstallationPathParser([Path("/home/apps")]) + parent = parser.parse("/home/apps/example.org/wp-test/htdocs", "parent", Environment.TEST) + child = parser.parse("/home/apps/example.org/wp-test/htdocs/child", "child", Environment.TEST) + plan = MigrationPlanner().build( + "parent", Environment.PRODUCTION, "source", "database", [parent, child] + ) + manifest = RunManifest( + "run-id", + "parent", + Operation.MIGRATE, + RunStatus.RUNNING, + "now", + False, + planned_steps=list(plan.steps), + migration_plan=plan, + ) + store = JsonStateStore(tmp_path) + + store.create_run(manifest) + loaded = store.load_manifest("parent", "run-id") + + assert loaded.migration_plan == plan + assert loaded.planned_steps == list(plan.steps) + parent_copy = next( + step + for step in loaded.planned_steps + if step.installation_id == "parent" and step.name == "copy_files" + ) + assert parent_copy.excludes == ( + child.path, + Path("*.sql"), + Path(".wp-modernizer"), + ) From 812c08e0491df6966bb72a5f225be6cf7bb441d5 Mon Sep 17 00:00:00 2001 From: apendindan Date: Sun, 30 Aug 2026 20:58:39 -0300 Subject: [PATCH 2/6] feat(runtime): wire infrastructure adapters into execution --- docs/architecture.md | 4 + docs/operations.md | 9 +- src/wp_modernizer/application/ports.py | 40 ++++ src/wp_modernizer/cli/main.py | 46 +++- .../infrastructure/mysql/adapter.py | 168 ++++++++------ .../infrastructure/runtime_operations.py | 207 ++++++++++++++---- .../infrastructure/ssh/adapter.py | 61 ++++-- .../infrastructure/wpcli/adapter.py | 47 +++- tests/unit/test_adapters.py | 25 ++- tests/unit/test_composition_root.py | 102 +++++++++ 10 files changed, 575 insertions(+), 134 deletions(-) create mode 100644 tests/unit/test_composition_root.py diff --git a/docs/architecture.md b/docs/architecture.md index 8fcd25d..24b6a1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,6 +7,10 @@ importar APIs de processos externos. `application` contém os casos de uso e as `infrastructure` fornece adaptadores de subprocessos, estado local, YAML/ambiente, MySQL, SSH/rsync, WP-CLI, sistema de arquivos e Git. `cli` trata apenas da composição. +A composition root em `cli.main.build_service` liga a configuração ao +`EnvironmentSecretProvider`, cria os adaptadores SSH/MySQL/WP-CLI, injeta-os em +`RuntimeOperations` pelas portas da aplicação e, por fim, constrói `ModernizerService`. + As dependências apontam para dentro. Objetos falsos implementam os mesmos `Protocol`s e permitem testar todas as regras de segurança sem WordPress. Dataclasses modelam valores estáveis do domínio; Pydantic valida configurações não confiáveis na fronteira. Isso evita acoplar o domínio diff --git a/docs/operations.md b/docs/operations.md index cfc1a43..3d1d0e5 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,7 +16,8 @@ adaptador. Uma simulação ainda sonda capacidades e grava seu manifesto externo que o trabalho proposto possa ser auditado. Intencionalmente, não existe comando de publicação em produção. -O adaptador público de execução é conservador: a migração real permanece desabilitada até que a -implantação forneça e teste por contrato a origem SSH, a descoberta do banco de origem, a -retenção de cópias de segurança e o gravador protegido de `wp-config`. Ele falha antes de alterar -o estado quando a infraestrutura não está definida. +O adaptador público de execução delega cópias ao SSH/rsync, descoberta e transferência de bancos +ao MySQL e operações WordPress ao WP-CLI. Uma migração de banco exige endpoints de origem e de +TESTE permitidos e resolução não ambígua. Credenciais do `wp-config` são entregues ao WP-CLI por +entrada padrão, e não por `argv`. A retenção de uma cópia de teste já existente continua falhando +antes de alterar estado até que um adaptador específico seja configurado. diff --git a/src/wp_modernizer/application/ports.py b/src/wp_modernizer/application/ports.py index d7082f2..432eb36 100644 --- a/src/wp_modernizer/application/ports.py +++ b/src/wp_modernizer/application/ports.py @@ -22,6 +22,46 @@ def get_database(self, endpoint_id: str) -> Any: ... def list_schemas(self, endpoint_id: str) -> Set[str]: ... +class FileTransferPort(ServerRegistry, Protocol): + def copy_from( + self, + server_id: str, + source: Path, + destination_parent: Path, + excludes: Sequence[Path], + run_id: str, + ) -> int: ... + + +class DatabasePort(DatabaseRegistry, Protocol): + def dump(self, endpoint_id: str, database: str, output: Path, run_id: str) -> None: ... + + def import_dump(self, endpoint_id: str, database: str, source: Path, run_id: str) -> None: ... + + def snapshot_widgets(self, endpoint_id: str, database: str) -> WidgetSnapshot: ... + + def wordpress_configuration(self, endpoint_id: str, database: str) -> Mapping[str, str]: ... + + +class WordPressPort(Protocol): + def get_config(self, path: Path, name: str, run_id: str) -> str: ... + + def search_replace( + self, + path: Path, + old_url: str, + new_url: str, + *, + dry_run: bool, + multisite: bool, + run_id: str, + ) -> str: ... + + def set_config(self, path: Path, values: Mapping[str, str], run_id: str) -> None: ... + + def update(self, path: Path, arguments: Sequence[str], run_id: str) -> str: ... + + @dataclass(frozen=True) class CommandResult: argv: Tuple[str, ...] diff --git a/src/wp_modernizer/cli/main.py b/src/wp_modernizer/cli/main.py index db43310..081190c 100644 --- a/src/wp_modernizer/cli/main.py +++ b/src/wp_modernizer/cli/main.py @@ -8,30 +8,60 @@ import click +from wp_modernizer.application.ports import CommandRunner, SecretProvider from wp_modernizer.application.service import ModernizerService from wp_modernizer.config.loader import load_config +from wp_modernizer.config.models import ApplicationConfig from wp_modernizer.diagnostics.capability import CapabilityProbe from wp_modernizer.domain.enums import Operation, RunStatus from wp_modernizer.domain.errors import ModernizerError from wp_modernizer.domain.path_parser import InstallationPathParser from wp_modernizer.infrastructure.command import SubprocessCommandRunner from wp_modernizer.infrastructure.filesystem import LocalFileSystem +from wp_modernizer.infrastructure.mysql.adapter import MySQLAdapter from wp_modernizer.infrastructure.runtime_operations import RuntimeOperations +from wp_modernizer.infrastructure.secrets import EnvironmentSecretProvider +from wp_modernizer.infrastructure.ssh.adapter import RSyncSSHAdapter from wp_modernizer.infrastructure.state import JsonStateStore from wp_modernizer.infrastructure.time import SystemClock, UUIDGenerator +from wp_modernizer.infrastructure.wpcli.adapter import WPCLIAdapter + + +def build_service( + config: ApplicationConfig, + *, + runner: CommandRunner | None = None, + secrets: SecretProvider | None = None, +) -> ModernizerService: + """Composition root da aplicação; dependências opcionais mantêm os testes sem subprocessos.""" + command_runner = runner or SubprocessCommandRunner() + secret_provider = secrets or EnvironmentSecretProvider() + filesystem = LocalFileSystem() + ssh = RSyncSSHAdapter(config.servers, secret_provider, command_runner) + mysql = MySQLAdapter(config.databases, secret_provider, command_runner) + wpcli = WPCLIAdapter(command_runner) + operations = RuntimeOperations( + ssh, + mysql, + wpcli, + InstallationPathParser(config.allowed_app_roots), + database_overrides=config.database_overrides, + ) + return ModernizerService( + config, + CapabilityProbe(command_runner, filesystem), + JsonStateStore(config.state_directory), + filesystem, + SystemClock(), + UUIDGenerator(), + operations, + ) class Context: def __init__(self, config_path: Path, dry_run: bool) -> None: config = load_config(config_path) - filesystem = LocalFileSystem() - runner = SubprocessCommandRunner() - probe = CapabilityProbe(runner, filesystem) - state = JsonStateStore(config.state_directory) - operations = RuntimeOperations(runner, InstallationPathParser(config.allowed_app_roots)) - self.service = ModernizerService( - config, probe, state, filesystem, SystemClock(), UUIDGenerator(), operations - ) + self.service = build_service(config) self.dry_run = dry_run diff --git a/src/wp_modernizer/infrastructure/mysql/adapter.py b/src/wp_modernizer/infrastructure/mysql/adapter.py index 7ed27ea..cac4b77 100644 --- a/src/wp_modernizer/infrastructure/mysql/adapter.py +++ b/src/wp_modernizer/infrastructure/mysql/adapter.py @@ -1,12 +1,21 @@ from __future__ import annotations +import os import re +import tempfile +from contextlib import contextmanager from pathlib import Path -from typing import Dict, Set +from typing import Dict, Iterator, Mapping, Set from wp_modernizer.application.ports import CommandRunner, SecretProvider from wp_modernizer.config.models import DatabaseConfig -from wp_modernizer.domain.errors import AuthenticationError, InfrastructureError +from wp_modernizer.domain.enums import Environment +from wp_modernizer.domain.errors import ( + AuthenticationError, + ConfigurationError, + InfrastructureError, + UnsafeOperationError, +) from wp_modernizer.domain.widgets import WidgetOption, WidgetSnapshot @@ -27,53 +36,51 @@ def __init__( self._mysql = mysql_bin self._dump = mysqldump_bin + def get_database(self, endpoint_id: str) -> DatabaseConfig: + try: + return self._endpoints[endpoint_id] + except KeyError as exc: + raise ConfigurationError(f"Endpoint MySQL desconhecido: {endpoint_id}") from exc + def list_schemas(self, endpoint_id: str) -> Set[str]: result = self._query(endpoint_id, "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA") return set(result.splitlines()) def dump(self, endpoint_id: str, database: str, output: Path, run_id: str) -> None: - endpoint = self._endpoints[endpoint_id] - result = self._runner.run( - [ - self._dump, - "--host", - endpoint.host, - "--port", - str(endpoint.port), - "--user", - self._username(endpoint), - "--single-transaction", - "--quick", - "--default-character-set=utf8mb4", - database, - ], - environment=self._environment(endpoint), - stdout_path=output, - timeout=1800, - correlation_id=run_id, - ) + endpoint = self.get_database(endpoint_id) + with self._defaults_file(endpoint) as defaults: + result = self._runner.run( + [ + self._dump, + f"--defaults-extra-file={defaults}", + "--single-transaction", + "--quick", + "--default-character-set=utf8mb4", + database, + ], + stdout_path=output, + timeout=1800, + correlation_id=run_id, + ) self._ensure_success(result.return_code, result.stderr) def import_dump(self, endpoint_id: str, database: str, source: Path, run_id: str) -> None: - endpoint = self._endpoints[endpoint_id] - result = self._runner.run( - [ - self._mysql, - "--batch", - "--raw", - "--host", - endpoint.host, - "--port", - str(endpoint.port), - "--user", - self._username(endpoint), - database, - ], - environment=self._environment(endpoint), - stdin_path=source, - timeout=1800, - correlation_id=run_id, - ) + endpoint = self.get_database(endpoint_id) + if endpoint.environment is not Environment.TEST: + raise UnsafeOperationError("Importações MySQL são proibidas fora de TESTE") + with self._defaults_file(endpoint) as defaults: + result = self._runner.run( + [ + self._mysql, + f"--defaults-extra-file={defaults}", + "--batch", + "--raw", + database, + ], + stdin_path=source, + timeout=1800, + correlation_id=run_id, + ) self._ensure_success(result.return_code, result.stderr) def snapshot_widgets(self, endpoint_id: str, database: str) -> WidgetSnapshot: @@ -100,32 +107,67 @@ def snapshot_widgets(self, endpoint_id: str, database: str) -> WidgetSnapshot: ) return WidgetSnapshot.from_options(options) + def wordpress_configuration(self, endpoint_id: str, database: str) -> Mapping[str, str]: + endpoint = self.get_database(endpoint_id) + if endpoint.environment is not Environment.TEST: + raise UnsafeOperationError("Configuração WordPress é proibida fora de TESTE") + host = endpoint.host if endpoint.port == 3306 else f"{endpoint.host}:{endpoint.port}" + return { + # Troque o host primeiro: qualquer falha posterior já mantém o WordPress afastado + # do endpoint de produção copiado da origem. + "DB_HOST": host, + "DB_NAME": database, + "DB_USER": self._secrets.get(endpoint.username_secret), + "DB_PASSWORD": self._secrets.get(endpoint.password_secret), + } + def _query(self, endpoint_id: str, sql: str, database: str = "") -> str: - endpoint = self._endpoints[endpoint_id] - argv = [ - self._mysql, - "--batch", - "--raw", - "--skip-column-names", - "--host", - endpoint.host, - "--port", - str(endpoint.port), - "--user", - self._username(endpoint), - ] - if database: - argv.append(database) - argv.extend(["--execute", sql]) - result = self._runner.run(argv, environment=self._environment(endpoint), timeout=60) + endpoint = self.get_database(endpoint_id) + with self._defaults_file(endpoint) as defaults: + argv = [ + self._mysql, + f"--defaults-extra-file={defaults}", + "--batch", + "--raw", + "--skip-column-names", + ] + if database: + argv.append(database) + argv.extend(["--execute", sql]) + result = self._runner.run(argv, timeout=60) self._ensure_success(result.return_code, result.stderr) return result.stdout - def _username(self, endpoint: DatabaseConfig) -> str: - return self._secrets.get(endpoint.username_secret) + @contextmanager + def _defaults_file(self, endpoint: DatabaseConfig) -> Iterator[Path]: + username = self._secrets.get(endpoint.username_secret) + password = self._secrets.get(endpoint.password_secret) + path: Path | None = None + try: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: + path = Path(handle.name) + handle.write( + "[client]\n" + f"host={endpoint.host}\nport={endpoint.port}\n" + f"user={self._option_value(username)}\n" + f"password={self._option_value(password)}\n" + ) + os.chmod(path, 0o600) + yield path + finally: + if path is not None: + path.unlink(missing_ok=True) - def _environment(self, endpoint: DatabaseConfig) -> Dict[str, str]: - return {"MYSQL_PWD": self._secrets.get(endpoint.password_secret)} + @staticmethod + def _option_value(value: str) -> str: + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + return f'"{escaped}"' @staticmethod def _ensure_success(return_code: int, stderr: str) -> None: @@ -133,4 +175,4 @@ def _ensure_success(return_code: int, stderr: str) -> None: return if "access denied" in stderr.lower(): raise AuthenticationError("Falha na autenticação do banco de dados") - raise InfrastructureError(f"Falha no comando do banco de dados: {stderr}") + raise InfrastructureError("Falha no comando do banco de dados; consulte o log redigido") diff --git a/src/wp_modernizer/infrastructure/runtime_operations.py b/src/wp_modernizer/infrastructure/runtime_operations.py index cb5bcb3..1662f9b 100644 --- a/src/wp_modernizer/infrastructure/runtime_operations.py +++ b/src/wp_modernizer/infrastructure/runtime_operations.py @@ -1,17 +1,19 @@ from __future__ import annotations +import tempfile from pathlib import Path from typing import Any, ClassVar, Dict, Tuple -from wp_modernizer.application.ports import CommandRunner -from wp_modernizer.domain.enums import StepStatus +from wp_modernizer.application.ports import DatabasePort, FileTransferPort, WordPressPort +from wp_modernizer.domain.database import DatabaseLocator, SuffixDatabaseNamingStrategy +from wp_modernizer.domain.enums import Environment, PendingOperationType, StepStatus from wp_modernizer.domain.errors import UnsafeOperationError from wp_modernizer.domain.models import PlannedStep, StepResult from wp_modernizer.domain.path_parser import InstallationPathParser class RuntimeOperations: - """Adaptador local conservador. Ações específicas não suportadas falham e preservam.""" + """Traduz etapas planejadas em chamadas às portas concretas de infraestrutura.""" _wp_commands: ClassVar[Dict[str, Tuple[str, ...]]] = { "core_update": ("core", "update"), @@ -23,80 +25,199 @@ class RuntimeOperations: "theme_languages": ("language", "theme", "update", "--all"), } - def __init__(self, runner: CommandRunner, parser: InstallationPathParser) -> None: - self._runner = runner + def __init__( + self, + files: FileTransferPort, + databases: DatabasePort, + wordpress: WordPressPort, + parser: InstallationPathParser, + *, + database_overrides: Dict[str, str] | None = None, + ) -> None: + self._files = files + self._databases = databases + self._wordpress = wordpress self._parser = parser + self._database_overrides = database_overrides or {} + self._database_runs: Dict[tuple[str, str], Dict[str, Any]] = {} def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: planned_step = context.get("planned_step") if not isinstance(planned_step, PlannedStep): raise UnsafeOperationError(f"Etapa {step_name} não possui plano de execução") - installations = context.get("installations", {}) - installation = installations.get(planned_step.installation_id, context["installation"]) + installation = context.get("installations", {}).get( + planned_step.installation_id, context["installation"] + ) + if installation.destination_environment is not Environment.TEST: + raise UnsafeOperationError("Operações mutáveis são proibidas fora de TESTE") path = Path(installation.destination_path) + run_id = str(context["run_id"]) + if step_name == "backup_existing_test": if not path.exists(): return self._ok(step_name, False, "não há cópia de teste existente") if not context.get("replace_existing"): - return StepResult( - step_name, - StepStatus.FAILED, - False, - "a cópia de teste existente requer --replace-existing", + return self._failed( + step_name, "a cópia de teste existente requer --replace-existing" ) parsed = self._parser.parse( - str(path), context["installation_id"], installation.destination_environment + str(path), planned_step.installation_id, installation.destination_environment ) self._parser.assert_safe_destructive_target(path, parsed) - # A política de cópias depende da implantação. Não exclua sem confirmação do adaptador. - return StepResult( + return self._failed( step_name, - StepStatus.FAILED, - False, "o adaptador de cópia de segurança deve ser configurado antes da substituição; " "o teste existente foi preservado", ) + if step_name == "copy_files": - excluded = ", ".join(str(path) for path in planned_step.excludes) - return StepResult( - step_name, - StepStatus.FAILED, - False, - "o adaptador de origem SSH/rsync deve ser configurado explicitamente com " - f"exclusions [{excluded}]; nenhum arquivo foi alterado", + parsed = self._parser.parse( + str(path), planned_step.installation_id, installation.destination_environment + ) + self._parser.assert_safe_destructive_target(path, parsed) + server = self._files.get_server(installation.source_server) + if server.environment is not installation.source_environment: + raise UnsafeOperationError("O ambiente do servidor não coincide com o da origem") + elapsed = self._files.copy_from( + installation.source_server, + Path(installation.source_path), + path.parent, + planned_step.excludes, + run_id, ) - if step_name in {"snapshot_source_database", "copy_database", "write_test_db_config"}: return StepResult( step_name, - StepStatus.FAILED, - False, - "o adaptador de migração requer descoberta da origem em tempo de execução; " - "nenhum banco de dados foi alterado", + StepStatus.SUCCEEDED, + True, + "arquivos copiados pelo adaptador SSH/rsync", + {"duration_seconds": float(elapsed)}, + ) + + if step_name == "snapshot_source_database": + return self._snapshot_source_database( + step_name, planned_step.installation_id, installation, path, run_id ) + if step_name == "copy_database": + return self._copy_database(step_name, planned_step.installation_id, run_id) + if step_name == "write_test_db_config": + return self._write_test_db_config(step_name, planned_step.installation_id, path, run_id) + if step_name in {"preflight", "snapshot", "widget_validation", "final_health_check"}: return self._ok(step_name, False, "ponto de controle de diagnóstico concluído") if step_name == "pending_search_replace": - return self._ok( - step_name, False, "nenhuma operação pendente materializada com segurança" - ) + return self._search_replace(step_name, path, context, run_id) if step_name == "managed_plugin_refresh": return self._ok(step_name, False, "nenhuma atualização de plugin gerenciado solicitada") command = self._wp_commands.get(step_name) if command: - result = self._runner.run( - ["wp", f"--path={path}", "--skip-plugins", "--skip-themes", *command], - timeout=900, - correlation_id=context["run_id"], - ) - return StepResult( + output = self._wordpress.update(path, command, run_id) + return StepResult(step_name, StepStatus.SUCCEEDED, True, output) + raise UnsafeOperationError(f"Etapa mutável desconhecida: {step_name}") + + def _snapshot_source_database( + self, + step_name: str, + installation_id: str, + installation: Any, + path: Path, + run_id: str, + ) -> StepResult: + source_name = installation.database_override or self._wordpress.get_config( + path, "DB_NAME", run_id + ) + source_endpoints = [ + endpoint_id + for endpoint_id in installation.allowed_database_endpoints + if self._databases.get_database(endpoint_id).environment + is installation.source_environment + and source_name in self._databases.list_schemas(endpoint_id) + ] + if len(source_endpoints) != 1: + return self._failed( step_name, - StepStatus.SUCCEEDED if result.return_code == 0 else StepStatus.FAILED, - result.return_code == 0, - result.stdout or result.stderr, - {"duration_seconds": result.elapsed_seconds}, + "a origem MySQL não foi identificada de forma única entre os endpoints permitidos", ) - raise UnsafeOperationError(f"Etapa mutável desconhecida: {step_name}") + target_endpoints = [ + endpoint_id + for endpoint_id in installation.allowed_database_endpoints + if self._databases.get_database(endpoint_id).environment is Environment.TEST + ] + target = DatabaseLocator(self._databases, SuffixDatabaseNamingStrategy("test")).locate( + source_name, + installation.database_aliases, + target_endpoints, + self._database_overrides, + installation_id, + ) + self._database_runs[(run_id, installation_id)] = { + "source_endpoint": source_endpoints[0], + "source_database": source_name, + "target_endpoint": target.endpoint_id, + "target_database": target.database_name, + } + return self._ok(step_name, False, "origem e destino MySQL resolvidos sem ambiguidade") + + def _copy_database(self, step_name: str, installation_id: str, run_id: str) -> StepResult: + key = (run_id, installation_id) + state = self._database_runs.get(key) + if not state: + return self._failed(step_name, "não há instantâneo MySQL desta execução para importar") + with tempfile.NamedTemporaryFile( + prefix="wp-modernizer-", suffix=".sql", delete=False + ) as handle: + dump_path = Path(handle.name) + try: + self._databases.dump( + state["source_endpoint"], state["source_database"], dump_path, run_id + ) + self._databases.import_dump( + state["target_endpoint"], state["target_database"], dump_path, run_id + ) + finally: + dump_path.unlink(missing_ok=True) + return self._ok(step_name, True, "banco importado pelo adapter MySQL no ambiente de teste") + + def _write_test_db_config( + self, step_name: str, installation_id: str, path: Path, run_id: str + ) -> StepResult: + key = (run_id, installation_id) + state = self._database_runs.get(key) + if not state: + return self._failed(step_name, "o destino MySQL desta execução não foi resolvido") + values = self._databases.wordpress_configuration( + state["target_endpoint"], state["target_database"] + ) + self._wordpress.set_config(path, values, run_id) + self._database_runs.pop(key, None) + 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 + ) -> StepResult: + plan = context.get("migration_plan") + pending = next( + ( + item + for item in getattr(plan, "pending_operations", ()) + if item.operation_type is PendingOperationType.SEARCH_REPLACE and not item.completed + ), + None, + ) + if pending is None: + return self._ok(step_name, False, "nenhum search-replace pendente") + old_url = pending.parameters.get("old_url", "") + new_url = pending.parameters.get("new_url", "") + if not old_url or not new_url or "runtime" in old_url or "configured" in new_url: + return self._ok(step_name, False, "search-replace ainda depende de valores descobertos") + output = self._wordpress.search_replace( + path, old_url, new_url, dry_run=False, multisite=False, run_id=run_id + ) + return StepResult(step_name, StepStatus.SUCCEEDED, True, output) @staticmethod def _ok(name: str, changed: bool, message: str) -> StepResult: return StepResult(name, StepStatus.SUCCEEDED, changed, message) + + @staticmethod + def _failed(name: str, message: str) -> StepResult: + return StepResult(name, StepStatus.FAILED, False, message) diff --git a/src/wp_modernizer/infrastructure/ssh/adapter.py b/src/wp_modernizer/infrastructure/ssh/adapter.py index 1868066..a5ba71d 100644 --- a/src/wp_modernizer/infrastructure/ssh/adapter.py +++ b/src/wp_modernizer/infrastructure/ssh/adapter.py @@ -1,5 +1,8 @@ from __future__ import annotations +import os +import re +import tempfile from pathlib import Path from typing import Dict, Iterable @@ -16,6 +19,12 @@ def __init__( self._secrets = secrets self._runner = runner + def get_server(self, server_id: str) -> ServerConfig: + try: + return self._servers[server_id] + except KeyError as exc: + raise ConfigurationError(f"Servidor SSH desconhecido: {server_id}") from exc + def copy_from( self, server_id: str, @@ -24,28 +33,52 @@ def copy_from( excludes: Iterable[Path], run_id: str, ) -> int: - server = self._servers[server_id] + server = self.get_server(server_id) if server.authentication == "password": raise ConfigurationError( "SSH com senha requer um adaptador separado, sem linha de comando e revisado; " "autenticação por chave é o padrão público" ) + # O usuário vem de SecretProvider e, por isso, não pode fazer parte de argv. Um arquivo + # efêmero 0600 é entendido diretamente pelo ssh e removido mesmo quando o rsync falha. username = self._secrets.get(server.username_secret) - ssh = [ - "ssh", - "-p", - str(server.port), - "-o", - "StrictHostKeyChecking=" + if not re.fullmatch(r"[A-Za-z0-9._@+-]+", username): + raise ConfigurationError( + "O usuário SSH fornecido pelo segredo contém caracteres inválidos" + ) + lines = [ + "Host wp-modernizer-source", + f" HostName {server.host}", + f" Port {server.port}", + f" User {username}", + " StrictHostKeyChecking " + ("yes" if server.host_key_policy == "strict" else "accept-new"), ] if server.private_key: - ssh.extend(["-i", str(server.private_key)]) - argv = ["rsync", "-a", "--info=stats2", "--protect-args", "-e", " ".join(ssh)] - for excluded in excludes: - argv.extend(["--exclude", str(excluded)]) - argv.extend([f"{username}@{server.host}:{source}", str(destination_parent)]) - result = self._runner.run(argv, timeout=1800, correlation_id=run_id) + lines.append(f" IdentityFile {server.private_key}") + config_path: Path | None = None + try: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: + config_path = Path(handle.name) + handle.write("\n".join(lines) + "\n") + os.chmod(config_path, 0o600) + argv = [ + "rsync", + "-a", + "--info=stats2", + "--protect-args", + "-e", + f"ssh -F {config_path}", + ] + for excluded in excludes: + argv.extend(["--exclude", str(excluded)]) + argv.extend([f"wp-modernizer-source:{source}", str(destination_parent)]) + result = self._runner.run(argv, timeout=1800, correlation_id=run_id) + finally: + if config_path is not None: + config_path.unlink(missing_ok=True) if result.return_code != 0: - raise InfrastructureError(f"Falha no rsync sobre SSH: {result.stderr}") + raise InfrastructureError( + f"Falha no rsync sobre SSH (código {result.return_code}); consulte o log redigido" + ) return int(result.elapsed_seconds) diff --git a/src/wp_modernizer/infrastructure/wpcli/adapter.py b/src/wp_modernizer/infrastructure/wpcli/adapter.py index 0164c09..d974224 100644 --- a/src/wp_modernizer/infrastructure/wpcli/adapter.py +++ b/src/wp_modernizer/infrastructure/wpcli/adapter.py @@ -1,5 +1,7 @@ +import os +import tempfile from pathlib import Path -from typing import Sequence +from typing import Mapping, Sequence from wp_modernizer.application.ports import CommandRunner from wp_modernizer.domain.errors import WordPressUnavailableError @@ -10,6 +12,16 @@ def __init__(self, runner: CommandRunner, binary: str = "wp") -> None: self._runner = runner self._binary = binary + def get_config(self, path: Path, name: str, run_id: str) -> str: + result = self._runner.run( + [self._binary, f"--path={path}", "config", "get", name], + timeout=60, + correlation_id=run_id, + ) + if result.return_code != 0: + raise WordPressUnavailableError(result.stderr) + return result.stdout.strip() + def search_replace( self, path: Path, old_url: str, new_url: str, *, dry_run: bool, multisite: bool, run_id: str ) -> str: @@ -36,6 +48,39 @@ def search_replace( ) return result.stdout + def set_config(self, path: Path, values: Mapping[str, str], run_id: str) -> None: + for name, value in values.items(): + if "\n" in value or "\r" in value: + raise WordPressUnavailableError( + f"o valor de configuração {name} contém quebra de linha insegura" + ) + stdin_path: Path | None = None + try: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: + stdin_path = Path(handle.name) + handle.write(value + "\n") + os.chmod(stdin_path, 0o600) + result = self._runner.run( + [ + self._binary, + f"--path={path}", + "--prompt=value", + "config", + "set", + name, + ], + stdin_path=stdin_path, + timeout=60, + correlation_id=run_id, + ) + finally: + if stdin_path is not None: + stdin_path.unlink(missing_ok=True) + if result.return_code != 0: + raise WordPressUnavailableError( + f"falha ao definir {name} no wp-config; consulte o log redigido" + ) + def update(self, path: Path, arguments: Sequence[str], run_id: str) -> str: result = self._runner.run( [self._binary, f"--path={path}", "--skip-plugins", "--skip-themes", *arguments], diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index d94b4be..4d28b4d 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -5,7 +5,12 @@ from tests.fakes.core import FakeCommandResult, FakeCommandRunner from wp_modernizer.config.models import DatabaseConfig, ServerConfig from wp_modernizer.domain.enums import Environment -from wp_modernizer.domain.errors import AuthenticationError, ConfigurationError, InfrastructureError +from wp_modernizer.domain.errors import ( + AuthenticationError, + ConfigurationError, + InfrastructureError, + UnsafeOperationError, +) from wp_modernizer.infrastructure.filesystem import LocalFileSystem from wp_modernizer.infrastructure.mysql.adapter import MySQLAdapter from wp_modernizer.infrastructure.secrets import EnvironmentSecretProvider @@ -42,6 +47,16 @@ def test_mysql_schema_discovery_and_authentication_error() -> None: denied.list_schemas("db") +def test_mysql_never_imports_into_production() -> None: + production = database().model_copy(update={"environment": Environment.PRODUCTION}) + runner = FakeCommandRunner() + with pytest.raises(UnsafeOperationError, match="fora de TESTE"): + MySQLAdapter({"db": production}, Secrets(), runner).import_dump( + "db", "site", Path("/tmp/dump.sql"), "r" + ) + assert runner.calls == [] + + def test_mysql_widget_snapshot_preserves_binary_and_rejects_bad_table() -> None: runner = FakeCommandRunner( [ @@ -72,6 +87,14 @@ def test_wpcli_adapter_dry_run_multisite_and_failure() -> None: ) +def test_wpcli_writes_config_values_via_stdin_not_argv() -> None: + runner = FakeCommandRunner() + WPCLIAdapter(runner).set_config(Path("/site"), {"DB_PASSWORD": "never-in-argv"}, "run-1") + assert "--prompt=value" in runner.calls[0] + assert "DB_PASSWORD" in runner.calls[0] + assert "never-in-argv" not in runner.calls[0] + + def test_ssh_is_key_first_and_password_adapter_is_refused() -> None: key = ServerConfig( host="source.example.invalid", diff --git a/tests/unit/test_composition_root.py b/tests/unit/test_composition_root.py new file mode 100644 index 0000000..b50f274 --- /dev/null +++ b/tests/unit/test_composition_root.py @@ -0,0 +1,102 @@ +from pathlib import Path + +from tests.fakes.core import FakeCommandRunner +from wp_modernizer.cli.main import build_service +from wp_modernizer.config.models import ApplicationConfig +from wp_modernizer.domain.enums import StepStatus +from wp_modernizer.domain.models import PlannedStep +from wp_modernizer.infrastructure.mysql.adapter import MySQLAdapter +from wp_modernizer.infrastructure.runtime_operations import RuntimeOperations +from wp_modernizer.infrastructure.ssh.adapter import RSyncSSHAdapter +from wp_modernizer.infrastructure.wpcli.adapter import WPCLIAdapter + + +class RecordingSecrets: + def __init__(self) -> None: + self.calls = [] + + def get(self, reference: str) -> str: + self.calls.append(reference) + return { + "SSH_USER": "ssh-user-must-not-leak", + "DB_USER": "db-user-must-not-leak", + "DB_PASSWORD": "db-password-must-not-leak", + }[reference] + + +def configured(tmp_path: Path) -> ApplicationConfig: + return ApplicationConfig.model_validate( + { + "state_directory": str(tmp_path / "state"), + "allowed_app_roots": [str(tmp_path)], + "servers": { + "source": { + "host": "source.example.invalid", + "environment": "production", + "username_secret": "SSH_USER", + } + }, + "databases": { + "test-db": { + "host": "db.example.invalid", + "environment": "test", + "username_secret": "DB_USER", + "password_secret": "DB_PASSWORD", + } + }, + "installations": { + "site": { + "source_server": "source", + "source_environment": "production", + "source_path": "/remote/example.org/wp-main/htdocs", + "destination_path": str(tmp_path / "example.org/wp-test/htdocs"), + "destination_environment": "test", + "allowed_database_endpoints": ["test-db"], + } + }, + } + ) + + +def planned(name: str, excludes: tuple[Path, ...] = ()) -> PlannedStep: + return PlannedStep(name, True, True, "probe", "recovery", "site", excludes) + + +def test_composition_root_wires_config_secrets_and_all_runtime_adapters(tmp_path: Path) -> None: + config = configured(tmp_path) + runner = FakeCommandRunner() + secrets = RecordingSecrets() + + service = build_service(config, runner=runner, secrets=secrets) + operations = service._operations + + assert isinstance(operations, RuntimeOperations) + assert isinstance(operations._files, RSyncSSHAdapter) + assert operations._files.get_server("source") is config.servers["source"] + assert isinstance(operations._databases, MySQLAdapter) + assert operations._databases.get_database("test-db") is config.databases["test-db"] + assert isinstance(operations._wordpress, WPCLIAdapter) + + context = { + "run_id": "run-1", + "installation_id": "site", + "installation": config.installations["site"], + "installations": config.installations, + "planned_step": planned("copy_files", (Path("child"), Path("*.sql"))), + } + result = operations.execute("copy_files", context) + + assert result.status is StepStatus.SUCCEEDED + assert "SSH_USER" in secrets.calls + assert runner.calls[0][0] == "rsync" + assert "--exclude" in runner.calls[0] + assert "ssh-user-must-not-leak" not in " ".join(runner.calls[0]) + + operations._databases.list_schemas("test-db") + assert {"DB_USER", "DB_PASSWORD"}.issubset(secrets.calls) + assert not any("must-not-leak" in argument for call in runner.calls for argument in call) + + context["planned_step"] = planned("core_update") + wp_result = operations.execute("core_update", context) + assert wp_result.status is StepStatus.SUCCEEDED + assert runner.calls[-1][-2:] == ("core", "update") From 8dfbf3c10a12c17bd7cbff1ceb94221e28182890 Mon Sep 17 00:00:00 2001 From: apendindan Date: Sun, 30 Aug 2026 21:07:45 -0300 Subject: [PATCH 3/6] fix(resume): restore original operation plan when resuming --- src/wp_modernizer/application/service.py | 105 +++++++++++++++--- src/wp_modernizer/domain/models.py | 5 + .../infrastructure/runtime_operations.py | 44 ++++++-- src/wp_modernizer/infrastructure/state.py | 5 + tests/unit/test_service.py | 89 ++++++++++++++- tests/unit/test_state.py | 13 +++ 6 files changed, 236 insertions(+), 25 deletions(-) diff --git a/src/wp_modernizer/application/service.py b/src/wp_modernizer/application/service.py index 7000205..3128ee7 100644 --- a/src/wp_modernizer/application/service.py +++ b/src/wp_modernizer/application/service.py @@ -13,9 +13,19 @@ StateStore, ) from wp_modernizer.config.models import ApplicationConfig -from wp_modernizer.domain.enums import Environment, Operation, PendingOperationType, RunStatus -from wp_modernizer.domain.errors import ConfigurationError, UnsafeOperationError -from wp_modernizer.domain.models import MigrationPlan, PendingOperation, RunManifest +from wp_modernizer.domain.enums import ( + Environment, + Operation, + PendingOperationType, + RunStatus, + StepStatus, +) +from wp_modernizer.domain.errors import ( + ConfigurationError, + ResumeConsistencyError, + UnsafeOperationError, +) +from wp_modernizer.domain.models import MigrationPlan, PendingOperation, PlannedStep, RunManifest from wp_modernizer.domain.path_parser import InstallationPathParser from wp_modernizer.domain.planning import MigrationPlanner from wp_modernizer.pipeline.runner import PipelineRunner @@ -150,6 +160,12 @@ def execute( pending_operations=list(migration_plan.pending_operations), planned_steps=list(planned_steps), migration_plan=migration_plan, + execution_parameters={ + "replace_existing": replace_existing, + "restore_widgets": restore_widgets, + }, + recovery_data={}, + original_run_id=run_id, ) context = { "run_id": run_id, @@ -159,35 +175,39 @@ def execute( "restore_widgets": restore_widgets, "installations": self.config.installations, "migration_plan": migration_plan, + "recovery_data": manifest.recovery_data, } steps = tuple(OperationStep(step, self._operations) for step in planned_steps) return self._runner.run(manifest, path, steps, context) def resume(self, installation_id: str, run_id: str, dry_run: bool) -> RunManifest: old = self._state.load_manifest(installation_id, run_id) + original_steps = self._safe_resume_plan(old) path = self._installation(installation_id).destination_path self._runner.assert_resume_consistent(old, path) - completed = { - (step.installation_id or installation_id, step.name) - for step in old.steps - if step.status.value == "SUCCEEDED" - } - original_steps = old.planned_steps or list(planned_update_steps(installation_id)) - remaining = [ - step - for step in original_steps - if (step.installation_id or installation_id, step.name) not in completed - ] + completed_count = self._completed_prefix(old, original_steps) + remaining = original_steps[completed_count:] + parameters = dict(old.execution_parameters or {}) new = RunManifest( self._ids.new(), installation_id, - Operation.RESUME, + old.operation, RunStatus.RUNNING, self._clock.now_iso(), dry_run, + steps=list(old.steps[:completed_count]), pending_operations=list(old.pending_operations), - planned_steps=remaining, + last_successful_step=( + old.steps[completed_count - 1].name if completed_count else None + ), + widget_diff=list(old.widget_diff), + planned_steps=list(original_steps), migration_plan=old.migration_plan, + execution_parameters=parameters, + recovery_data={key: dict(value) for key, value in old.recovery_data.items()}, + original_run_id=old.original_run_id or old.run_id, + resumed_from_run_id=old.run_id, + resume_source_failed_step=old.failed_step, ) return self._runner.run( new, @@ -199,10 +219,63 @@ def resume(self, installation_id: str, run_id: str, dry_run: bool) -> RunManifes "installation": self._installation(installation_id), "installations": self.config.installations, "migration_plan": old.migration_plan, + "replace_existing": parameters["replace_existing"], + "restore_widgets": parameters["restore_widgets"], + "recovery_data": new.recovery_data, "resumed_from": run_id, }, ) + @staticmethod + def _safe_resume_plan(old: RunManifest) -> list[PlannedStep]: + executable = {Operation.MIGRATE, Operation.UPDATE, Operation.PIPELINE} + missing = [] + if old.operation not in executable: + missing.append("operação original") + if not old.planned_steps: + missing.append("plano original ordenado") + if old.execution_parameters is None or not { + "replace_existing", + "restore_widgets", + }.issubset(old.execution_parameters): + missing.append("parâmetros de execução") + if old.migration_plan is None: + missing.append("plano de migração e operações pendentes") + if missing: + detail = ", ".join(missing) + raise ResumeConsistencyError( + "Este manifest não contém informação suficiente para um resume seguro: " + f"{detail}. Execute novamente a operação original." + ) + return list(old.planned_steps) + + @staticmethod + def _completed_prefix(old: RunManifest, planned_steps: list[PlannedStep]) -> int: + if len(old.steps) > len(planned_steps): + raise ResumeConsistencyError( + "O histórico de steps não corresponde ao plano original; resume seguro recusado" + ) + completed = 0 + encountered_incomplete = False + for index, result in enumerate(old.steps): + planned = planned_steps[index] + identity = (result.installation_id or old.installation_id, result.name) + expected = (planned.installation_id or old.installation_id, planned.name) + if identity != expected: + raise ResumeConsistencyError( + "O histórico de steps não corresponde ao plano original; " + "resume seguro recusado" + ) + if result.status is StepStatus.SUCCEEDED: + if encountered_incomplete: + raise ResumeConsistencyError( + "O histórico possui steps concluídos fora de ordem; resume seguro recusado" + ) + completed += 1 + else: + encountered_incomplete = True + return completed + def _installation(self, installation_id: str) -> Any: try: return self.config.installations[installation_id] diff --git a/src/wp_modernizer/domain/models.py b/src/wp_modernizer/domain/models.py index 399b3f0..1f941e2 100644 --- a/src/wp_modernizer/domain/models.py +++ b/src/wp_modernizer/domain/models.py @@ -129,3 +129,8 @@ class RunManifest: finished_at: Optional[str] = None planned_steps: List[PlannedStep] = field(default_factory=list) migration_plan: Optional[MigrationPlan] = None + execution_parameters: Optional[Dict[str, bool]] = None + recovery_data: Dict[str, Dict[str, str]] = field(default_factory=dict) + original_run_id: Optional[str] = None + resumed_from_run_id: Optional[str] = None + resume_source_failed_step: Optional[str] = None diff --git a/src/wp_modernizer/infrastructure/runtime_operations.py b/src/wp_modernizer/infrastructure/runtime_operations.py index 1662f9b..0b8ddc1 100644 --- a/src/wp_modernizer/infrastructure/runtime_operations.py +++ b/src/wp_modernizer/infrastructure/runtime_operations.py @@ -95,12 +95,28 @@ def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: if step_name == "snapshot_source_database": return self._snapshot_source_database( - step_name, planned_step.installation_id, installation, path, run_id + step_name, + planned_step.installation_id, + installation, + path, + run_id, + context.get("recovery_data", {}), ) if step_name == "copy_database": - return self._copy_database(step_name, planned_step.installation_id, run_id) + return self._copy_database( + step_name, + planned_step.installation_id, + run_id, + context.get("recovery_data", {}), + ) if step_name == "write_test_db_config": - return self._write_test_db_config(step_name, planned_step.installation_id, path, run_id) + return self._write_test_db_config( + step_name, + planned_step.installation_id, + path, + run_id, + context.get("recovery_data", {}), + ) if step_name in {"preflight", "snapshot", "widget_validation", "final_health_check"}: return self._ok(step_name, False, "ponto de controle de diagnóstico concluído") @@ -121,6 +137,7 @@ def _snapshot_source_database( installation: Any, path: Path, run_id: str, + recovery_data: Dict[str, Dict[str, str]], ) -> StepResult: source_name = installation.database_override or self._wordpress.get_config( path, "DB_NAME", run_id @@ -155,11 +172,18 @@ def _snapshot_source_database( "target_endpoint": target.endpoint_id, "target_database": target.database_name, } + recovery_data[installation_id] = dict(self._database_runs[(run_id, installation_id)]) return self._ok(step_name, False, "origem e destino MySQL resolvidos sem ambiguidade") - def _copy_database(self, step_name: str, installation_id: str, run_id: str) -> StepResult: + def _copy_database( + self, + step_name: str, + installation_id: str, + run_id: str, + recovery_data: Dict[str, Dict[str, str]], + ) -> StepResult: key = (run_id, installation_id) - state = self._database_runs.get(key) + state = self._database_runs.get(key) or recovery_data.get(installation_id) if not state: return self._failed(step_name, "não há instantâneo MySQL desta execução para importar") with tempfile.NamedTemporaryFile( @@ -178,10 +202,15 @@ def _copy_database(self, step_name: str, installation_id: str, run_id: str) -> S return self._ok(step_name, True, "banco importado pelo adapter MySQL no ambiente de teste") def _write_test_db_config( - self, step_name: str, installation_id: str, path: Path, run_id: str + self, + step_name: str, + installation_id: str, + path: Path, + run_id: str, + recovery_data: Dict[str, Dict[str, str]], ) -> StepResult: key = (run_id, installation_id) - state = self._database_runs.get(key) + state = self._database_runs.get(key) or recovery_data.get(installation_id) if not state: return self._failed(step_name, "o destino MySQL desta execução não foi resolvido") values = self._databases.wordpress_configuration( @@ -189,6 +218,7 @@ def _write_test_db_config( ) self._wordpress.set_config(path, values, run_id) self._database_runs.pop(key, None) + recovery_data.pop(installation_id, None) return self._ok(step_name, True, "wp-config aponta para o banco do ambiente de teste") def _search_replace( diff --git a/src/wp_modernizer/infrastructure/state.py b/src/wp_modernizer/infrastructure/state.py index 0f8d361..315432d 100644 --- a/src/wp_modernizer/infrastructure/state.py +++ b/src/wp_modernizer/infrastructure/state.py @@ -82,6 +82,11 @@ def load_manifest(self, installation_id: str, run_id: str) -> RunManifest: self._deserialize_planned_step(item) for item in raw.get("planned_steps", []) ], migration_plan=self._deserialize_migration_plan(raw.get("migration_plan")), + execution_parameters=raw.get("execution_parameters"), + recovery_data=raw.get("recovery_data", {}), + original_run_id=raw.get("original_run_id"), + resumed_from_run_id=raw.get("resumed_from_run_id"), + resume_source_failed_step=raw.get("resume_source_failed_step"), ) @staticmethod diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 96fedb6..a1b4d70 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -1,3 +1,5 @@ +import pytest + from tests.fakes.core import ( FakeClock, FakeFileSystem, @@ -10,7 +12,8 @@ from wp_modernizer.application.service import ModernizerService from wp_modernizer.config.models import ApplicationConfig from wp_modernizer.domain.enums import HealthStatus, Operation, RunStatus -from wp_modernizer.domain.models import PlannedStep +from wp_modernizer.domain.errors import ResumeConsistencyError +from wp_modernizer.domain.models import PlannedStep, RunManifest def config() -> ApplicationConfig: @@ -142,4 +145,86 @@ def test_resume_skips_successful_steps() -> None: app = service(state=state) old = app.execute(Operation.UPDATE, "parent", dry_run=False) result = app.resume("parent", old.run_id, dry_run=True) - assert result.steps == [] + assert result.steps == old.steps + assert result.operation is Operation.UPDATE + assert result.planned_steps == old.planned_steps + + +@pytest.mark.parametrize( + ("operation", "failed_step"), + [ + (Operation.MIGRATE, "snapshot_source_database"), + (Operation.UPDATE, "core_update"), + (Operation.PIPELINE, "core_update"), + ], +) +def test_interrupted_operation_resumes_the_same_original_plan( + operation: Operation, failed_step: str +) -> None: + state = FakeStateStore() + operations = FakeOperations(fail_at=failed_step) + app = service(operations=operations, state=state) + old = app.execute( + operation, + "parent", + dry_run=False, + replace_existing=True, + restore_widgets=True, + ) + assert old.status is RunStatus.UPDATE_FAILED_PRESERVED + completed_calls = list(operations.calls[:-1]) + operations.calls.clear() + operations.contexts.clear() + operations.fail_at = None + + resumed = app.resume("parent", old.run_id, dry_run=False) + + assert resumed.status is RunStatus.SUCCEEDED + assert resumed.operation is operation + assert resumed.planned_steps == old.planned_steps + assert resumed.execution_parameters == old.execution_parameters + assert resumed.resume_source_failed_step == failed_step + assert operations.calls[0] == failed_step + assert not set(completed_calls).intersection(operations.calls[:1]) + assert operations.contexts[0]["replace_existing"] is True + assert operations.contexts[0]["restore_widgets"] is True + + +def test_resume_after_copy_files_does_not_copy_files_again() -> None: + state = FakeStateStore() + operations = FakeOperations(fail_at="snapshot_source_database") + app = service(operations=operations, state=state) + old = app.execute(Operation.MIGRATE, "parent", dry_run=False) + assert "copy_files" in operations.calls + operations.calls.clear() + operations.contexts.clear() + operations.fail_at = None + + app.resume("parent", old.run_id, dry_run=False) + + # A child installation can still have its own pending copy, but the completed parent copy + # is never replayed. The resumed call starts exactly at the failed parent snapshot. + assert operations.calls[0] == "snapshot_source_database" + parent_copy_contexts = [ + context + for name, context in zip(operations.calls, operations.contexts, strict=True) + if name == "copy_files" and context["planned_step"].installation_id == "parent" + ] + assert parent_copy_contexts == [] + + +def test_old_incomplete_manifest_is_rejected_instead_of_becoming_update() -> None: + state = FakeStateStore() + old = RunManifest( + "legacy", + "parent", + Operation.MIGRATE, + RunStatus.UPDATE_FAILED_PRESERVED, + "now", + False, + failed_step="copy_files", + ) + state.manifests[("parent", "legacy")] = old + + with pytest.raises(ResumeConsistencyError, match=r"informação suficiente.*resume seguro"): + service(state=state).resume("parent", "legacy", dry_run=False) diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index ee07b43..8f212dc 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -37,6 +37,16 @@ def test_migration_plan_round_trip_preserves_step_metadata(tmp_path: Path) -> No False, planned_steps=list(plan.steps), migration_plan=plan, + execution_parameters={"replace_existing": True, "restore_widgets": False}, + recovery_data={ + "parent": { + "source_endpoint": "production", + "source_database": "wordpress", + "target_endpoint": "test", + "target_database": "wordpress_test", + } + }, + original_run_id="run-id", ) store = JsonStateStore(tmp_path) @@ -45,6 +55,9 @@ def test_migration_plan_round_trip_preserves_step_metadata(tmp_path: Path) -> No assert loaded.migration_plan == plan assert loaded.planned_steps == list(plan.steps) + assert loaded.execution_parameters == manifest.execution_parameters + assert loaded.recovery_data == manifest.recovery_data + assert loaded.original_run_id == "run-id" parent_copy = next( step for step in loaded.planned_steps From 74f801a0c21ec57595e09e24f14aa96318b110f1 Mon Sep 17 00:00:00 2001 From: apendindan Date: Sun, 30 Aug 2026 21:18:39 -0300 Subject: [PATCH 4/6] feat(ssh): support secure password authentication --- .env.example | 2 +- config.example.yaml | 7 +- docs/architecture.md | 6 +- docs/configuration.md | 27 ++ docs/deployment-requirements.md | 19 +- docs/operations.md | 4 +- docs/security.md | 8 +- pyproject.toml | 11 +- src/wp_modernizer/application/service.py | 7 +- src/wp_modernizer/cli/main.py | 17 +- src/wp_modernizer/config/models.py | 3 + src/wp_modernizer/domain/errors.py | 20 ++ src/wp_modernizer/domain/planning.py | 4 +- .../infrastructure/runtime_operations.py | 21 +- .../infrastructure/ssh/__init__.py | 4 +- .../infrastructure/ssh/adapter.py | 5 +- .../infrastructure/ssh/password_adapter.py | 285 ++++++++++++++++++ .../infrastructure/ssh/router.py | 43 +++ tests/unit/test_adapters.py | 206 ++++++++++++- tests/unit/test_composition_root.py | 94 +++++- tests/unit/test_config.py | 20 ++ 21 files changed, 767 insertions(+), 46 deletions(-) create mode 100644 src/wp_modernizer/infrastructure/ssh/password_adapter.py create mode 100644 src/wp_modernizer/infrastructure/ssh/router.py diff --git a/.env.example b/.env.example index 0a8a917..009a22c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Copie para .env localmente. A aplicação lê as variáveis de ambiente do processo; # ela nunca grava segredos na configuração ou nos relatórios. PROD_EXAMPLE_USERNAME=replace-me -PROD_EXAMPLE_KEY_PASSPHRASE=replace-me +PROD_EXAMPLE_PASSWORD=replace-me TEST_DB_EXAMPLE_USERNAME=replace-me TEST_DB_EXAMPLE_PASSWORD=replace-me diff --git a/config.example.yaml b/config.example.yaml index 22930eb..bf7b8f7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -7,9 +7,11 @@ servers: port: 22 environment: production username_secret: PROD_EXAMPLE_USERNAME - authentication: key - private_key: /path/to/private/key + authentication: password + password_secret: PROD_EXAMPLE_PASSWORD host_key_policy: strict + # Opcional: arquivo adicional no formato OpenSSH. Sem esta opção, usa ~/.ssh/known_hosts. + # known_hosts_file: /etc/wp-modernizer/known_hosts databases: test-db-example: host: db-test.example.invalid @@ -38,4 +40,3 @@ observability: json_stdout: true log_file: null otel_enabled: false - diff --git a/docs/architecture.md b/docs/architecture.md index 24b6a1b..9bfac76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,10 +5,12 @@ modelos imutáveis, enums, invariantes, análise de caminhos, nomenclatura e pla importar APIs de processos externos. `application` contém os casos de uso e as portas `Protocol`. `pipeline` contém etapas independentes e o executor que preserva o estado em caso de falha. `infrastructure` fornece adaptadores de subprocessos, estado local, YAML/ambiente, MySQL, -SSH/rsync, WP-CLI, sistema de arquivos e Git. `cli` trata apenas da composição. +SSH/rsync por chave, SSH/SFTP por senha, WP-CLI, sistema de arquivos e Git. `cli` trata apenas da +composição. A composition root em `cli.main.build_service` liga a configuração ao -`EnvironmentSecretProvider`, cria os adaptadores SSH/MySQL/WP-CLI, injeta-os em +`EnvironmentSecretProvider`, cria os adaptadores SSH/MySQL/WP-CLI, injeta um roteador de +transporte que escolhe chave ou senha explicitamente em `RuntimeOperations` pelas portas da aplicação e, por fim, constrói `ModernizerService`. As dependências apontam para dentro. Objetos falsos implementam os mesmos `Protocol`s e permitem diff --git a/docs/configuration.md b/docs/configuration.md index b6d90d8..dddffad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,3 +9,30 @@ Cada instalação informa um servidor de origem, um ambiente de origem (`product um caminho absoluto de origem, um destino de TESTE absoluto e IDs permitidos de endpoints de banco de dados de teste. Apelidos e substituições exatas de bancos são explícitos. Por padrão, não é permitida a criação de bancos inexistentes. + +## Transporte SSH + +Cada servidor escolhe o transporte explicitamente em `authentication`. O cenário principal de +implantação usa `password`, com `username_secret` e `password_secret` apontando para entradas do +`SecretProvider`: + +```yaml +servers: + source-example: + host: source.example.org + port: 22 + environment: production + username_secret: PROD_EXAMPLE_USERNAME + authentication: password + password_secret: PROD_EXAMPLE_PASSWORD + host_key_policy: strict +``` + +O valor da senha não pertence ao YAML. O adapter SFTP obtém usuário e senha somente no momento da +conexão e os fornece à API do Paramiko, sem shell ou subprocesso. `authentication: key` continua +disponível com `private_key` e usa OpenSSH/rsync; os dois mecanismos são adapters separados. + +Com `host_key_policy: strict`, o transporte carrega o `~/.ssh/known_hosts` da conta que executa a +aplicação e rejeita chaves desconhecidas ou alteradas. `known_hosts_file` pode indicar um arquivo +OpenSSH adicional, por exemplo `/etc/wp-modernizer/known_hosts`. O arquivo deve ser provisionado +antes do preflight por um canal confiável. Não use `accept-new` em produção. diff --git a/docs/deployment-requirements.md b/docs/deployment-requirements.md index 0078f88..707a85b 100644 --- a/docs/deployment-requirements.md +++ b/docs/deployment-requirements.md @@ -12,5 +12,22 @@ | Quais pontos de controle do núcleo são aceitos por site? | compatibilidade controlada de atualização | lista ordenada de versões | apenas etapas genéricas configuradas são executadas | | Quais plugins gerenciados e qual política para árvore suja se aplicam? | evitar perder trabalho local | repositório público/acessível, branch e política | atualização gerenciada ignorada | | Onde o estado externo é mantido e copiado? | durabilidade da retomada e auditoria | diretório absoluto e política de retenção/criptografia | apenas estado local configurado | -| É necessária compatibilidade com senha no SSH? | escolha de adaptador/segurança | sim/não e mecanismo de transporte seguro | apenas autenticação por chave | +| Quais referências de usuário e senha SSH serão provisionadas? | autenticação do transporte SFTP | nomes das entradas no `SecretProvider`; nunca os valores | cópia remota indisponível | | Qual destino de telemetria e política de dados estão aprovados? | exportação OTLP opcional | endpoint, referências de ambiente para TLS/autenticação e retenção | apenas logs JSON locais | + +## Preflight SSH por senha + +Antes de liberar uma origem, confirme que: + +1. o DNS e a porta do servidor são alcançáveis pela conta de serviço; +2. `username_secret` e `password_secret` existem no `SecretProvider`; +3. a chave pública do host foi validada fora de banda e instalada em `~/.ssh/known_hosts` ou no + `known_hosts_file` configurado; +4. a entrada usa o formato `[host]:porta` quando a porta não é 22; +5. a conta possui leitura e travessia sobre toda a árvore de origem; +6. o destino local possui espaço e permissões para criar a cópia; +7. um teste em infraestrutura descartável confirma as exclusões do plano e os timeouts. + +Uma chave ausente ou diferente deve interromper o preflight. Não altere `host_key_policy: strict` +para resolver falhas de autenticação: confiança do host e credenciais do usuário são verificações +independentes. diff --git a/docs/operations.md b/docs/operations.md index 3d1d0e5..5b8cd38 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,7 +16,9 @@ adaptador. Uma simulação ainda sonda capacidades e grava seu manifesto externo que o trabalho proposto possa ser auditado. Intencionalmente, não existe comando de publicação em produção. -O adaptador público de execução delega cópias ao SSH/rsync, descoberta e transferência de bancos +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 +descoberta e transferência de bancos é delegada ao MySQL e operações WordPress ao WP-CLI. Uma migração de banco exige endpoints de origem e de TESTE permitidos e resolução não ambígua. Credenciais do `wp-config` são entregues ao WP-CLI por entrada padrão, e não por `argv`. A retenção de uma cópia de teste já existente continua falhando diff --git a/docs/security.md b/docs/security.md index ebf3800..84c2f27 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,9 +1,11 @@ # Modelo de segurança As fronteiras de segurança incluem destinos somente de TESTE, caminhos canônicos e endpoints em -listas de permissão, chaves de host SSH estritas por padrão, preferência por autenticação por -chave, execução de subprocessos por `argv` sem shell, limites de tempo, segredos apenas no -ambiente, ocultação centralizada de saída/`argv` e estado externo de execução. Dumps de bancos de +listas de permissão, chaves de host SSH estritas por padrão, adapters separados para autenticação +por chave e senha, execução de subprocessos por `argv` sem shell, limites de tempo, segredos +apenas no provedor, ocultação centralizada de saída/`argv` e estado externo de execução. O adapter +SFTP entrega a senha diretamente à API Paramiko em memória; não usa `sshpass`, shell, `expect`, +variável de subprocesso ou linha de comando. Dumps de bancos de dados e arquivos compactados são ignorados e exigem criptografia/retenção no nível da implantação. Antes da publicação, execute a varredura de segredos/topologia documentada em `development.md`, diff --git a/pyproject.toml b/pyproject.toml index 7b4d723..e9ada08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,12 @@ readme = "README.md" requires-python = ">=3.10" license = {text = "Seleção de licença pendente de aprovação organizacional"} authors = [{name = "Colaboradores do wp-modernizer"}] -dependencies = ["click>=8.1,<9", "pydantic>=2,<3", "PyYAML>=6,<7"] +dependencies = [ + "click>=8.1,<9", + "paramiko>=3.4,<5", + "pydantic>=2,<3", + "PyYAML>=6,<7", +] [project.optional-dependencies] otel = ["opentelemetry-api>=1.27", "opentelemetry-sdk>=1.27", "opentelemetry-exporter-otlp>=1.27"] @@ -58,3 +63,7 @@ mypy_path = "src" [[tool.mypy.overrides]] module = "yaml" ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["paramiko", "paramiko.*"] +ignore_missing_imports = true diff --git a/src/wp_modernizer/application/service.py b/src/wp_modernizer/application/service.py index 3128ee7..ad77f70 100644 --- a/src/wp_modernizer/application/service.py +++ b/src/wp_modernizer/application/service.py @@ -197,9 +197,7 @@ def resume(self, installation_id: str, run_id: str, dry_run: bool) -> RunManifes dry_run, steps=list(old.steps[:completed_count]), pending_operations=list(old.pending_operations), - last_successful_step=( - old.steps[completed_count - 1].name if completed_count else None - ), + last_successful_step=(old.steps[completed_count - 1].name if completed_count else None), widget_diff=list(old.widget_diff), planned_steps=list(original_steps), migration_plan=old.migration_plan, @@ -263,8 +261,7 @@ def _completed_prefix(old: RunManifest, planned_steps: list[PlannedStep]) -> int expected = (planned.installation_id or old.installation_id, planned.name) if identity != expected: raise ResumeConsistencyError( - "O histórico de steps não corresponde ao plano original; " - "resume seguro recusado" + "O histórico de steps não corresponde ao plano original; resume seguro recusado" ) if result.status is StepStatus.SUCCEEDED: if encountered_incomplete: diff --git a/src/wp_modernizer/cli/main.py b/src/wp_modernizer/cli/main.py index 081190c..58f3bb7 100644 --- a/src/wp_modernizer/cli/main.py +++ b/src/wp_modernizer/cli/main.py @@ -4,7 +4,7 @@ import sys from dataclasses import asdict from pathlib import Path -from typing import Any +from typing import Any, Callable import click @@ -21,7 +21,11 @@ from wp_modernizer.infrastructure.mysql.adapter import MySQLAdapter from wp_modernizer.infrastructure.runtime_operations import RuntimeOperations from wp_modernizer.infrastructure.secrets import EnvironmentSecretProvider -from wp_modernizer.infrastructure.ssh.adapter import RSyncSSHAdapter +from wp_modernizer.infrastructure.ssh import ( + FileTransferRouter, + PasswordSFTPAdapter, + RSyncSSHAdapter, +) from wp_modernizer.infrastructure.state import JsonStateStore from wp_modernizer.infrastructure.time import SystemClock, UUIDGenerator from wp_modernizer.infrastructure.wpcli.adapter import WPCLIAdapter @@ -32,12 +36,19 @@ def build_service( *, runner: CommandRunner | None = None, secrets: SecretProvider | None = None, + ssh_client_factory: Callable[[], Any] | None = None, ) -> ModernizerService: """Composition root da aplicação; dependências opcionais mantêm os testes sem subprocessos.""" command_runner = runner or SubprocessCommandRunner() secret_provider = secrets or EnvironmentSecretProvider() filesystem = LocalFileSystem() - ssh = RSyncSSHAdapter(config.servers, secret_provider, command_runner) + key_transport = RSyncSSHAdapter(config.servers, secret_provider, command_runner) + password_transport = ( + PasswordSFTPAdapter(config.servers, secret_provider, client_factory=ssh_client_factory) + if ssh_client_factory is not None + else PasswordSFTPAdapter(config.servers, secret_provider) + ) + ssh = FileTransferRouter(config.servers, key_transport, password_transport) mysql = MySQLAdapter(config.databases, secret_provider, command_runner) wpcli = WPCLIAdapter(command_runner) operations = RuntimeOperations( diff --git a/src/wp_modernizer/config/models.py b/src/wp_modernizer/config/models.py index 8505d5c..44b7202 100644 --- a/src/wp_modernizer/config/models.py +++ b/src/wp_modernizer/config/models.py @@ -15,11 +15,14 @@ class ServerConfig(BaseModel): private_key: Optional[Path] = None password_secret: Optional[str] = None host_key_policy: Literal["strict", "accept-new"] = "strict" + known_hosts_file: Optional[Path] = None @model_validator(mode="after") def password_required_for_compatibility(self) -> "ServerConfig": if self.authentication == "password" and not self.password_secret: raise ValueError("a autenticação por senha requer password_secret") + if self.authentication == "password" and self.private_key is not None: + raise ValueError("a autenticação por senha não utiliza private_key") return self diff --git a/src/wp_modernizer/domain/errors.py b/src/wp_modernizer/domain/errors.py index 43683e3..5cd79db 100644 --- a/src/wp_modernizer/domain/errors.py +++ b/src/wp_modernizer/domain/errors.py @@ -18,6 +18,26 @@ class AuthenticationError(InfrastructureError): pass +class PasswordAuthenticationError(AuthenticationError): + """A autenticação por senha foi recusada sem divulgar a credencial.""" + + +class AuthenticationRefusedError(AuthenticationError): + """O servidor não permite o método de autenticação solicitado.""" + + +class HostKeyVerificationError(InfrastructureError): + """A identidade SSH do host não pertence ao conjunto confiável.""" + + +class RemoteHostUnreachableError(InfrastructureError): + """Não foi possível alcançar o endpoint remoto.""" + + +class TransferError(InfrastructureError): + """A sessão autenticou, mas a transferência falhou.""" + + class DiagnosticError(ModernizerError): pass diff --git a/src/wp_modernizer/domain/planning.py b/src/wp_modernizer/domain/planning.py index 8f99df7..7ef3744 100644 --- a/src/wp_modernizer/domain/planning.py +++ b/src/wp_modernizer/domain/planning.py @@ -43,7 +43,9 @@ def build( mutable=True, idempotent=True, completion_probe="os manifestos de cópia da origem e do destino coincidem", - partial_recovery="o rsync retoma; raízes aninhadas permanecem excluídas", + partial_recovery=( + "repetir a cópia idempotente; raízes aninhadas permanecem excluídas" + ), installation_id=node.installation_id, excludes=(*descendants, Path("*.sql"), Path(".wp-modernizer")), ) diff --git a/src/wp_modernizer/infrastructure/runtime_operations.py b/src/wp_modernizer/infrastructure/runtime_operations.py index 0b8ddc1..128893e 100644 --- a/src/wp_modernizer/infrastructure/runtime_operations.py +++ b/src/wp_modernizer/infrastructure/runtime_operations.py @@ -7,7 +7,7 @@ from wp_modernizer.application.ports import DatabasePort, FileTransferPort, WordPressPort from wp_modernizer.domain.database import DatabaseLocator, SuffixDatabaseNamingStrategy from wp_modernizer.domain.enums import Environment, PendingOperationType, StepStatus -from wp_modernizer.domain.errors import UnsafeOperationError +from wp_modernizer.domain.errors import InfrastructureError, UnsafeOperationError from wp_modernizer.domain.models import PlannedStep, StepResult from wp_modernizer.domain.path_parser import InstallationPathParser @@ -78,18 +78,21 @@ def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: server = self._files.get_server(installation.source_server) if server.environment is not installation.source_environment: raise UnsafeOperationError("O ambiente do servidor não coincide com o da origem") - elapsed = self._files.copy_from( - installation.source_server, - Path(installation.source_path), - path.parent, - planned_step.excludes, - run_id, - ) + try: + elapsed = self._files.copy_from( + installation.source_server, + Path(installation.source_path), + path.parent, + planned_step.excludes, + run_id, + ) + except InfrastructureError as exc: + return self._failed(step_name, str(exc)) return StepResult( step_name, StepStatus.SUCCEEDED, True, - "arquivos copiados pelo adaptador SSH/rsync", + "arquivos copiados pelo transporte SSH configurado", {"duration_seconds": float(elapsed)}, ) diff --git a/src/wp_modernizer/infrastructure/ssh/__init__.py b/src/wp_modernizer/infrastructure/ssh/__init__.py index 988676a..6d6457b 100644 --- a/src/wp_modernizer/infrastructure/ssh/__init__.py +++ b/src/wp_modernizer/infrastructure/ssh/__init__.py @@ -1,3 +1,5 @@ from .adapter import RSyncSSHAdapter +from .password_adapter import PasswordSFTPAdapter +from .router import FileTransferRouter -__all__ = ["RSyncSSHAdapter"] +__all__ = ["FileTransferRouter", "PasswordSFTPAdapter", "RSyncSSHAdapter"] diff --git a/src/wp_modernizer/infrastructure/ssh/adapter.py b/src/wp_modernizer/infrastructure/ssh/adapter.py index a5ba71d..f2cda6a 100644 --- a/src/wp_modernizer/infrastructure/ssh/adapter.py +++ b/src/wp_modernizer/infrastructure/ssh/adapter.py @@ -34,10 +34,9 @@ def copy_from( run_id: str, ) -> int: server = self.get_server(server_id) - if server.authentication == "password": + if server.authentication != "key": raise ConfigurationError( - "SSH com senha requer um adaptador separado, sem linha de comando e revisado; " - "autenticação por chave é o padrão público" + "O adaptador SSH/rsync aceita apenas servidores com autenticação por chave" ) # O usuário vem de SecretProvider e, por isso, não pode fazer parte de argv. Um arquivo # efêmero 0600 é entendido diretamente pelo ssh e removido mesmo quando o rsync falha. diff --git a/src/wp_modernizer/infrastructure/ssh/password_adapter.py b/src/wp_modernizer/infrastructure/ssh/password_adapter.py new file mode 100644 index 0000000..99c3618 --- /dev/null +++ b/src/wp_modernizer/infrastructure/ssh/password_adapter.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import fnmatch +import os +import socket +import stat +import time +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Dict, Iterable, Sequence + +import paramiko + +from wp_modernizer.application.ports import SecretProvider +from wp_modernizer.config.models import ServerConfig +from wp_modernizer.domain.errors import ( + AuthenticationRefusedError, + CommandTimeoutError, + ConfigurationError, + HostKeyVerificationError, + PasswordAuthenticationError, + RemoteHostUnreachableError, + TransferError, +) + + +class _RejectUnknownHostKey: + def missing_host_key(self, client: Any, hostname: str, key: Any) -> None: + del client, hostname, key + raise HostKeyVerificationError( + "A chave do host SSH não consta nos arquivos known_hosts confiáveis" + ) + + +class PasswordSFTPAdapter: + """Copia árvores por SFTP; a senha é entregue somente à API SSH em memória.""" + + CONNECT_TIMEOUT_SECONDS = 30.0 + TRANSFER_TIMEOUT_SECONDS = 1800.0 + + def __init__( + self, + servers: Dict[str, ServerConfig], + secrets: SecretProvider, + *, + client_factory: Callable[[], Any] = paramiko.SSHClient, + ) -> None: + self._servers = servers + self._secrets = secrets + self._client_factory = client_factory + + def get_server(self, server_id: str) -> ServerConfig: + try: + return self._servers[server_id] + except KeyError as exc: + raise ConfigurationError(f"Servidor SSH desconhecido: {server_id}") from exc + + def copy_from( + self, + server_id: str, + source: Path, + destination_parent: Path, + excludes: Sequence[Path], + run_id: str, + ) -> int: + del run_id # correlação pertence ao chamador; credenciais nunca entram em relatórios + server = self.get_server(server_id) + if server.authentication != "password" or server.password_secret is None: + raise ConfigurationError( + "O adaptador SFTP por senha aceita apenas servidores configurados com password" + ) + remote_source = PurePosixPath(str(source)) + if not remote_source.is_absolute() or not remote_source.name: + raise ConfigurationError("O caminho remoto de origem deve ser absoluto e nomeado") + + username = self._secrets.get(server.username_secret) + password = self._secrets.get(server.password_secret) + client = self._client_factory() + started = time.monotonic() + try: + self._configure_host_verification(client, server) + self._connect(client, server, username, password) + self._transfer( + client, + remote_source, + destination_parent, + self._normalize_excludes(remote_source, excludes), + started, + ) + finally: + client.close() + return int(time.monotonic() - started) + + @staticmethod + def _configure_host_verification(client: Any, server: ServerConfig) -> None: + try: + client.load_system_host_keys() + if server.known_hosts_file is not None: + client.load_host_keys(str(server.known_hosts_file)) + except OSError as exc: + raise ConfigurationError( + "Não foi possível carregar o arquivo known_hosts configurado" + ) from exc + if server.host_key_policy == "strict": + client.set_missing_host_key_policy(_RejectUnknownHostKey()) + else: + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + def _connect(self, client: Any, server: ServerConfig, username: str, password: str) -> None: + try: + client.connect( + hostname=server.host, + port=server.port, + username=username, + password=password, + timeout=self.CONNECT_TIMEOUT_SECONDS, + banner_timeout=self.CONNECT_TIMEOUT_SECONDS, + auth_timeout=self.CONNECT_TIMEOUT_SECONDS, + allow_agent=False, + look_for_keys=False, + ) + except HostKeyVerificationError: + raise + except paramiko.BadHostKeyException as exc: + raise HostKeyVerificationError( + "A chave do host SSH mudou ou não corresponde à identidade confiável" + ) from exc + except paramiko.BadAuthenticationType: + raise AuthenticationRefusedError( + "O servidor SSH recusou o método de autenticação por senha" + ) from None + except paramiko.AuthenticationException: + # O protocolo normalmente não revela se o usuário ou a senha estava incorreto. + raise PasswordAuthenticationError( + "Autenticação SSH recusada; verifique o usuário e a senha configurados" + ) from None + except (socket.timeout, TimeoutError) as exc: + raise CommandTimeoutError( + f"A conexão SSH excedeu o limite de {self.CONNECT_TIMEOUT_SECONDS:g}s" + ) from exc + except (paramiko.SSHException, OSError) as exc: + raise RemoteHostUnreachableError( + "Não foi possível alcançar ou negociar uma sessão com o host SSH" + ) from exc + + def _transfer( + self, + client: Any, + remote_source: PurePosixPath, + destination_parent: Path, + excludes: tuple[str, ...], + started: float, + ) -> None: + sftp = None + try: + sftp = client.open_sftp() + sftp.get_channel().settimeout(self.TRANSFER_TIMEOUT_SECONDS) + destination_parent.mkdir(parents=True, exist_ok=True) + self._copy_entry( + sftp, + remote_source, + destination_parent / remote_source.name, + PurePosixPath("."), + excludes, + started, + ) + except (socket.timeout, TimeoutError) as exc: + raise CommandTimeoutError( + f"A transferência SFTP excedeu o limite de {self.TRANSFER_TIMEOUT_SECONDS:g}s" + ) from exc + except CommandTimeoutError: + raise + except (OSError, paramiko.SSHException) as exc: + raise TransferError( + "A transferência SFTP falhou; consulte o diagnóstico seguro" + ) from exc + finally: + if sftp is not None: + sftp.close() + + def _copy_entry( + self, + sftp: Any, + remote: PurePosixPath, + local: Path, + relative: PurePosixPath, + excludes: tuple[str, ...], + started: float, + ) -> None: + self._assert_within_timeout(started) + if relative != PurePosixPath(".") and self._is_excluded(relative, excludes): + return + attributes = sftp.lstat(remote.as_posix()) + mode = attributes.st_mode + if stat.S_ISDIR(mode): + if local.exists() and (not local.is_dir() or local.is_symlink()): + raise OSError("o destino local conflita com um diretório remoto") + local.mkdir(parents=True, exist_ok=True) + for child in sftp.listdir_attr(remote.as_posix()): + self._validate_entry_name(child.filename) + child_relative = ( + PurePosixPath(child.filename) + if relative == PurePosixPath(".") + else relative / child.filename + ) + self._copy_entry( + sftp, + remote / child.filename, + local / child.filename, + child_relative, + excludes, + started, + ) + self._preserve_metadata(local, attributes) + return + if stat.S_ISREG(mode): + if local.is_symlink(): + raise OSError("o destino local contém um link simbólico inseguro") + if local.exists() and local.is_dir(): + raise OSError("o destino local conflita com um arquivo remoto") + sftp.get( + remote.as_posix(), + str(local), + callback=lambda transferred, total: self._transfer_progress( + transferred, total, started + ), + ) + self._preserve_metadata(local, attributes) + return + if stat.S_ISLNK(mode): + target = sftp.readlink(remote.as_posix()) + if local.exists() or local.is_symlink(): + local.unlink() + local.symlink_to(target) + return + raise OSError("a origem contém um tipo de arquivo SFTP não suportado") + + def _assert_within_timeout(self, started: float) -> None: + if time.monotonic() - started > self.TRANSFER_TIMEOUT_SECONDS: + raise CommandTimeoutError( + f"A transferência SFTP excedeu o limite de {self.TRANSFER_TIMEOUT_SECONDS:g}s" + ) + + def _transfer_progress(self, transferred: int, total: int, started: float) -> None: + del transferred, total + self._assert_within_timeout(started) + + @staticmethod + def _normalize_excludes(source: PurePosixPath, excludes: Iterable[Path]) -> tuple[str, ...]: + normalized = [] + for item in excludes: + remote = PurePosixPath(str(item)) + if remote.is_absolute(): + try: + remote = remote.relative_to(source) + except ValueError: + continue + pattern = remote.as_posix().lstrip("./") + if pattern and pattern != ".." and not pattern.startswith("../"): + normalized.append(pattern.rstrip("/")) + return tuple(normalized) + + @staticmethod + def _is_excluded(relative: PurePosixPath, excludes: tuple[str, ...]) -> bool: + value = relative.as_posix() + for pattern in excludes: + if not any(character in pattern for character in "*?["): + if value == pattern or value.startswith(pattern + "/"): + return True + if "/" not in pattern and relative.name == pattern: + return True + elif fnmatch.fnmatchcase(value, pattern) or fnmatch.fnmatchcase(relative.name, pattern): + return True + return False + + @staticmethod + def _validate_entry_name(name: str) -> None: + if name in {"", ".", ".."} or "/" in name or "\x00" in name: + raise OSError("a origem SFTP retornou um nome de arquivo inseguro") + + @staticmethod + def _preserve_metadata(path: Path, attributes: Any) -> None: + path.chmod(stat.S_IMODE(attributes.st_mode)) + path.touch(exist_ok=True) + if attributes.st_atime is not None and attributes.st_mtime is not None: + os.utime(path, (attributes.st_atime, attributes.st_mtime), follow_symlinks=False) diff --git a/src/wp_modernizer/infrastructure/ssh/router.py b/src/wp_modernizer/infrastructure/ssh/router.py new file mode 100644 index 0000000..1c65040 --- /dev/null +++ b/src/wp_modernizer/infrastructure/ssh/router.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Sequence + +from wp_modernizer.application.ports import FileTransferPort +from wp_modernizer.config.models import ServerConfig +from wp_modernizer.domain.errors import ConfigurationError + + +class FileTransferRouter: + """Seleciona explicitamente o transporte correspondente ao método de autenticação.""" + + def __init__( + self, + servers: Dict[str, ServerConfig], + key_transport: FileTransferPort, + password_transport: FileTransferPort, + ) -> None: + self._servers = servers + self._transports = { + "key": key_transport, + "password": password_transport, + } + + def get_server(self, server_id: str) -> ServerConfig: + try: + return self._servers[server_id] + except KeyError as exc: + raise ConfigurationError(f"Servidor SSH desconhecido: {server_id}") from exc + + def copy_from( + self, + server_id: str, + source: Path, + destination_parent: Path, + excludes: Sequence[Path], + run_id: str, + ) -> int: + server = self.get_server(server_id) + return self._transports[server.authentication].copy_from( + server_id, source, destination_parent, excludes, run_id + ) diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index 4d28b4d..b0bfec6 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -1,5 +1,10 @@ -from pathlib import Path +import socket +import stat +from pathlib import Path, PurePosixPath +from types import SimpleNamespace +from typing import Any +import paramiko import pytest from tests.fakes.core import FakeCommandResult, FakeCommandRunner @@ -7,22 +12,112 @@ from wp_modernizer.domain.enums import Environment from wp_modernizer.domain.errors import ( AuthenticationError, + AuthenticationRefusedError, + CommandTimeoutError, ConfigurationError, + HostKeyVerificationError, InfrastructureError, + PasswordAuthenticationError, + RemoteHostUnreachableError, + TransferError, UnsafeOperationError, ) from wp_modernizer.infrastructure.filesystem import LocalFileSystem from wp_modernizer.infrastructure.mysql.adapter import MySQLAdapter from wp_modernizer.infrastructure.secrets import EnvironmentSecretProvider from wp_modernizer.infrastructure.ssh.adapter import RSyncSSHAdapter +from wp_modernizer.infrastructure.ssh.password_adapter import PasswordSFTPAdapter +from wp_modernizer.infrastructure.ssh.router import FileTransferRouter from wp_modernizer.infrastructure.wpcli.adapter import WPCLIAdapter class Secrets: + def __init__(self) -> None: + self.calls = [] + def get(self, reference: str) -> str: + self.calls.append(reference) return {"USER": "user", "PASS": "password"}[reference] +class FakeSFTP: + def __init__(self) -> None: + directory = stat.S_IFDIR | 0o750 + regular = stat.S_IFREG | 0o640 + self.nodes = { + "/source": SimpleNamespace(st_mode=directory, st_atime=1, st_mtime=2), + "/source/keep.txt": SimpleNamespace(st_mode=regular, st_atime=1, st_mtime=2), + "/source/skip.sql": SimpleNamespace(st_mode=regular, st_atime=1, st_mtime=2), + "/source/nested": SimpleNamespace(st_mode=directory, st_atime=1, st_mtime=2), + "/source/nested/inside.txt": SimpleNamespace(st_mode=regular, st_atime=1, st_mtime=2), + } + self.files = { + "/source/keep.txt": b"keep", + "/source/skip.sql": b"skip", + "/source/nested/inside.txt": b"nested", + } + self.timeout = None + self.closed = False + + def get_channel(self) -> "FakeSFTP": + return self + + def settimeout(self, timeout: float) -> None: + self.timeout = timeout + + def lstat(self, path: str) -> Any: + try: + return self.nodes[path] + except KeyError as exc: + raise OSError("missing remote entry") from exc + + def listdir_attr(self, path: str) -> list[Any]: + root = PurePosixPath(path) + names = { + PurePosixPath(item).name for item in self.nodes if PurePosixPath(item).parent == root + } + return [SimpleNamespace(filename=name) for name in sorted(names)] + + def get(self, remote: str, local: str, callback: Any = None) -> None: + Path(local).write_bytes(self.files[remote]) + if callback is not None: + callback(len(self.files[remote]), len(self.files[remote])) + + def close(self) -> None: + self.closed = True + + +class FakeSSHClient: + def __init__(self, *, connect_error: Exception | None = None) -> None: + self.connect_error = connect_error + self.sftp = FakeSFTP() + self.connect_kwargs: dict[str, Any] = {} + self.policy = None + self.loaded_system_keys = False + self.loaded_host_keys = [] + self.closed = False + + def load_system_host_keys(self) -> None: + self.loaded_system_keys = True + + def load_host_keys(self, filename: str) -> None: + self.loaded_host_keys.append(filename) + + def set_missing_host_key_policy(self, policy: Any) -> None: + self.policy = policy + + def connect(self, **kwargs: Any) -> None: + self.connect_kwargs = kwargs + if self.connect_error is not None: + raise self.connect_error + + def open_sftp(self) -> FakeSFTP: + return self.sftp + + def close(self) -> None: + self.closed = True + + def database() -> DatabaseConfig: return DatabaseConfig(host="db.example.invalid", username_secret="USER", password_secret="PASS") @@ -95,7 +190,7 @@ def test_wpcli_writes_config_values_via_stdin_not_argv() -> None: assert "never-in-argv" not in runner.calls[0] -def test_ssh_is_key_first_and_password_adapter_is_refused() -> None: +def test_key_ssh_adapter_continues_to_use_rsync_without_credentials_in_argv() -> None: key = ServerConfig( host="source.example.invalid", environment=Environment.PRODUCTION, @@ -107,11 +202,108 @@ def test_ssh_is_key_first_and_password_adapter_is_refused() -> None: "s", Path("/source"), Path("/target"), [], "r" ) assert runner.calls[0][0] == "rsync" - password = key.model_copy(update={"authentication": "password", "password_secret": "PASS"}) - with pytest.raises(ConfigurationError, match="SSH com senha"): - RSyncSSHAdapter({"s": password}, Secrets(), runner).copy_from( - "s", Path("/source"), Path("/target"), [], "r" - ) + assert "user" not in runner.calls[0] + + +def password_server(**updates: Any) -> ServerConfig: + values = { + "host": "source.example.invalid", + "environment": Environment.PRODUCTION, + "username_secret": "USER", + "authentication": "password", + "password_secret": "PASS", + "host_key_policy": "strict", + } + values.update(updates) + return ServerConfig(**values) + + +def test_password_sftp_resolves_secrets_by_api_and_copies_with_exclusions( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + client = FakeSSHClient() + secrets = Secrets() + adapter = PasswordSFTPAdapter( + {"s": password_server(known_hosts_file=tmp_path / "known_hosts")}, + secrets, + client_factory=lambda: client, + ) + + adapter.copy_from( + "s", + Path("/source"), + tmp_path, + [Path("*.sql"), Path("/source/nested")], + "run-1", + ) + + assert secrets.calls == ["USER", "PASS"] + assert client.connect_kwargs["username"] == "user" + assert client.connect_kwargs["password"] == "password" + assert client.connect_kwargs["allow_agent"] is False + assert client.connect_kwargs["look_for_keys"] is False + assert client.loaded_system_keys is True + assert client.loaded_host_keys == [str(tmp_path / "known_hosts")] + assert client.policy.__class__.__name__ == "_RejectUnknownHostKey" + assert (tmp_path / "source/keep.txt").read_bytes() == b"keep" + assert not (tmp_path / "source/skip.sql").exists() + assert not (tmp_path / "source/nested").exists() + assert "password" not in caplog.text + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (paramiko.AuthenticationException("password"), PasswordAuthenticationError), + ( + paramiko.BadAuthenticationType("refused", ["publickey"]), + AuthenticationRefusedError, + ), + (socket.timeout(), CommandTimeoutError), + (HostKeyVerificationError("unknown"), HostKeyVerificationError), + (OSError("network unreachable"), RemoteHostUnreachableError), + ], +) +def test_password_sftp_reports_connection_failures_without_secret( + tmp_path: Path, error: Exception, expected: type[Exception] +) -> None: + client = FakeSSHClient(connect_error=error) + adapter = PasswordSFTPAdapter( + {"s": password_server()}, Secrets(), client_factory=lambda: client + ) + with pytest.raises(expected) as raised: + adapter.copy_from("s", Path("/source"), tmp_path, [], "run-1") + assert "password" not in str(raised.value).lower() + + +def test_password_sftp_reports_transfer_failure(tmp_path: Path) -> None: + client = FakeSSHClient() + client.sftp.nodes.pop("/source") + adapter = PasswordSFTPAdapter( + {"s": password_server()}, Secrets(), client_factory=lambda: client + ) + with pytest.raises(TransferError, match="transferência SFTP"): + adapter.copy_from("s", Path("/source"), tmp_path, [], "run-1") + + +def test_file_transfer_router_selects_authentication_explicitly(tmp_path: Path) -> None: + key = password_server().model_copy(update={"authentication": "key", "password_secret": None}) + password = password_server() + runner = FakeCommandRunner() + client = FakeSSHClient() + key_transport = RSyncSSHAdapter({"key": key, "password": password}, Secrets(), runner) + password_transport = PasswordSFTPAdapter( + {"key": key, "password": password}, Secrets(), client_factory=lambda: client + ) + router = FileTransferRouter( + {"key": key, "password": password}, key_transport, password_transport + ) + + router.copy_from("key", Path("/source"), tmp_path, [], "run-key") + router.copy_from("password", Path("/source"), tmp_path, [], "run-password") + + assert runner.calls[0][0] == "rsync" + assert client.connect_kwargs["password"] == "password" def test_local_filesystem_fingerprint_changes_and_remove(tmp_path: Path) -> None: diff --git a/tests/unit/test_composition_root.py b/tests/unit/test_composition_root.py index b50f274..08ca007 100644 --- a/tests/unit/test_composition_root.py +++ b/tests/unit/test_composition_root.py @@ -1,14 +1,24 @@ from pathlib import Path +from typing import Any -from tests.fakes.core import FakeCommandRunner +import paramiko + +from tests.fakes.core import FakeClock, FakeCommandRunner, FakeFileSystem, FakeProbe, health from wp_modernizer.cli.main import build_service from wp_modernizer.config.models import ApplicationConfig -from wp_modernizer.domain.enums import StepStatus -from wp_modernizer.domain.models import PlannedStep +from wp_modernizer.domain.enums import HealthStatus, Operation, RunStatus, StepStatus +from wp_modernizer.domain.models import PlannedStep, RunManifest from wp_modernizer.infrastructure.mysql.adapter import MySQLAdapter from wp_modernizer.infrastructure.runtime_operations import RuntimeOperations -from wp_modernizer.infrastructure.ssh.adapter import RSyncSSHAdapter +from wp_modernizer.infrastructure.ssh import ( + FileTransferRouter, + PasswordSFTPAdapter, + RSyncSSHAdapter, +) +from wp_modernizer.infrastructure.state import JsonStateStore from wp_modernizer.infrastructure.wpcli.adapter import WPCLIAdapter +from wp_modernizer.pipeline.runner import PipelineRunner +from wp_modernizer.pipeline.steps import OperationStep class RecordingSecrets: @@ -19,11 +29,30 @@ def get(self, reference: str) -> str: self.calls.append(reference) return { "SSH_USER": "ssh-user-must-not-leak", + "SSH_PASSWORD": "ssh-password-must-not-leak", "DB_USER": "db-user-must-not-leak", "DB_PASSWORD": "db-password-must-not-leak", }[reference] +class RejectingSSHClient: + def load_system_host_keys(self) -> None: + pass + + def load_host_keys(self, filename: str) -> None: + del filename + + def set_missing_host_key_policy(self, policy: Any) -> None: + del policy + + def connect(self, **kwargs: Any) -> None: + del kwargs + raise paramiko.AuthenticationException("ssh-password-must-not-leak") + + def close(self) -> None: + pass + + def configured(tmp_path: Path) -> ApplicationConfig: return ApplicationConfig.model_validate( { @@ -71,7 +100,9 @@ def test_composition_root_wires_config_secrets_and_all_runtime_adapters(tmp_path operations = service._operations assert isinstance(operations, RuntimeOperations) - assert isinstance(operations._files, RSyncSSHAdapter) + assert isinstance(operations._files, FileTransferRouter) + assert isinstance(operations._files._transports["key"], RSyncSSHAdapter) + assert isinstance(operations._files._transports["password"], PasswordSFTPAdapter) assert operations._files.get_server("source") is config.servers["source"] assert isinstance(operations._databases, MySQLAdapter) assert operations._databases.get_database("test-db") is config.databases["test-db"] @@ -100,3 +131,56 @@ def test_composition_root_wires_config_secrets_and_all_runtime_adapters(tmp_path wp_result = operations.execute("core_update", context) assert wp_result.status is StepStatus.SUCCEEDED assert runner.calls[-1][-2:] == ("core", "update") + + +def test_composition_root_routes_password_server_to_sftp(tmp_path: Path) -> None: + config = configured(tmp_path) + config.servers["source"] = config.servers["source"].model_copy( + update={"authentication": "password", "password_secret": "SSH_PASSWORD"} + ) + service = build_service(config, runner=FakeCommandRunner(), secrets=RecordingSecrets()) + files = service._operations._files + + assert isinstance(files, FileTransferRouter) + assert files.get_server("source").authentication == "password" + assert isinstance(files._transports["password"], PasswordSFTPAdapter) + + +def test_password_authentication_failure_becomes_safe_failed_step(tmp_path: Path) -> None: + config = configured(tmp_path) + config.servers["source"] = config.servers["source"].model_copy( + update={"authentication": "password", "password_secret": "SSH_PASSWORD"} + ) + service = build_service( + config, + runner=FakeCommandRunner(), + secrets=RecordingSecrets(), + ssh_client_factory=RejectingSSHClient, + ) + operations = service._operations + context = { + "run_id": "run-1", + "installation_id": "site", + "installation": config.installations["site"], + "installations": config.installations, + "planned_step": planned("copy_files"), + } + + result = operations.execute("copy_files", context) + + assert result.status is StepStatus.FAILED + assert "ssh-password-must-not-leak" not in result.message + manifest = RunManifest("run-1", "site", Operation.MIGRATE, RunStatus.RUNNING, "now", False) + store = JsonStateStore(tmp_path / "state-check") + preserved = PipelineRunner( + FakeProbe([health(HealthStatus.HEALTHY)]), store, FakeFileSystem(), FakeClock() + ).run( + manifest, + config.installations["site"].destination_path, + [OperationStep("copy_files", operations)], + context, + ) + + assert preserved.status is RunStatus.UPDATE_FAILED_PRESERVED + persisted = "".join(path.read_text() for path in (tmp_path / "state-check").rglob("*.json")) + assert "ssh-password-must-not-leak" not in persisted diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index aa02eeb..6720dbb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -70,3 +70,23 @@ def test_password_authentication_requires_password_secret() -> None: username_secret="USER", authentication="password", ) + + +def test_password_authentication_accepts_secret_reference_and_rejects_private_key() -> None: + server = ServerConfig( + host="source.example.invalid", + environment="production", + username_secret="USER", + authentication="password", + password_secret="PASSWORD", + ) + assert server.password_secret == "PASSWORD" + with pytest.raises(ValidationError, match="não utiliza private_key"): + ServerConfig( + host="source.example.invalid", + environment="production", + username_secret="USER", + authentication="password", + password_secret="PASSWORD", + private_key="/key", + ) From d3077cc62094dcf199defbb277c37b04f0a1412b Mon Sep 17 00:00:00 2001 From: apendindan Date: Sun, 30 Aug 2026 21:26:16 -0300 Subject: [PATCH 5/6] fix(diagnostics): probe database availability independently --- src/wp_modernizer/application/ports.py | 11 +- src/wp_modernizer/cli/main.py | 18 ++- src/wp_modernizer/diagnostics/capability.py | 72 +++++++++-- src/wp_modernizer/domain/enums.py | 9 ++ src/wp_modernizer/domain/models.py | 11 ++ .../infrastructure/mysql/adapter.py | 67 ++++++++++- tests/unit/test_adapters.py | 39 +++++- tests/unit/test_capabilities.py | 112 +++++++++++++++++- tests/unit/test_composition_root.py | 4 + 9 files changed, 320 insertions(+), 23 deletions(-) diff --git a/src/wp_modernizer/application/ports.py b/src/wp_modernizer/application/ports.py index 432eb36..872878d 100644 --- a/src/wp_modernizer/application/ports.py +++ b/src/wp_modernizer/application/ports.py @@ -4,7 +4,12 @@ from pathlib import Path from typing import Any, Dict, Mapping, Optional, Protocol, Sequence, Set, Tuple -from wp_modernizer.domain.models import CapabilityReport, RunManifest, StepResult +from wp_modernizer.domain.models import ( + CapabilityReport, + DatabaseProbeResult, + RunManifest, + StepResult, +) from wp_modernizer.domain.widgets import WidgetSnapshot @@ -22,6 +27,10 @@ def get_database(self, endpoint_id: str) -> Any: ... def list_schemas(self, endpoint_id: str) -> Set[str]: ... +class DatabaseProbePort(Protocol): + def probe_database(self, endpoint_id: str, database: str) -> DatabaseProbeResult: ... + + class FileTransferPort(ServerRegistry, Protocol): def copy_from( self, diff --git a/src/wp_modernizer/cli/main.py b/src/wp_modernizer/cli/main.py index 58f3bb7..17cbfd5 100644 --- a/src/wp_modernizer/cli/main.py +++ b/src/wp_modernizer/cli/main.py @@ -13,7 +13,7 @@ from wp_modernizer.config.loader import load_config from wp_modernizer.config.models import ApplicationConfig from wp_modernizer.diagnostics.capability import CapabilityProbe -from wp_modernizer.domain.enums import Operation, RunStatus +from wp_modernizer.domain.enums import Environment, Operation, RunStatus from wp_modernizer.domain.errors import ModernizerError from wp_modernizer.domain.path_parser import InstallationPathParser from wp_modernizer.infrastructure.command import SubprocessCommandRunner @@ -51,6 +51,14 @@ def build_service( ssh = FileTransferRouter(config.servers, key_transport, password_transport) mysql = MySQLAdapter(config.databases, secret_provider, command_runner) wpcli = WPCLIAdapter(command_runner) + database_endpoints = { + installation.destination_path: tuple( + endpoint_id + for endpoint_id in installation.allowed_database_endpoints + if config.databases[endpoint_id].environment is Environment.TEST + ) + for installation in config.installations.values() + } operations = RuntimeOperations( ssh, mysql, @@ -60,7 +68,13 @@ def build_service( ) return ModernizerService( config, - CapabilityProbe(command_runner, filesystem), + CapabilityProbe( + command_runner, + filesystem, + database=mysql, + wordpress=wpcli, + database_endpoints=database_endpoints, + ), JsonStateStore(config.state_directory), filesystem, SystemClock(), diff --git a/src/wp_modernizer/diagnostics/capability.py b/src/wp_modernizer/diagnostics/capability.py index 8c7d5a5..2c5fb19 100644 --- a/src/wp_modernizer/diagnostics/capability.py +++ b/src/wp_modernizer/diagnostics/capability.py @@ -1,9 +1,16 @@ +from __future__ import annotations + from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, List, Mapping, Sequence, Tuple -from wp_modernizer.application.ports import CommandRunner, FileSystem -from wp_modernizer.domain.enums import Capability, HealthStatus -from wp_modernizer.domain.models import CapabilityReport, ProbeResult +from wp_modernizer.application.ports import ( + CommandRunner, + DatabaseProbePort, + FileSystem, + WordPressPort, +) +from wp_modernizer.domain.enums import Capability, DatabaseAvailabilityStatus, HealthStatus +from wp_modernizer.domain.models import CapabilityReport, DatabaseProbeResult, ProbeResult class CapabilityProbe: @@ -13,11 +20,17 @@ def __init__( filesystem: FileSystem, wp_bin: str = "wp", php_bin: str = "php", + database: DatabaseProbePort | None = None, + wordpress: WordPressPort | None = None, + database_endpoints: Mapping[Path, Sequence[str]] | None = None, ) -> None: self._runner = runner self._filesystem = filesystem self._wp = wp_bin self._php = php_bin + self._database = database + self._wordpress = wordpress + self._database_endpoints = database_endpoints or {} def probe(self, installation_path: Path) -> CapabilityReport: config = installation_path / "wp-config.php" @@ -78,11 +91,8 @@ def probe(self, installation_path: Path) -> CapabilityReport: else ProbeResult(Capability.WPCLI_FULL_BOOTSTRAP, False, "WP-CLI ausente") ) results[Capability.WPCLI_FULL_BOOTSTRAP] = full - # A disponibilidade do banco é fornecida por uma sondagem independente na composição. - results[Capability.DATABASE_AVAILABLE] = ProbeResult( - Capability.DATABASE_AVAILABLE, - lint.available, - "sondagem do banco de dados no nível da configuração", + results[Capability.DATABASE_AVAILABLE] = self._probe_database( + installation_path, lint.available, cli.available ) ordered = tuple(results[item] for item in Capability) return CapabilityReport(ordered, self._classify(results), self._fatal_errors(full, reduced)) @@ -100,6 +110,50 @@ def _detect_multisite(self, config: Path) -> bool: compact = "".join(line.split("//", 1)[0] for line in text.splitlines()) return "MULTISITE" in compact and "true" in compact.lower() + def _probe_database( + self, installation_path: Path, config_valid: bool, wpcli_available: bool + ) -> ProbeResult: + insufficient = ProbeResult( + Capability.DATABASE_AVAILABLE, False, "configuração insuficiente" + ) + endpoints = tuple(self._database_endpoints.get(installation_path, ())) + if ( + not config_valid + or not wpcli_available + or self._database is None + or self._wordpress is None + or not endpoints + ): + return insufficient + try: + database_name = self._wordpress.get_config( + installation_path, "DB_NAME", "capability-probe" + ).strip() + except Exception: + return insufficient + if not database_name: + return insufficient + + evidence = [ + self._database.probe_database(endpoint_id, database_name) for endpoint_id in endpoints + ] + available = next((item for item in evidence if item.available), None) + if available is not None: + return ProbeResult(Capability.DATABASE_AVAILABLE, True, available.detail) + + precedence = ( + DatabaseAvailabilityStatus.SCHEMA_NOT_FOUND, + DatabaseAvailabilityStatus.AUTHENTICATION_DENIED, + DatabaseAvailabilityStatus.ENDPOINT_UNAVAILABLE, + DatabaseAvailabilityStatus.CONFIGURATION_INSUFFICIENT, + DatabaseAvailabilityStatus.UNKNOWN, + ) + selected = next( + (item for status in precedence for item in evidence if item.status is status), + DatabaseProbeResult(DatabaseAvailabilityStatus.UNKNOWN, "estado do banco desconhecido"), + ) + return ProbeResult(Capability.DATABASE_AVAILABLE, False, selected.detail) + @staticmethod def _classify(results: Dict[Capability, ProbeResult]) -> HealthStatus: def has(item: Capability) -> bool: diff --git a/src/wp_modernizer/domain/enums.py b/src/wp_modernizer/domain/enums.py index 31acb5f..0b5aaf2 100644 --- a/src/wp_modernizer/domain/enums.py +++ b/src/wp_modernizer/domain/enums.py @@ -29,6 +29,15 @@ class HealthStatus(str, Enum): UNKNOWN = "UNKNOWN" +class DatabaseAvailabilityStatus(str, Enum): + AVAILABLE = "AVAILABLE" + AUTHENTICATION_DENIED = "AUTHENTICATION_DENIED" + SCHEMA_NOT_FOUND = "SCHEMA_NOT_FOUND" + ENDPOINT_UNAVAILABLE = "ENDPOINT_UNAVAILABLE" + CONFIGURATION_INSUFFICIENT = "CONFIGURATION_INSUFFICIENT" + UNKNOWN = "UNKNOWN" + + class StepStatus(str, Enum): PENDING = "PENDING" RUNNING = "RUNNING" diff --git a/src/wp_modernizer/domain/models.py b/src/wp_modernizer/domain/models.py index 1f941e2..393ae4b 100644 --- a/src/wp_modernizer/domain/models.py +++ b/src/wp_modernizer/domain/models.py @@ -6,6 +6,7 @@ from .enums import ( Capability, + DatabaseAvailabilityStatus, Environment, HealthStatus, Operation, @@ -48,6 +49,16 @@ class ProbeResult: detail: str = "" +@dataclass(frozen=True) +class DatabaseProbeResult: + status: DatabaseAvailabilityStatus + detail: str + + @property + def available(self) -> bool: + return self.status is DatabaseAvailabilityStatus.AVAILABLE + + @dataclass(frozen=True) class CapabilityReport: results: Tuple[ProbeResult, ...] diff --git a/src/wp_modernizer/infrastructure/mysql/adapter.py b/src/wp_modernizer/infrastructure/mysql/adapter.py index cac4b77..e86a5cf 100644 --- a/src/wp_modernizer/infrastructure/mysql/adapter.py +++ b/src/wp_modernizer/infrastructure/mysql/adapter.py @@ -7,15 +7,17 @@ from pathlib import Path from typing import Dict, Iterator, Mapping, Set -from wp_modernizer.application.ports import CommandRunner, SecretProvider +from wp_modernizer.application.ports import CommandResult, CommandRunner, SecretProvider from wp_modernizer.config.models import DatabaseConfig -from wp_modernizer.domain.enums import Environment +from wp_modernizer.domain.enums import DatabaseAvailabilityStatus, Environment from wp_modernizer.domain.errors import ( AuthenticationError, + CommandTimeoutError, ConfigurationError, InfrastructureError, UnsafeOperationError, ) +from wp_modernizer.domain.models import DatabaseProbeResult from wp_modernizer.domain.widgets import WidgetOption, WidgetSnapshot @@ -46,6 +48,41 @@ def list_schemas(self, endpoint_id: str) -> Set[str]: result = self._query(endpoint_id, "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA") return set(result.splitlines()) + def probe_database(self, endpoint_id: str, database: str) -> DatabaseProbeResult: + if not endpoint_id or not database: + return self._probe_result(DatabaseAvailabilityStatus.CONFIGURATION_INSUFFICIENT) + try: + result = self._run_query(endpoint_id, "SELECT 1", database) + except (ConfigurationError, FileNotFoundError): + return self._probe_result(DatabaseAvailabilityStatus.CONFIGURATION_INSUFFICIENT) + except (CommandTimeoutError, TimeoutError): + return self._probe_result(DatabaseAvailabilityStatus.ENDPOINT_UNAVAILABLE) + except Exception: + return self._probe_result(DatabaseAvailabilityStatus.UNKNOWN) + + if result.return_code == 0: + return self._probe_result(DatabaseAvailabilityStatus.AVAILABLE) + error = result.stderr.lower() + if "access denied" in error or "error 1045" in error: + return self._probe_result(DatabaseAvailabilityStatus.AUTHENTICATION_DENIED) + if "unknown database" in error or "error 1049" in error: + return self._probe_result(DatabaseAvailabilityStatus.SCHEMA_NOT_FOUND) + unavailable_markers = ( + "error 2002", + "error 2003", + "error 2005", + "error 2006", + "error 2013", + "can't connect", + "cannot connect", + "connection refused", + "lost connection", + "unknown mysql server host", + ) + if any(marker in error for marker in unavailable_markers): + return self._probe_result(DatabaseAvailabilityStatus.ENDPOINT_UNAVAILABLE) + return self._probe_result(DatabaseAvailabilityStatus.UNKNOWN) + def dump(self, endpoint_id: str, database: str, output: Path, run_id: str) -> None: endpoint = self.get_database(endpoint_id) with self._defaults_file(endpoint) as defaults: @@ -122,6 +159,11 @@ def wordpress_configuration(self, endpoint_id: str, database: str) -> Mapping[st } def _query(self, endpoint_id: str, sql: str, database: str = "") -> str: + result = self._run_query(endpoint_id, sql, database) + self._ensure_success(result.return_code, result.stderr) + return result.stdout + + def _run_query(self, endpoint_id: str, sql: str, database: str = "") -> CommandResult: endpoint = self.get_database(endpoint_id) with self._defaults_file(endpoint) as defaults: argv = [ @@ -135,8 +177,25 @@ def _query(self, endpoint_id: str, sql: str, database: str = "") -> str: argv.append(database) argv.extend(["--execute", sql]) result = self._runner.run(argv, timeout=60) - self._ensure_success(result.return_code, result.stderr) - return result.stdout + return result + + @staticmethod + def _probe_result(status: DatabaseAvailabilityStatus) -> DatabaseProbeResult: + details = { + DatabaseAvailabilityStatus.AVAILABLE: ( + "endpoint alcançável; autenticação aceita; schema disponível" + ), + DatabaseAvailabilityStatus.AUTHENTICATION_DENIED: ( + "endpoint alcançável; autenticação negada" + ), + DatabaseAvailabilityStatus.SCHEMA_NOT_FOUND: ( + "endpoint alcançável; autenticação aceita; schema inexistente" + ), + DatabaseAvailabilityStatus.ENDPOINT_UNAVAILABLE: "endpoint indisponível", + DatabaseAvailabilityStatus.CONFIGURATION_INSUFFICIENT: "configuração insuficiente", + DatabaseAvailabilityStatus.UNKNOWN: "estado do banco desconhecido", + } + return DatabaseProbeResult(status, details[status]) @contextmanager def _defaults_file(self, endpoint: DatabaseConfig) -> Iterator[Path]: diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index b0bfec6..9238fa4 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -9,7 +9,7 @@ from tests.fakes.core import FakeCommandResult, FakeCommandRunner from wp_modernizer.config.models import DatabaseConfig, ServerConfig -from wp_modernizer.domain.enums import Environment +from wp_modernizer.domain.enums import DatabaseAvailabilityStatus, Environment from wp_modernizer.domain.errors import ( AuthenticationError, AuthenticationRefusedError, @@ -142,6 +142,43 @@ def test_mysql_schema_discovery_and_authentication_error() -> None: denied.list_schemas("db") +@pytest.mark.parametrize( + ("result", "expected"), + [ + (FakeCommandResult(stdout="1\n"), DatabaseAvailabilityStatus.AVAILABLE), + ( + FakeCommandResult(1, stderr="ERROR 1045: Access denied for password secret-value"), + DatabaseAvailabilityStatus.AUTHENTICATION_DENIED, + ), + ( + FakeCommandResult(1, stderr="ERROR 1049: Unknown database 'missing'"), + DatabaseAvailabilityStatus.SCHEMA_NOT_FOUND, + ), + ( + FakeCommandResult(1, stderr="ERROR 2003: Can't connect to MySQL server"), + DatabaseAvailabilityStatus.ENDPOINT_UNAVAILABLE, + ), + ( + FakeCommandResult(1, stderr="unexpected secret-value"), + DatabaseAvailabilityStatus.UNKNOWN, + ), + ], +) +def test_mysql_database_probe_returns_redacted_evidence(result, expected) -> None: + probe = MySQLAdapter({"db": database()}, Secrets(), FakeCommandRunner([result])).probe_database( + "db", "site" + ) + assert probe.status is expected + assert "secret-value" not in probe.detail + + +def test_mysql_database_probe_reports_insufficient_configuration_without_command() -> None: + runner = FakeCommandRunner() + probe = MySQLAdapter({"db": database()}, Secrets(), runner).probe_database("db", "") + assert probe.status is DatabaseAvailabilityStatus.CONFIGURATION_INSUFFICIENT + assert runner.calls == [] + + def test_mysql_never_imports_into_production() -> None: production = database().model_copy(update={"environment": Environment.PRODUCTION}) runner = FakeCommandRunner() diff --git a/tests/unit/test_capabilities.py b/tests/unit/test_capabilities.py index 736514b..e42dc0b 100644 --- a/tests/unit/test_capabilities.py +++ b/tests/unit/test_capabilities.py @@ -2,23 +2,119 @@ from tests.fakes.core import FakeCommandResult, FakeCommandRunner, FakeFileSystem from wp_modernizer.diagnostics.capability import CapabilityProbe -from wp_modernizer.domain.enums import Capability, HealthStatus +from wp_modernizer.domain.enums import Capability, DatabaseAvailabilityStatus, HealthStatus +from wp_modernizer.domain.models import DatabaseProbeResult PATH = Path("/site") -def run_probe(codes, config=" None: +def test_operational_database_is_healthy() -> None: report = run_probe([0, 0, 0, 0, 0, 0]) assert report.health is HealthStatus.HEALTHY assert report.has(Capability.MULTISITE) + assert report.has(Capability.DATABASE_AVAILABLE) + + +def test_php_lint_ok_with_mysql_offline_is_not_available() -> None: + report = run_probe( + [0, 0, 0, 0, 0, 0], + database_status=DatabaseAvailabilityStatus.ENDPOINT_UNAVAILABLE, + ) + database = next( + item for item in report.results if item.capability is Capability.DATABASE_AVAILABLE + ) + assert not database.available + assert database.detail == "endpoint indisponível" + assert report.health is HealthStatus.DATABASE_UNAVAILABLE + + +def test_php_lint_ok_with_access_denied_is_not_available() -> None: + report = run_probe( + [0, 0, 0, 0, 0, 0], + database_status=DatabaseAvailabilityStatus.AUTHENTICATION_DENIED, + ) + database = next( + item for item in report.results if item.capability is Capability.DATABASE_AVAILABLE + ) + assert not database.available + assert database.detail == "endpoint alcançável; autenticação negada" + + +def test_missing_schema_is_distinct_from_unreachable_endpoint() -> None: + report = run_probe( + [0, 0, 0, 0, 0, 0], + database_status=DatabaseAvailabilityStatus.SCHEMA_NOT_FOUND, + ) + database = next( + item for item in report.results if item.capability is Capability.DATABASE_AVAILABLE + ) + assert not database.available + assert database.detail.endswith("schema inexistente") + + +def test_insufficient_configuration_is_not_assumed_healthy() -> None: + report = run_probe([0, 0, 0, 0, 0, 0], endpoints=()) + database = next( + item for item in report.results if item.capability is Capability.DATABASE_AVAILABLE + ) + assert not database.available + assert database.detail == "configuração insuficiente" + assert report.health is HealthStatus.DATABASE_UNAVAILABLE def test_plugin_or_theme_fatal() -> None: @@ -32,7 +128,7 @@ def test_wpcli_partial() -> None: def test_wpcli_absent() -> None: - assert run_probe([0, 0, 1]).health is HealthStatus.PRE_BOOTSTRAP_RECOVERY_REQUIRED + assert run_probe([0, 0, 1]).health is HealthStatus.DATABASE_UNAVAILABLE def test_invalid_config() -> None: @@ -42,6 +138,10 @@ def test_invalid_config() -> None: def test_core_incomplete() -> None: files = {PATH / "wp-config.php": " Date: Sun, 30 Aug 2026 21:32:26 -0300 Subject: [PATCH 6/6] fix(deps): upgrade paramiko to address CVE-2026-44405 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e9ada08..14f42c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ license = {text = "Seleção de licença pendente de aprovação organizacional" authors = [{name = "Colaboradores do wp-modernizer"}] dependencies = [ "click>=8.1,<9", - "paramiko>=3.4,<5", + "paramiko>=5.0,<6", "pydantic>=2,<3", "PyYAML>=6,<7", ]