Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ Os comandos que alteram estado são `migrate`, `update`, `pipeline` e `resume`.
`migrate --replace-existing` cria uma cópia de segurança da cópia de teste existente; em seguida,
exige que todas as proteções de caminhos destrutivos sejam aprovadas. `diagnose`, `inventory` e
`plan` são somente leitura. Todos os comandos aceitam `--json`; `--dry-run` é uma opção global e
garante que as portas que alteram estado não sejam chamadas.
garante que o alvo não seja alterado. Etapas somente leitura ficam `VALIDATED`, etapas mutáveis sem
simulação segura ficam `PLANNED`, e apenas dry-runs nativos revisados explicitamente podem validar
uma etapa mutável. Execuções reais bem-sucedidas ficam `EXECUTED`.

Consulte [operações](docs/operations.md), [configuração](docs/configuration.md),
[arquitetura](docs/architecture.md) e [recuperação](docs/recovery.md).
23 changes: 19 additions & 4 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,25 @@
- `resume` carrega uma execução, compara a impressão digital da instalação e só prossegue se ela
for consistente.

`--dry-run`, no nível global ou do comando, impede que qualquer etapa mutável invoque seu
adaptador. Uma simulação ainda sonda capacidades e grava seu manifesto externo de execução, para
que o trabalho proposto possa ser auditado. Intencionalmente, não existe comando de publicação
em produção.
`--dry-run`, no nível global ou do comando, nunca altera a instalação, seus arquivos ou seu banco.
Cada etapa declara uma capacidade revisada explicitamente:

- `READ_ONLY`: a leitura é executada e o resultado fica `VALIDATED`;
- `MUTABLE_WITHOUT_SAFE_DRY_RUN`: nenhuma porta operacional é chamada e o resultado fica
`PLANNED`;
- `MUTABLE_WITH_NATIVE_DRY_RUN`: somente a entrada de validação separada e autorizada é chamada,
e o resultado fica `VALIDATED`.

Uma validação também declara suas capacidades mínimas. Se elas não estiverem disponíveis, o
adapter não é chamado e a etapa permanece `PLANNED`; ausência de infraestrutura nunca transforma
uma tentativa de validação em execução implícita.

Uma execução real bem-sucedida fica `EXECUTED`. Hoje, `wp search-replace --dry-run` é a única
simulação nativa autorizada; o adapter usa bootstrap reduzido e não marca a operação pendente como
concluída. Ter uma opção chamada `--dry-run` não é suficiente para autorizar outro comando: cada
nova operação precisa ser classificada e adaptada explicitamente. A simulação ainda sonda
capacidades e grava seu manifesto externo de auditoria. Intencionalmente, não existe comando de
publicação em produção.

O adaptador público de execução delega cópias à porta de transporte remoto. Um roteador usa
SSH/rsync para autenticação por chave e SSH/SFTP (Paramiko) para autenticação por senha. A
Expand Down
2 changes: 2 additions & 0 deletions src/wp_modernizer/application/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,5 @@ def new(self) -> str: ...

class MutableOperations(Protocol):
def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult: ...

def validate(self, step_name: str, context: Dict[str, Any]) -> StepResult: ...
4 changes: 2 additions & 2 deletions src/wp_modernizer/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def __init__(self, config_path: Path, dry_run: bool) -> None:
@click.option(
"--dry-run",
is_flag=True,
help="Garante que etapas mutáveis sejam planejadas, mas não executadas.",
help="Não altera o alvo; valida somente leituras e dry-runs nativos autorizados.",
)
@click.version_option()
@click.pass_context
Expand Down Expand Up @@ -167,7 +167,7 @@ def _mutable_command(operation: Operation) -> Any:
"--dry-run",
"command_dry_run",
is_flag=True,
help="Planeja sem alterar estado (também disponível globalmente).",
help="Não altera o alvo e valida apenas operações seguras (também disponível globalmente).",
)
@click.option(
"--replace-existing",
Expand Down
26 changes: 24 additions & 2 deletions src/wp_modernizer/domain/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,24 @@ class DatabaseAvailabilityStatus(str, Enum):
class StepStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
EXECUTED = "EXECUTED"
SUCCEEDED = "EXECUTED"
FAILED = "FAILED"
SKIPPED = "SKIPPED"
PLANNED = "PLANNED"
VALIDATED = "VALIDATED"

@classmethod
def _missing_(cls, value: object) -> "StepStatus | None":
if value == "SUCCEEDED":
return cls.EXECUTED
return None


class StepCapability(str, Enum):
READ_ONLY = "READ_ONLY"
MUTABLE_WITHOUT_SAFE_DRY_RUN = "MUTABLE_WITHOUT_SAFE_DRY_RUN"
MUTABLE_WITH_NATIVE_DRY_RUN = "MUTABLE_WITH_NATIVE_DRY_RUN"


class ManagedPluginStatus(str, Enum):
Expand All @@ -56,12 +70,20 @@ class ManagedPluginStatus(str, Enum):

class RunStatus(str, Enum):
PLANNED = "PLANNED"
VALIDATED = "VALIDATED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
EXECUTED = "EXECUTED"
SUCCEEDED = "EXECUTED"
UPDATE_FAILED_PRESERVED = "UPDATE_FAILED_PRESERVED"
PAUSED_FOR_MANUAL_REPAIR = "PAUSED_FOR_MANUAL_REPAIR"
INCONSISTENT_AFTER_INTERVENTION = "INCONSISTENT_AFTER_INTERVENTION"

@classmethod
def _missing_(cls, value: object) -> "RunStatus | None":
if value == "SUCCEEDED":
return cls.EXECUTED
return None


class Operation(str, Enum):
MIGRATE = "migrate"
Expand Down
15 changes: 15 additions & 0 deletions src/wp_modernizer/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Operation,
PendingOperationType,
RunStatus,
StepCapability,
StepStatus,
)
from .errors import UnsafeOperationError
Expand Down Expand Up @@ -92,6 +93,20 @@ class PlannedStep:
partial_recovery: str
installation_id: str
excludes: Tuple[Path, ...] = ()
capability: Optional[StepCapability] = None
dry_run_requirements: Tuple[Capability, ...] = ()

def __post_init__(self) -> None:
capability = self.capability
if capability is None:
capability = (
StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN
if self.mutable
else StepCapability.READ_ONLY
)
object.__setattr__(self, "capability", capability)
if self.mutable is (capability is StepCapability.READ_ONLY):
raise ValueError("mutable e capability descrevem capacidades incompatíveis")


@dataclass(frozen=True)
Expand Down
22 changes: 20 additions & 2 deletions src/wp_modernizer/domain/planning.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Iterable, Tuple

from .enums import Environment, PendingOperationType
from .enums import Capability, Environment, PendingOperationType, StepCapability
from .models import MigrationPlan, PendingOperation, PlannedStep, WordPressInstallation


Expand Down Expand Up @@ -35,6 +35,7 @@ def build(
completion_probe="o manifesto e o resumo de conteúdo da cópia existem",
partial_recovery="criar uma nova cópia de segurança imutável",
installation_id=node.installation_id,
capability=StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN,
)
)
steps.append(
Expand All @@ -48,17 +49,29 @@ def build(
),
installation_id=node.installation_id,
excludes=(*descendants, Path("*.sql"), Path(".wp-modernizer")),
capability=StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN,
)
)
for name in ("snapshot_source_database", "copy_database", "write_test_db_config"):
capability = (
StepCapability.READ_ONLY
if name == "snapshot_source_database"
else StepCapability.MUTABLE_WITHOUT_SAFE_DRY_RUN
)
steps.append(
PlannedStep(
name,
True,
capability is not StepCapability.READ_ONLY,
True,
"ponto de controle mais estado inspecionado",
"repetir com segurança",
node.installation_id,
capability=capability,
dry_run_requirements=(
(Capability.WPCLI_AVAILABLE, Capability.DATABASE_AVAILABLE)
if capability is StepCapability.READ_ONLY
else ()
),
)
)
if any(
Expand All @@ -74,6 +87,11 @@ def build(
completion_probe="a URL de origem não permanece no banco de TESTE",
partial_recovery="preservar a cópia e repetir o search-replace com WP-CLI",
installation_id=installation_id,
capability=StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN,
dry_run_requirements=(
Capability.WPCLI_REDUCED_BOOTSTRAP,
Capability.DATABASE_AVAILABLE,
),
)
)
return MigrationPlan(
Expand Down
56 changes: 50 additions & 6 deletions src/wp_modernizer/infrastructure/runtime_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Environment,
ManagedPluginStatus,
PendingOperationType,
StepCapability,
StepStatus,
)
from wp_modernizer.domain.errors import (
Expand Down Expand Up @@ -143,14 +144,14 @@ def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult:
context.get("recovery_data", {}),
)

if step_name in {"preflight", "final_health_check"}:
if step_name == "preflight":
return self._ok(step_name, False, "ponto de controle de diagnóstico concluído")
if step_name == "snapshot":
return self._snapshot_widgets(step_name, installation, path, context, run_id)
if step_name == "widget_validation":
return self._validate_widgets(step_name, installation, path, context, run_id)
if step_name == "pending_search_replace":
return self._search_replace(step_name, path, context, run_id)
return self._search_replace(step_name, path, context, run_id, dry_run=False)
if step_name == "managed_plugin_refresh":
return self._refresh_managed_plugins(
step_name, planned_step.installation_id, installation, path, context, run_id
Expand All @@ -165,6 +166,28 @@ def execute(self, step_name: str, context: Dict[str, Any]) -> StepResult:
return StepResult(step_name, StepStatus.SUCCEEDED, True, output)
raise UnsafeOperationError(f"Etapa mutável desconhecida: {step_name}")

def validate(self, step_name: str, context: Dict[str, Any]) -> StepResult:
"""Execute only native dry-runs whose safety was explicitly reviewed here."""
planned_step = context.get("planned_step")
if not isinstance(planned_step, PlannedStep):
raise UnsafeOperationError(f"Etapa {step_name} não possui plano de validação")
if planned_step.capability is not StepCapability.MUTABLE_WITH_NATIVE_DRY_RUN:
raise UnsafeOperationError(f"Etapa {step_name} não possui dry-run nativo autorizado")
installation = context.get("installations", {}).get(
planned_step.installation_id, context["installation"]
)
if installation.destination_environment is not Environment.TEST:
raise UnsafeOperationError("Validações operacionais são proibidas fora de TESTE")
if step_name != "pending_search_replace":
raise UnsafeOperationError(f"Dry-run nativo desconhecido: {step_name}")
return self._search_replace(
step_name,
Path(installation.destination_path),
context,
str(context["run_id"]),
dry_run=True,
)

def _snapshot_source_database(
self,
step_name: str,
Expand Down Expand Up @@ -430,7 +453,13 @@ def _write_test_db_config(
return self._ok(step_name, True, "wp-config aponta para o banco do ambiente de teste")

def _search_replace(
self, step_name: str, path: Path, context: Dict[str, Any], run_id: str
self,
step_name: str,
path: Path,
context: Dict[str, Any],
run_id: str,
*,
dry_run: bool,
) -> StepResult:
plan = context.get("migration_plan")
pending = next(
Expand All @@ -442,6 +471,13 @@ def _search_replace(
None,
)
if pending is None:
if dry_run:
return StepResult(
step_name,
StepStatus.VALIDATED,
False,
"nenhum search-replace pendente",
)
return self._ok(step_name, False, "nenhum search-replace pendente")
explicit_url = pending.parameters.get("test_url") or None
try:
Expand All @@ -459,7 +495,7 @@ def _search_replace(
)
multisite = self._wordpress.is_multisite(path, run_id)
changed_count = self._wordpress.search_replace(
path, old_url, new_url, dry_run=False, multisite=multisite, run_id=run_id
path, old_url, new_url, dry_run=dry_run, multisite=multisite, run_id=run_id
)
except ConfigurationError as exc:
return self._failed(step_name, str(exc))
Expand All @@ -471,7 +507,7 @@ def _search_replace(
return self._failed(step_name, "search-replace falhou; a cópia de TESTE foi preservada")

manifest = context.get("manifest")
if manifest is not None:
if manifest is not None and not dry_run:
for index, operation in enumerate(manifest.pending_operations):
if (
operation.operation_type is pending.operation_type
Expand All @@ -480,9 +516,17 @@ def _search_replace(
):
manifest.pending_operations[index] = replace(operation, completed=True)
break
if dry_run:
return StepResult(
step_name,
StepStatus.VALIDATED,
False,
f"search-replace validado pelo dry-run nativo: {changed_count} substituições",
{"potential_replacements": float(changed_count)},
)
return StepResult(
step_name,
StepStatus.SUCCEEDED,
StepStatus.EXECUTED,
changed_count > 0,
f"search-replace concluído: {changed_count} substituições",
{"replacements": float(changed_count)},
Expand Down
6 changes: 6 additions & 0 deletions src/wp_modernizer/infrastructure/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from typing import Any, Dict

from wp_modernizer.domain.enums import (
Capability,
Environment,
HealthStatus,
ManagedPluginStatus,
Operation,
PendingOperationType,
RunStatus,
StepCapability,
StepStatus,
)
from wp_modernizer.domain.models import (
Expand Down Expand Up @@ -128,6 +130,10 @@ def _deserialize_planned_step(raw: Dict[str, Any]) -> PlannedStep:
partial_recovery=raw["partial_recovery"],
installation_id=raw["installation_id"],
excludes=tuple(Path(item) for item in raw.get("excludes", [])),
capability=(StepCapability(raw["capability"]) if raw.get("capability") else None),
dry_run_requirements=tuple(
Capability(item) for item in raw.get("dry_run_requirements", [])
),
)

@staticmethod
Expand Down
Loading