Add channel-aware release tooling and updater recovery

This commit is contained in:
2026-09-10 00:39:37 +02:00
parent cc48bf45d2
commit 0a89323533
42 changed files with 7840 additions and 0 deletions

View File

@@ -0,0 +1,603 @@
from __future__ import annotations
import hashlib
import io
import json
from pathlib import Path
from types import SimpleNamespace
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherRemoteHistory,
LauncherRemoteRelease,
LauncherUpdateError,
ManifestFile,
UpdateManifest,
_activate_release,
_fetch_historical_manifest,
_recover_interrupted_activation,
_validate_release_directory,
launcher_rollback_candidates,
parse_launcher_release_history,
rollback_launcher_update,
)
def history_release(
version: str,
channel: str,
*,
status: str = "active",
) -> dict[str, object]:
tag = f"v{version}"
base = f"https://updates.example.test/releases/download/{tag}"
return {
"channel": channel,
"compatibility": {"config_schema": 1, "minimum_config_schema": 1},
"manifest_signature_url": f"{base}/manifest.json.sig",
"manifest_url": f"{base}/manifest.json",
"release_date": "2026-09-08T12:00:00Z",
"release_id": 42,
"release_notes_url": f"https://updates.example.test/releases/tag/{tag}",
"status": status,
"tag": tag,
"title": f"Launcher {version}",
"version": version,
}
def history_bytes(releases: list[dict[str, object]]) -> bytes:
return (
json.dumps(
{
"application": "PristonTale-EU",
"generated_at": "2026-09-08T12:01:00Z",
"releases": releases,
"schema": 1,
},
sort_keys=True,
)
+ "\n"
).encode("utf-8")
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str, *, extra_file: bool = False) -> None:
files = {
"release.json": (
json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
},
sort_keys=True,
)
+ "\n"
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
if extra_file:
files["ptlinux/new_feature.py"] = b"only in the newer release\n"
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
def archived_release(root: Path, version: str) -> tuple[Path, tuple[ManifestFile, ...]]:
release = root / "payload"
make_release(release, version)
archive = root / f"PristonTale-EU-{version}.tar.gz"
with tarfile.open(archive, "w:gz") as bundle:
for path in sorted(release.rglob("*")):
relative = path.relative_to(release).as_posix()
info = tarfile.TarInfo(relative)
info.mode = 0o755 if path.is_dir() else 0o644
if path.is_dir():
info.type = tarfile.DIRTYPE
bundle.addfile(info)
else:
data = path.read_bytes()
info.size = len(data)
bundle.addfile(info, io.BytesIO(data))
files = tuple(
ManifestFile(
path=path.relative_to(release).as_posix(),
size=path.stat().st_size,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
)
for path in sorted(release.rglob("*"))
if path.is_file()
)
return archive, files
class RemoteHistoryTests(unittest.TestCase):
def test_release_validation_removes_generated_bytecode_caches(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir(mode=0o775)
(cache / "cli.cpython-314.pyc").write_bytes(b"generated bytecode")
cache.chmod(0o775)
self.assertEqual(_validate_release_directory(release), "1.8.0")
self.assertFalse(cache.exists())
def test_release_validation_rejects_unexpected_cache_content(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir()
unexpected = cache / "payload.txt"
unexpected.write_text("not bytecode\n", encoding="utf-8")
with self.assertRaisesRegex(LauncherUpdateError, "unexpected path"):
_validate_release_directory(release)
self.assertTrue(unexpected.is_file())
def test_history_is_strict_and_drives_channel_aware_rollback(self) -> None:
data = history_bytes(
[
history_release("1.9.0-beta.2", "prerelease"),
history_release("1.9.0-beta.1", "prerelease", status="withdrawn"),
history_release("1.8.0", "stable"),
history_release("1.7.4", "stable"),
]
)
history = parse_launcher_release_history(
data,
history_url="https://updates.example.test/updates/history.json",
)
stable = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="stable",
)
prerelease = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="prerelease",
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.8.0", "1.7.4"],
)
def test_history_rejects_unsorted_and_detached_signature_urls(self) -> None:
with self.assertRaisesRegex(LauncherUpdateError, "descending"):
parse_launcher_release_history(
history_bytes(
[
history_release("1.8.0", "stable"),
history_release("1.9.0", "stable"),
]
),
history_url="https://updates.example.test/updates/history.json",
)
detached = history_release("1.8.0", "stable")
detached["manifest_signature_url"] = "https://attacker.test/manifest.sig"
with self.assertRaisesRegex(LauncherUpdateError, "signature URL"):
parse_launcher_release_history(
history_bytes([detached]),
history_url="https://updates.example.test/updates/history.json",
)
def test_signed_history_compatibility_overrides_immutable_manifest_defaults(self) -> None:
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
config_schema=3,
minimum_config_schema=2,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=1,
minimum_config_schema=1,
)
with (
patch(
"ptlinux.self_update._fetch_small_file",
side_effect=[b"manifest", b"signature"],
),
patch("ptlinux.self_update._verify_manifest_signature"),
patch("ptlinux.self_update.parse_update_manifest", return_value=manifest),
):
result = _fetch_historical_manifest(
record,
Path("public.pem"),
timeout_seconds=1,
)
self.assertEqual(result.config_schema, 3)
self.assertEqual(result.minimum_config_schema, 2)
class RemoteRollbackTests(unittest.TestCase):
def test_remote_rollback_verifies_and_activates_a_complete_target_release(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0-beta.3"
make_release(current, current.name, extra_file=True)
paths.launcher_current.symlink_to(Path("releases") / current.name)
archive, files = archived_release(root, "1.8.0")
archive_bytes = archive.read_bytes()
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/PristonTale-EU-1.8.0.tar.gz",
archive_sha256=hashlib.sha256(archive_bytes).hexdigest(),
archive_size=len(archive_bytes),
published_at="2026-09-08T12:00:00Z",
notes_url="https://updates.example.test/releases/tag/v1.8.0",
tag="v1.8.0",
files=files,
manifest_sha256="a" * 64,
)
record_value = history_release("1.8.0", "stable")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url=str(record_value["manifest_url"]),
manifest_signature_url=str(record_value["manifest_signature_url"]),
release_date=str(record_value["release_date"]),
release_notes_url=str(record_value["release_notes_url"]),
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/updates/history.json",
releases=(record,),
history_sha256="b" * 64,
)
paths.config.mkdir(parents=True)
paths.settings_file.write_text(
json.dumps({"schema": 1, "user_choice": "preserve me"}),
encoding="utf-8",
)
def copy_archive(_manifest: object, destination: Path, **_kwargs: object) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive_bytes)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive", side_effect=copy_archive),
patch("ptlinux.self_update._self_check_release"),
):
result = rollback_launcher_update(
paths,
target_version="1.8.0",
selected_channel="prerelease",
)
self.assertEqual(result.from_version, "1.9.0-beta.3")
self.assertEqual(result.to_version, "1.8.0")
self.assertEqual(paths.launcher_current.resolve().name, "1.8.0")
self.assertEqual(paths.launcher_previous.resolve().name, "1.9.0-beta.3")
self.assertFalse((paths.launcher_current / "ptlinux/new_feature.py").exists())
self.assertEqual(
json.loads(paths.settings_file.read_text(encoding="utf-8"))["user_choice"],
"preserve me",
)
operations = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)["operations"]
self.assertEqual(operations[-1]["reason"], "manual")
self.assertTrue(operations[-1]["rollback"])
integrity_record = paths.launcher_release_integrity / "1.8.0.json"
self.assertTrue(integrity_record.is_file())
(paths.launcher_current / "ptlinux/__init__.py").write_text(
"locally modified after verification\n",
encoding="utf-8",
)
_activate_release(paths, current)
with self.assertRaisesRegex(LauncherUpdateError, "signed manifest"):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_remote_rollback_rejects_incompatible_configuration_before_download(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"schema":1}\n', encoding="utf-8")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=3,
minimum_config_schema=2,
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "config schemas 2 through 3"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_rollback_refuses_to_mutate_while_a_managed_game_is_active(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with (
patch("ptlinux.self_update.managed_game_process_is_active", return_value=True),
self.assertRaisesRegex(RuntimeError, "active managed game client"),
):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_precheck_rejects_insufficient_disk_space(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch(
"ptlinux.self_update.shutil.disk_usage",
return_value=SimpleNamespace(free=0),
),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "insufficient free disk space"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_failed_link_activation_restores_the_original_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
from ptlinux import self_update
real_replace = self_update._replace_release_link
calls = 0
def fail_current_once(link: Path, release: Path, releases: Path) -> None:
nonlocal calls
calls += 1
if calls == 2:
raise OSError("simulated current-link failure")
real_replace(link, release, releases)
with (
patch(
"ptlinux.self_update._replace_release_link",
side_effect=fail_current_once,
),
self.assertRaisesRegex(OSError, "simulated current-link failure"),
):
_activate_release(paths, target)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
def test_crash_journal_restores_original_links_even_if_target_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases/1.9.0"))
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "applying",
"current": current.name,
"previous": previous.name,
"target": "1.9.0",
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
self.assertEqual(
[call.args[0] for call in send.call_args_list],
["rollback_restore_started", "rollback_restore_success"],
)
def test_completed_activation_journal_is_cleaned_without_rolling_back(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / target.name)
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "activated",
"current": current.name,
"previous": previous.name,
"target": target.name,
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), target.resolve())
self.assertEqual(paths.launcher_previous.resolve(), current.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
send.assert_not_called()
def test_diagnostic_write_failure_does_not_reverse_completed_rollback(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with patch(
"ptlinux.self_update._write_json",
side_effect=OSError("read-only diagnostics directory"),
):
result = rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), previous.resolve())
self.assertFalse(result.state_recorded)
self.assertIn("could not be saved", result.warning or "")
if __name__ == "__main__":
unittest.main()