migrate-guard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- migrate_guard/__init__.py +3 -0
- migrate_guard/application/__init__.py +0 -0
- migrate_guard/application/use_cases/__init__.py +0 -0
- migrate_guard/application/use_cases/run_corpus.py +122 -0
- migrate_guard/cli/__init__.py +0 -0
- migrate_guard/cli/app.py +118 -0
- migrate_guard/domain/__init__.py +0 -0
- migrate_guard/domain/entities/__init__.py +0 -0
- migrate_guard/domain/entities/case.py +40 -0
- migrate_guard/domain/entities/case_result.py +59 -0
- migrate_guard/domain/entities/difference.py +42 -0
- migrate_guard/domain/entities/state.py +27 -0
- migrate_guard/domain/ports/__init__.py +0 -0
- migrate_guard/domain/ports/state.py +23 -0
- migrate_guard/domain/ports/target_runner.py +21 -0
- migrate_guard/domain/services/__init__.py +0 -0
- migrate_guard/domain/services/case_compare.py +23 -0
- migrate_guard/domain/services/redaction.py +94 -0
- migrate_guard/domain/services/response_diff.py +270 -0
- migrate_guard/domain/services/state_diff.py +27 -0
- migrate_guard/domain/services/state_values.py +60 -0
- migrate_guard/domain/value_objects/__init__.py +0 -0
- migrate_guard/domain/value_objects/json_pointer.py +35 -0
- migrate_guard/domain/value_objects/json_value.py +24 -0
- migrate_guard/domain/value_objects/rules.py +74 -0
- migrate_guard/domain/value_objects/state_rules.py +39 -0
- migrate_guard/infrastructure/__init__.py +0 -0
- migrate_guard/infrastructure/adapters/__init__.py +0 -0
- migrate_guard/infrastructure/adapters/reporters/__init__.py +0 -0
- migrate_guard/infrastructure/adapters/reporters/json_reporter.py +63 -0
- migrate_guard/infrastructure/adapters/reporters/junit_reporter.py +60 -0
- migrate_guard/infrastructure/adapters/reporters/plain_text.py +78 -0
- migrate_guard/infrastructure/adapters/reporters/terminal_reporter.py +87 -0
- migrate_guard/infrastructure/adapters/state/__init__.py +0 -0
- migrate_guard/infrastructure/adapters/state/sqlalchemy_state.py +167 -0
- migrate_guard/infrastructure/adapters/targets/__init__.py +0 -0
- migrate_guard/infrastructure/adapters/targets/http_target.py +114 -0
- migrate_guard/infrastructure/config/__init__.py +0 -0
- migrate_guard/infrastructure/config/loader.py +254 -0
- migrate_guard/infrastructure/config/state_config.py +195 -0
- migrate_guard/infrastructure/wiring.py +54 -0
- migrate_guard/py.typed +0 -0
- migrate_guard/pytest_plugin.py +136 -0
- migrate_guard-0.1.0.dist-info/METADATA +219 -0
- migrate_guard-0.1.0.dist-info/RECORD +48 -0
- migrate_guard-0.1.0.dist-info/WHEEL +4 -0
- migrate_guard-0.1.0.dist-info/entry_points.txt +6 -0
- migrate_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Use case: replay every case against the baseline and the candidate, and compare."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
|
|
8
|
+
from migrate_guard.domain.entities.case import Case, HttpResponse
|
|
9
|
+
from migrate_guard.domain.entities.case_result import CaseResult, CaseStatus, RunResult
|
|
10
|
+
from migrate_guard.domain.entities.state import StateSnapshot
|
|
11
|
+
from migrate_guard.domain.ports.state import Seeder, StateError, StateProbe
|
|
12
|
+
from migrate_guard.domain.ports.target_runner import TargetError, TargetRunner
|
|
13
|
+
from migrate_guard.domain.services.case_compare import compare_case
|
|
14
|
+
from migrate_guard.domain.services.state_diff import diff_states
|
|
15
|
+
from migrate_guard.domain.value_objects.rules import Rule
|
|
16
|
+
from migrate_guard.domain.value_objects.state_rules import StateRule
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RunRefused(Exception):
|
|
20
|
+
"""The run did not start: nothing was sent to any target. Maps to exit code 2."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class StateCheck:
|
|
25
|
+
"""The database side of a run. Seeding is not optional: every case starts from a known state."""
|
|
26
|
+
|
|
27
|
+
baseline_seeder: Seeder
|
|
28
|
+
candidate_seeder: Seeder
|
|
29
|
+
baseline_probe: StateProbe
|
|
30
|
+
candidate_probe: StateProbe
|
|
31
|
+
rules: tuple[StateRule, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _CaseFailed(Exception):
|
|
35
|
+
"""One step of a case failed: the case is an error and its remaining steps are skipped."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RunCorpus:
|
|
40
|
+
baseline: TargetRunner
|
|
41
|
+
candidate: TargetRunner
|
|
42
|
+
rules: tuple[Rule, ...] = ()
|
|
43
|
+
allow_mutating: bool = False
|
|
44
|
+
state: StateCheck | None = None
|
|
45
|
+
|
|
46
|
+
def execute(self, cases: Sequence[Case]) -> RunResult:
|
|
47
|
+
# Every refusal happens before the first request, so a refused run touches nothing.
|
|
48
|
+
if not cases:
|
|
49
|
+
raise RunRefused("the corpus has no cases")
|
|
50
|
+
mutating = [c.name for c in cases if c.is_mutating]
|
|
51
|
+
if mutating and not self.allow_mutating:
|
|
52
|
+
raise RunRefused(
|
|
53
|
+
"mutating cases need `allow_mutating: true` in the config: " + ", ".join(mutating)
|
|
54
|
+
)
|
|
55
|
+
for side, target in (("baseline", self.baseline), ("candidate", self.candidate)):
|
|
56
|
+
try:
|
|
57
|
+
target.check_reachable()
|
|
58
|
+
except TargetError as e:
|
|
59
|
+
raise RunRefused(f"target unreachable: {side} ({e})") from e
|
|
60
|
+
|
|
61
|
+
results: list[CaseResult] = []
|
|
62
|
+
used: set[Rule | StateRule] = set()
|
|
63
|
+
compared_any = False
|
|
64
|
+
for case in cases:
|
|
65
|
+
try:
|
|
66
|
+
result, case_used = self._run_case(case)
|
|
67
|
+
except _CaseFailed as e:
|
|
68
|
+
results.append(CaseResult.failed(case.name, str(e)))
|
|
69
|
+
continue
|
|
70
|
+
results.append(result)
|
|
71
|
+
used.update(case_used)
|
|
72
|
+
compared_any = True
|
|
73
|
+
|
|
74
|
+
# With no case compared there is no evidence that a rule is stale: don't warn.
|
|
75
|
+
all_rules: tuple[Rule | StateRule, ...] = (
|
|
76
|
+
*self.rules,
|
|
77
|
+
*(self.state.rules if self.state else ()),
|
|
78
|
+
)
|
|
79
|
+
unused = tuple(r for r in all_rules if r not in used) if compared_any else ()
|
|
80
|
+
return RunResult(tuple(results), unused)
|
|
81
|
+
|
|
82
|
+
def _run_case(self, case: Case) -> tuple[CaseResult, set[Rule | StateRule]]:
|
|
83
|
+
# Order (SPEC §6): seed both -> baseline -> probe baseline -> candidate -> probe
|
|
84
|
+
# candidate -> compare. Seeding both first means a failed seed sends no request at all.
|
|
85
|
+
if self.state:
|
|
86
|
+
_step("seed failed: baseline", self.state.baseline_seeder.seed)
|
|
87
|
+
_step("seed failed: candidate", self.state.candidate_seeder.seed)
|
|
88
|
+
b_response = _request("baseline", self.baseline, case)
|
|
89
|
+
b_state = self._probe("baseline", self.state.baseline_probe if self.state else None)
|
|
90
|
+
c_response = _request("candidate", self.candidate, case)
|
|
91
|
+
c_state = self._probe("candidate", self.state.candidate_probe if self.state else None)
|
|
92
|
+
|
|
93
|
+
result, body = compare_case(case.name, b_response, c_response, self.rules)
|
|
94
|
+
used: set[Rule | StateRule] = {r for r in self.rules if r not in body.unused_rules}
|
|
95
|
+
if self.state and b_state is not None and c_state is not None:
|
|
96
|
+
state = diff_states(b_state, c_state, self.state.rules)
|
|
97
|
+
used.update(r for r in self.state.rules if r not in state.unused_rules)
|
|
98
|
+
if state.differences:
|
|
99
|
+
result = replace(
|
|
100
|
+
result, status=CaseStatus.DIFFERENCE, state_differences=state.differences
|
|
101
|
+
)
|
|
102
|
+
return result, used
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _probe(side: str, probe: StateProbe | None) -> StateSnapshot | None:
|
|
106
|
+
if probe is None:
|
|
107
|
+
return None
|
|
108
|
+
return _step(f"state probe failed: {side}", probe.snapshot)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _request(side: str, target: TargetRunner, case: Case) -> HttpResponse:
|
|
112
|
+
try:
|
|
113
|
+
return target.execute(case.request)
|
|
114
|
+
except TargetError as e:
|
|
115
|
+
raise _CaseFailed(f"{side}: {e}") from e
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _step[T](label: str, action: Callable[[], T]) -> T:
|
|
119
|
+
try:
|
|
120
|
+
return action()
|
|
121
|
+
except StateError as e:
|
|
122
|
+
raise _CaseFailed(f"{label}: {e}") from e
|
|
File without changes
|
migrate_guard/cli/app.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The `migrate-guard` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from migrate_guard.application.use_cases.run_corpus import RunRefused
|
|
16
|
+
from migrate_guard.domain.entities.case_result import ExitCode
|
|
17
|
+
from migrate_guard.domain.services.redaction import RedactionPolicy
|
|
18
|
+
from migrate_guard.infrastructure.adapters.reporters.json_reporter import render_json_report
|
|
19
|
+
from migrate_guard.infrastructure.adapters.reporters.junit_reporter import render_junit_report
|
|
20
|
+
from migrate_guard.infrastructure.adapters.reporters.terminal_reporter import TerminalReporter
|
|
21
|
+
from migrate_guard.infrastructure.adapters.targets.http_target import UnsafeTargetError
|
|
22
|
+
from migrate_guard.infrastructure.config.loader import ConfigError, load_config
|
|
23
|
+
from migrate_guard.infrastructure.config.state_config import StateConfigError
|
|
24
|
+
from migrate_guard.infrastructure.wiring import select_cases, wire
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
help="Catch behavior changes when you rewrite a legacy service.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ReportFormat(StrEnum):
|
|
34
|
+
TERMINAL = "terminal"
|
|
35
|
+
JSON = "json"
|
|
36
|
+
JUNIT = "junit"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_RENDER = {ReportFormat.JSON: render_json_report, ReportFormat.JUNIT: render_junit_report}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback()
|
|
43
|
+
def _root() -> None:
|
|
44
|
+
"""Keeps `run` as a subcommand even while it is the only one."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def run(
|
|
49
|
+
config: Annotated[
|
|
50
|
+
Path, typer.Option("--config", "-c", help="Path to migrate-guard.yaml.")
|
|
51
|
+
] = Path("migrate-guard.yaml"),
|
|
52
|
+
case: Annotated[
|
|
53
|
+
list[str] | None, typer.Option("--case", help="Run only this case (repeatable).")
|
|
54
|
+
] = None,
|
|
55
|
+
allow_remote: Annotated[
|
|
56
|
+
bool, typer.Option("--allow-remote", help="Allow targets that are not loopback.")
|
|
57
|
+
] = False,
|
|
58
|
+
report: Annotated[
|
|
59
|
+
ReportFormat,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--report",
|
|
62
|
+
help="json or junit go to --output (plus the terminal summary), or alone to stdout.",
|
|
63
|
+
),
|
|
64
|
+
] = ReportFormat.TERMINAL,
|
|
65
|
+
output: Annotated[
|
|
66
|
+
Path | None, typer.Option("--output", "-o", help="File for the json or junit report.")
|
|
67
|
+
] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Replay the corpus against the baseline and the candidate, and compare responses and state.
|
|
70
|
+
|
|
71
|
+
Exit codes: 0 all match, 1 differences or case errors, 2 refused (config, target, safety).
|
|
72
|
+
"""
|
|
73
|
+
# Refusals go to stderr, so stdout stays a valid report when it is one.
|
|
74
|
+
errors = TerminalReporter(Console(stderr=True, highlight=False), RedactionPolicy())
|
|
75
|
+
try:
|
|
76
|
+
if output and report is ReportFormat.TERMINAL:
|
|
77
|
+
raise ConfigError("--output needs --report json or --report junit")
|
|
78
|
+
loaded = load_config(config)
|
|
79
|
+
cases = select_cases(loaded.cases, case or [])
|
|
80
|
+
wiring = wire(loaded, allow_remote=allow_remote)
|
|
81
|
+
try:
|
|
82
|
+
result = wiring.run_corpus.execute(cases)
|
|
83
|
+
finally:
|
|
84
|
+
wiring.close()
|
|
85
|
+
except (ConfigError, StateConfigError, UnsafeTargetError, RunRefused) as e:
|
|
86
|
+
errors.refused(str(e))
|
|
87
|
+
raise typer.Exit(ExitCode.REFUSED) from e
|
|
88
|
+
|
|
89
|
+
if report is ReportFormat.TERMINAL or output:
|
|
90
|
+
TerminalReporter(Console(highlight=False), loaded.redaction).report(result)
|
|
91
|
+
if report is not ReportFormat.TERMINAL:
|
|
92
|
+
text = _RENDER[report](result, loaded.redaction)
|
|
93
|
+
try:
|
|
94
|
+
_write(text, output)
|
|
95
|
+
except OSError as e:
|
|
96
|
+
errors.refused(f"can't write the report: {e.strerror or type(e).__name__}")
|
|
97
|
+
raise typer.Exit(ExitCode.REFUSED) from e
|
|
98
|
+
raise typer.Exit(result.exit_code)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _write(text: str, output: Path | None) -> None:
|
|
102
|
+
if output is None:
|
|
103
|
+
sys.stdout.write(text)
|
|
104
|
+
sys.stdout.flush()
|
|
105
|
+
return
|
|
106
|
+
# Write next to the target and rename: CI never picks up half a report.
|
|
107
|
+
fd, tmp = tempfile.mkstemp(dir=output.parent, prefix=f".{output.name}.", suffix=".tmp")
|
|
108
|
+
try:
|
|
109
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
110
|
+
f.write(text)
|
|
111
|
+
os.replace(tmp, output)
|
|
112
|
+
except BaseException:
|
|
113
|
+
Path(tmp).unlink(missing_ok=True)
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main() -> None:
|
|
118
|
+
app()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""A case: one request replayed against both targets, and the response it gets back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from migrate_guard.domain.value_objects.json_value import JsonValue
|
|
8
|
+
|
|
9
|
+
# Methods that can change state on the target: they need `allow_mutating: true` (SPEC §7).
|
|
10
|
+
MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
11
|
+
|
|
12
|
+
type Headers = tuple[tuple[str, str], ...]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class HttpRequest:
|
|
17
|
+
method: str
|
|
18
|
+
path: str
|
|
19
|
+
query: tuple[tuple[str, str], ...] = ()
|
|
20
|
+
headers: Headers = ()
|
|
21
|
+
json: JsonValue | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Case:
|
|
26
|
+
name: str
|
|
27
|
+
request: HttpRequest
|
|
28
|
+
mutating: bool = False # set it for a GET that writes; mutating methods count anyway
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_mutating(self) -> bool:
|
|
32
|
+
return self.mutating or self.request.method.upper() in MUTATING_METHODS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class HttpResponse:
|
|
37
|
+
status: int
|
|
38
|
+
headers: Headers
|
|
39
|
+
# The parsed JSON body (numbers as Decimal), or the raw text when the body is not JSON.
|
|
40
|
+
body: JsonValue
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The result of one case, and of a whole run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from migrate_guard.domain.entities.difference import Difference
|
|
9
|
+
from migrate_guard.domain.value_objects.rules import Rule
|
|
10
|
+
from migrate_guard.domain.value_objects.state_rules import StateRule
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CaseStatus(StrEnum):
|
|
14
|
+
MATCH = "match"
|
|
15
|
+
DIFFERENCE = "difference"
|
|
16
|
+
# The case could not be compared (timeout, connection error...). Never counted as a match.
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class StatusMismatch:
|
|
22
|
+
baseline: int
|
|
23
|
+
candidate: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class CaseResult:
|
|
28
|
+
name: str
|
|
29
|
+
status: CaseStatus
|
|
30
|
+
status_mismatch: StatusMismatch | None = None
|
|
31
|
+
differences: tuple[Difference, ...] = () # in the response body
|
|
32
|
+
state_differences: tuple[Difference, ...] = () # in the database, at /table/row/column
|
|
33
|
+
error: str = ""
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def failed(cls, name: str, error: str) -> CaseResult:
|
|
37
|
+
return cls(name, CaseStatus.ERROR, error=error)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExitCode:
|
|
41
|
+
OK = 0 # every case matched
|
|
42
|
+
DIFFERENCES = 1 # at least one difference or case error
|
|
43
|
+
REFUSED = 2 # invalid config, unreachable target or safety refusal
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class RunResult:
|
|
48
|
+
cases: tuple[CaseResult, ...]
|
|
49
|
+
# Rules that matched nothing in any case of the run: probably stale (SPEC R2).
|
|
50
|
+
unused_rules: tuple[Rule | StateRule, ...] = ()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def exit_code(self) -> int:
|
|
54
|
+
if all(c.status is CaseStatus.MATCH for c in self.cases):
|
|
55
|
+
return ExitCode.OK
|
|
56
|
+
return ExitCode.DIFFERENCES
|
|
57
|
+
|
|
58
|
+
def count(self, status: CaseStatus) -> int:
|
|
59
|
+
return sum(1 for c in self.cases if c.status is status)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""One mismatch between the baseline and the candidate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum, StrEnum
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from migrate_guard.domain.value_objects.json_pointer import JsonPointer
|
|
10
|
+
from migrate_guard.domain.value_objects.json_value import JsonValue
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Missing(Enum):
|
|
14
|
+
MISSING = "missing"
|
|
15
|
+
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
return "MISSING"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# A key that does not exist, which is not the same as a key whose value is null.
|
|
21
|
+
MISSING = _Missing.MISSING
|
|
22
|
+
type Side = JsonValue | Literal[_Missing.MISSING]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DiffKind(StrEnum):
|
|
26
|
+
VALUE_CHANGED = "value_changed" # same type, different value: 2.68 vs 2.67
|
|
27
|
+
TYPE_CHANGED = "type_changed" # "10" vs 10, null vs 0
|
|
28
|
+
MISSING_IN_CANDIDATE = "missing_in_candidate" # the rewrite dropped a field
|
|
29
|
+
ADDED_IN_CANDIDATE = "added_in_candidate" # the rewrite added a field
|
|
30
|
+
DUPLICATE_KEY = "duplicate_key" # an `unordered` array has two items with the same key
|
|
31
|
+
# A rule matched but can't read the value (bad datetime, no timezone, missing key...).
|
|
32
|
+
# Never a silent match: the rule's assumption is broken, so a human has to look.
|
|
33
|
+
RULE_NOT_APPLICABLE = "rule_not_applicable"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Difference:
|
|
38
|
+
kind: DiffKind
|
|
39
|
+
location: JsonPointer
|
|
40
|
+
baseline: Side
|
|
41
|
+
candidate: Side
|
|
42
|
+
detail: str = "" # why, in words, when the kind alone doesn't say it
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""What a target's database looks like after a case: the watched tables, row by row."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from migrate_guard.domain.value_objects.json_value import JsonValue
|
|
8
|
+
|
|
9
|
+
type Row = dict[str, JsonValue] # logical column name -> normalized value
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class TableSnapshot:
|
|
14
|
+
# Row key text (e.g. "sku=A1") -> the row. The key comes from the configured key columns.
|
|
15
|
+
rows: dict[str, Row] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class StateSnapshot:
|
|
20
|
+
tables: dict[str, TableSnapshot] = field(default_factory=dict) # by logical table name
|
|
21
|
+
|
|
22
|
+
def as_json(self) -> JsonValue:
|
|
23
|
+
"""The snapshot as one JSON document, so the response diff engine can compare it."""
|
|
24
|
+
return {
|
|
25
|
+
name: {key: dict(row) for key, row in table.rows.items()}
|
|
26
|
+
for name, table in self.tables.items()
|
|
27
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Ports for the database side: read a target's state, and reset it before each case."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from migrate_guard.domain.entities.state import StateSnapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StateError(Exception):
|
|
11
|
+
"""Seeding or probing failed. The case becomes an error, never a match."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StateProbe(ABC):
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def snapshot(self) -> StateSnapshot:
|
|
17
|
+
"""Read the watched tables. Read-only: it must never write."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Seeder(ABC):
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def seed(self) -> None:
|
|
23
|
+
"""Reset the database to the known starting state, all or nothing."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Port: something that can execute a case (a running service, an in-process app...)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from migrate_guard.domain.entities.case import HttpRequest, HttpResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TargetError(Exception):
|
|
11
|
+
"""The target could not answer: connection refused, timeout, invalid response..."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TargetRunner(ABC):
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def check_reachable(self) -> None:
|
|
17
|
+
"""Raise TargetError if the target can't be reached at all, before any case runs."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def execute(self, request: HttpRequest) -> HttpResponse:
|
|
21
|
+
"""Send the request and return the response; raise TargetError if there is none."""
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Compare the two responses of one case: the status code, then the body with the rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from migrate_guard.domain.entities.case import HttpResponse
|
|
6
|
+
from migrate_guard.domain.entities.case_result import CaseResult, CaseStatus, StatusMismatch
|
|
7
|
+
from migrate_guard.domain.services.response_diff import ResponseDiff, diff_responses
|
|
8
|
+
from migrate_guard.domain.value_objects.rules import Rule
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compare_case(
|
|
12
|
+
name: str, baseline: HttpResponse, candidate: HttpResponse, rules: tuple[Rule, ...]
|
|
13
|
+
) -> tuple[CaseResult, ResponseDiff]:
|
|
14
|
+
"""The result, plus the raw body diff so the caller can track which rules were used."""
|
|
15
|
+
mismatch = (
|
|
16
|
+
StatusMismatch(baseline.status, candidate.status)
|
|
17
|
+
if baseline.status != candidate.status
|
|
18
|
+
else None
|
|
19
|
+
)
|
|
20
|
+
# The body is compared even when the status differs: both facts help to find the cause.
|
|
21
|
+
body = diff_responses(baseline.body, candidate.body, rules)
|
|
22
|
+
status = CaseStatus.MATCH if mismatch is None and body.is_match else CaseStatus.DIFFERENCE
|
|
23
|
+
return CaseResult(name, status, mismatch, body.differences), body
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Hide secrets before anything reaches a report or a log (SPEC §7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
|
|
8
|
+
from migrate_guard.domain.entities.case import Headers
|
|
9
|
+
from migrate_guard.domain.entities.difference import MISSING, Difference, Side
|
|
10
|
+
from migrate_guard.domain.value_objects.json_value import JsonValue
|
|
11
|
+
|
|
12
|
+
REDACTED = "[REDACTED]"
|
|
13
|
+
|
|
14
|
+
SENSITIVE_HEADERS = frozenset({"authorization", "cookie", "set-cookie", "x-api-key"})
|
|
15
|
+
DEFAULT_FIELD_PATTERNS = ("password", "passwd", "token", "secret", "api_key", "apikey")
|
|
16
|
+
|
|
17
|
+
# 13 to 19 digits, optionally grouped by spaces or dashes. Luhn-checked to limit false positives.
|
|
18
|
+
_CARD_CANDIDATE = re.compile(r"(?<!\d)\d(?:[ -]?\d){12,18}(?!\d)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class RedactionPolicy:
|
|
23
|
+
"""Field names are matched case-insensitively, as substrings: `token` hides `access_token`."""
|
|
24
|
+
|
|
25
|
+
field_patterns: tuple[str, ...] = DEFAULT_FIELD_PATTERNS
|
|
26
|
+
|
|
27
|
+
def is_sensitive_field(self, name: str) -> bool:
|
|
28
|
+
lowered = name.lower()
|
|
29
|
+
return any(p.lower() in lowered for p in self.field_patterns)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def redact_headers(headers: Headers, policy: RedactionPolicy) -> Headers:
|
|
33
|
+
return tuple(
|
|
34
|
+
(name, REDACTED)
|
|
35
|
+
if name.lower() in SENSITIVE_HEADERS or policy.is_sensitive_field(name)
|
|
36
|
+
else (name, value)
|
|
37
|
+
for name, value in headers
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def redact_value(value: JsonValue, policy: RedactionPolicy) -> JsonValue:
|
|
42
|
+
if isinstance(value, dict):
|
|
43
|
+
return {
|
|
44
|
+
k: REDACTED if policy.is_sensitive_field(k) else redact_value(v, policy)
|
|
45
|
+
for k, v in value.items()
|
|
46
|
+
}
|
|
47
|
+
if isinstance(value, list):
|
|
48
|
+
return [redact_value(v, policy) for v in value]
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return redact_text(value)
|
|
51
|
+
if isinstance(value, int) and not isinstance(value, bool) and _is_card(str(value)):
|
|
52
|
+
return REDACTED
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def redact_text(text: str) -> str:
|
|
57
|
+
return _CARD_CANDIDATE.sub(lambda m: REDACTED if _is_card(m.group()) else m.group(), text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def redact_difference(diff: Difference, policy: RedactionPolicy) -> Difference:
|
|
61
|
+
# Under a sensitive field everything goes, the detail too: it can quote the values.
|
|
62
|
+
if any(policy.is_sensitive_field(token) for token in diff.location.tokens):
|
|
63
|
+
return replace(
|
|
64
|
+
diff,
|
|
65
|
+
baseline=_hide(diff.baseline),
|
|
66
|
+
candidate=_hide(diff.candidate),
|
|
67
|
+
detail=REDACTED if diff.detail else "",
|
|
68
|
+
)
|
|
69
|
+
return replace(
|
|
70
|
+
diff,
|
|
71
|
+
baseline=_redact_side(diff.baseline, policy),
|
|
72
|
+
candidate=_redact_side(diff.candidate, policy),
|
|
73
|
+
detail=redact_text(diff.detail),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hide(side: Side) -> Side:
|
|
78
|
+
return side if side is MISSING else REDACTED
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _redact_side(side: Side, policy: RedactionPolicy) -> Side:
|
|
82
|
+
return side if side is MISSING else redact_value(side, policy)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_card(candidate: str) -> bool:
|
|
86
|
+
digits = [int(c) for c in candidate if c.isdigit()]
|
|
87
|
+
if not 13 <= len(digits) <= 19:
|
|
88
|
+
return False
|
|
89
|
+
total = 0
|
|
90
|
+
for i, d in enumerate(reversed(digits)):
|
|
91
|
+
if i % 2 == 1:
|
|
92
|
+
d = d * 2 - 9 if d > 4 else d * 2
|
|
93
|
+
total += d
|
|
94
|
+
return total % 10 == 0
|