feat: complete production workflow migration
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.source_sync import SourceSyncService, SyncState
|
||||
|
||||
|
||||
def _digest(content: str) -> str:
|
||||
return hashlib.sha256(content.encode()).hexdigest()
|
||||
|
||||
|
||||
def _write_project(
|
||||
root: Path,
|
||||
*,
|
||||
source_content: str | None = "baseline\n",
|
||||
target_content: str | None = "baseline\n",
|
||||
baseline_source: str = "baseline\n",
|
||||
baseline_target: str = "baseline\n",
|
||||
transformed: bool = False,
|
||||
source_relative_path: str = "job.py",
|
||||
target_relative_path: str = "src/runtime/job.py",
|
||||
) -> tuple[Path, Path, Path]:
|
||||
source_root = root / "upstream"
|
||||
repository_root = root / "repository"
|
||||
manifest_path = repository_root / "config" / "source-manifests" / "demo.json"
|
||||
config_path = repository_root / "config" / "source-projects.json"
|
||||
|
||||
source_root.mkdir(parents=True, exist_ok=True)
|
||||
source_relative = Path(source_relative_path)
|
||||
source_path_is_safe = (
|
||||
not source_relative.is_absolute() and ".." not in source_relative.parts
|
||||
)
|
||||
target_relative = Path(target_relative_path)
|
||||
target_path_is_safe = (
|
||||
not target_relative.is_absolute() and ".." not in target_relative.parts
|
||||
)
|
||||
if source_content is not None and source_path_is_safe:
|
||||
source_path = source_root / source_relative_path
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path.write_text(source_content, encoding="utf-8", newline="")
|
||||
if target_content is not None and target_path_is_safe:
|
||||
target_path = repository_root / target_relative_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(target_content, encoding="utf-8", newline="")
|
||||
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"module": "demo",
|
||||
"files": [
|
||||
{
|
||||
"source_relative_path": source_relative_path,
|
||||
"target_relative_path": target_relative_path,
|
||||
"source_sha256": _digest(baseline_source),
|
||||
"target_sha256": _digest(baseline_target),
|
||||
"transformed": transformed,
|
||||
"category": "source",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"projects": [
|
||||
{
|
||||
"module": "demo",
|
||||
"manifest": "config/source-manifests/demo.json",
|
||||
"root_env": "GYXX_SOURCE_DEMO_ROOT",
|
||||
"target_root": "src/runtime",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return repository_root, source_root, config_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_content", "target_content", "expected"),
|
||||
[
|
||||
("baseline\n", "baseline\n", SyncState.IN_SYNC),
|
||||
("upstream fix\n", "baseline\n", SyncState.SOURCE_CHANGED),
|
||||
("baseline\n", "local fix\n", SyncState.TARGET_CHANGED),
|
||||
("upstream fix\n", "local fix\n", SyncState.CONFLICT),
|
||||
(None, "baseline\n", SyncState.SOURCE_MISSING),
|
||||
("baseline\n", None, SyncState.TARGET_MISSING),
|
||||
],
|
||||
)
|
||||
def test_status_uses_manifest_as_three_way_hash_baseline(
|
||||
tmp_path: Path,
|
||||
source_content: str | None,
|
||||
target_content: str | None,
|
||||
expected: SyncState,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(
|
||||
tmp_path,
|
||||
source_content=source_content,
|
||||
target_content=target_content,
|
||||
)
|
||||
|
||||
service = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
source_roots={"demo": source_root},
|
||||
)
|
||||
|
||||
comparison = service.status("demo")[0]
|
||||
assert comparison.state is expected
|
||||
assert comparison.safe_to_apply is (
|
||||
expected is SyncState.SOURCE_CHANGED
|
||||
)
|
||||
|
||||
|
||||
def test_status_resolves_source_root_from_environment_without_writing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(
|
||||
tmp_path,
|
||||
source_content="upstream fix\n",
|
||||
)
|
||||
manifest_before = (repository_root / "config/source-manifests/demo.json").read_bytes()
|
||||
target_before = (repository_root / "src/runtime/job.py").read_bytes()
|
||||
|
||||
service = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
environ={"GYXX_SOURCE_DEMO_ROOT": str(source_root)},
|
||||
)
|
||||
|
||||
assert service.status("demo")[0].state is SyncState.SOURCE_CHANGED
|
||||
assert (repository_root / "config/source-manifests/demo.json").read_bytes() == manifest_before
|
||||
assert (repository_root / "src/runtime/job.py").read_bytes() == target_before
|
||||
|
||||
|
||||
def test_apply_safe_copies_source_and_atomically_refreshes_manifest(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(
|
||||
tmp_path,
|
||||
source_content="upstream fix\n",
|
||||
)
|
||||
manifest_path = repository_root / "config/source-manifests/demo.json"
|
||||
replace_calls: list[tuple[Path, Path]] = []
|
||||
|
||||
import gyxx_flow.source_sync.service as service_module
|
||||
|
||||
real_replace = service_module.os.replace
|
||||
|
||||
def recording_replace(source: str | Path, target: str | Path) -> None:
|
||||
replace_calls.append((Path(source), Path(target)))
|
||||
real_replace(source, target)
|
||||
|
||||
monkeypatch.setattr(service_module.os, "replace", recording_replace)
|
||||
service = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
source_roots={"demo": source_root},
|
||||
)
|
||||
|
||||
result = service.apply_safe("demo")
|
||||
|
||||
assert result.applied == ("src/runtime/job.py",)
|
||||
assert result.skipped == ()
|
||||
assert (repository_root / "src/runtime/job.py").read_text(
|
||||
encoding="utf-8"
|
||||
) == "upstream fix\n"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
entry = manifest["files"][0]
|
||||
assert entry["source_sha256"] == _digest("upstream fix\n")
|
||||
assert entry["target_sha256"] == _digest("upstream fix\n")
|
||||
assert any(target == manifest_path for _, target in replace_calls)
|
||||
assert not list(manifest_path.parent.glob("*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("transformed", "target_content", "expected_state"),
|
||||
[
|
||||
(True, "baseline\n", SyncState.SOURCE_CHANGED),
|
||||
(False, "local fix\n", SyncState.CONFLICT),
|
||||
],
|
||||
)
|
||||
def test_apply_safe_does_not_overwrite_transformed_or_conflicting_targets(
|
||||
tmp_path: Path,
|
||||
transformed: bool,
|
||||
target_content: str,
|
||||
expected_state: SyncState,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(
|
||||
tmp_path,
|
||||
source_content="upstream fix\n",
|
||||
target_content=target_content,
|
||||
transformed=transformed,
|
||||
)
|
||||
target_path = repository_root / "src/runtime/job.py"
|
||||
manifest_path = repository_root / "config/source-manifests/demo.json"
|
||||
manifest_before = manifest_path.read_bytes()
|
||||
|
||||
service = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
source_roots={"demo": source_root},
|
||||
)
|
||||
result = service.apply_safe("demo")
|
||||
|
||||
assert result.applied == ()
|
||||
assert result.skipped == ("src/runtime/job.py",)
|
||||
assert service.status("demo")[0].state is expected_state
|
||||
assert target_path.read_text(encoding="utf-8") == target_content
|
||||
assert manifest_path.read_bytes() == manifest_before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_relative_path", "target_relative_path"),
|
||||
[
|
||||
("../outside.py", "src/runtime/job.py"),
|
||||
("job.py", "../outside.py"),
|
||||
("C:/outside.py", "src/runtime/job.py"),
|
||||
("job.py", "C:/outside.py"),
|
||||
],
|
||||
)
|
||||
def test_rejects_absolute_paths_and_parent_traversal(
|
||||
tmp_path: Path,
|
||||
source_relative_path: str,
|
||||
target_relative_path: str,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(
|
||||
tmp_path,
|
||||
source_relative_path=source_relative_path,
|
||||
target_relative_path=target_relative_path,
|
||||
)
|
||||
service = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
source_roots={"demo": source_root},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="unsafe relative path"):
|
||||
service.status("demo")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_relative_path",
|
||||
[".env", "credentials.json", "browser/cookies.json", "private.pem"],
|
||||
)
|
||||
def test_rejects_sensitive_source_files(
|
||||
tmp_path: Path,
|
||||
source_relative_path: str,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(
|
||||
tmp_path,
|
||||
source_relative_path=source_relative_path,
|
||||
)
|
||||
service = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
source_roots={"demo": source_root},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="sensitive"):
|
||||
service.status("demo")
|
||||
|
||||
|
||||
def test_status_reports_unmapped_source_files_but_not_excluded_or_sensitive_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(tmp_path)
|
||||
manifest_path = repository_root / "config/source-manifests/demo.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["intentionally_excluded"] = [
|
||||
{"pattern": "tmp_*.py", "reason": "temporary diagnostics"},
|
||||
{
|
||||
"source_relative_path": "inspect_manual.py",
|
||||
"reason": "manual diagnostic",
|
||||
},
|
||||
]
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
(source_root / "new_job.py").write_text("print('new')\n", encoding="utf-8")
|
||||
(source_root / "tmp_probe.py").write_text("temporary\n", encoding="utf-8")
|
||||
(source_root / "inspect_manual.py").write_text("manual\n", encoding="utf-8")
|
||||
(source_root / ".env").write_text("TOKEN=secret\n", encoding="utf-8")
|
||||
cache = source_root / "__pycache__"
|
||||
cache.mkdir()
|
||||
(cache / "job.pyc").write_bytes(b"cache")
|
||||
root_profile = source_root / ".chrome_profile"
|
||||
root_profile.mkdir()
|
||||
(root_profile / "session.txt").write_text("browser state\n", encoding="utf-8")
|
||||
root_logs = source_root / "logs"
|
||||
root_logs.mkdir()
|
||||
(root_logs / "run.log").write_text("generated log\n", encoding="utf-8")
|
||||
|
||||
comparisons = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
source_roots={"demo": source_root},
|
||||
).status("demo")
|
||||
|
||||
assert [item.source_relative_path for item in comparisons] == [
|
||||
"job.py",
|
||||
"new_job.py",
|
||||
]
|
||||
candidate = comparisons[1]
|
||||
assert candidate.state is SyncState.NEW_SOURCE
|
||||
assert candidate.target_relative_path == "src/runtime/new_job.py"
|
||||
assert candidate.safe_to_apply is False
|
||||
|
||||
|
||||
def test_apply_safe_rolls_back_targets_when_manifest_commit_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, source_root, config_path = _write_project(
|
||||
tmp_path,
|
||||
source_content="upstream fix\n",
|
||||
)
|
||||
target_path = repository_root / "src/runtime/job.py"
|
||||
manifest_path = repository_root / "config/source-manifests/demo.json"
|
||||
original_target = target_path.read_bytes()
|
||||
original_manifest = manifest_path.read_bytes()
|
||||
|
||||
import gyxx_flow.source_sync.service as service_module
|
||||
|
||||
def fail_manifest_commit(path: Path, payload: object) -> None:
|
||||
del path, payload
|
||||
raise OSError("injected manifest failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"_atomic_write_json",
|
||||
fail_manifest_commit,
|
||||
)
|
||||
service = SourceSyncService(
|
||||
repository_root,
|
||||
config_path=config_path,
|
||||
source_roots={"demo": source_root},
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="injected manifest failure"):
|
||||
service.apply_safe("demo")
|
||||
|
||||
assert target_path.read_bytes() == original_target
|
||||
assert manifest_path.read_bytes() == original_manifest
|
||||
assert not list(repository_root.rglob("*.tmp"))
|
||||
Reference in New Issue
Block a user