vpnbot-xray-patches/tests/test_release_pipeline.py
loop-uh 28b36ad535
All checks were successful
Follow official Xray dev releases / candidate (push) Successful in 2m40s
Test and build VPnBot Xray patches / test-and-build (push) Successful in 12m31s
Наблюдатель выпусков Xray: Telegram по сетевому контракту хоста
Симптом: vpnbot-xray-release-alert.service падал на каждой попытке
доставки (раз в RETRY_SECONDS=900): 20 с таймаута на заблокированном
149.154.166.110:443, затем ENETUNREACH на IPv6. Ни авария, ни сообщение
о восстановлении не доходили.

Первопричина: монитор слал sendMessage прямым urllib.urlopen на
api.telegram.org, в обход сетевого контракта, которым пользуются все
процессы бота: VPNBOT_TELEGRAM_IP_FAMILY=ipv4, резервные
VPNBOT_TELEGRAM_API_FALLBACK_IPV4S и relay-туннели node manager из
/run/vpnbot-node-manager/telegram-egress.json.

Исправление: контракт читается из того же root-only runtime-env бота,
откуда уже берётся токен (без копии в /etc и без импорта кода бота).
Маршруты как у бота: свежая проекция со здоровыми портами api.telegram.org
- только loopback-relay; иначе резервные IPv4, затем DNS выбранного
семейства. Меняется лишь TCP-адрес, TLS держит SNI и проверку сертификата
api.telegram.org.

Инвариант: следующий маршрут пробуется только если соединение не
установилось и запрос не ушёл; ушедший запрос без ответа -
telegram_delivery_ambiguous без повтора в этом запуске. Каждый отказ
несёт typed-код (telegram_egress_config_invalid, telegram_no_route,
telegram_connect_failed, telegram_delivery_ambiguous,
telegram_http_status, telegram_response_invalid, telegram_rejected).

Проверка: unittest discover - 44 теста, включая TLS-сервер с SNI
api.telegram.org за loopback-портом; живой запрос getMe с заведомо
неверным токеном через relay 18443/18444 и резервный 149.154.167.220
получил 401 от Telegram.

Claude-Session: local_e733a8c5-58d7-482a-bce7-150abdb901db
2026-09-24 20:04:13 +03:00

1142 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location(
"release_pipeline", ROOT / "scripts" / "release_pipeline.py"
)
assert SPEC and SPEC.loader
release_pipeline = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = release_pipeline
SPEC.loader.exec_module(release_pipeline)
PROMOTER_SPEC = importlib.util.spec_from_file_location(
"pilot_and_promote", ROOT / "scripts" / "pilot_and_promote.py"
)
assert PROMOTER_SPEC and PROMOTER_SPEC.loader
pilot_and_promote = importlib.util.module_from_spec(PROMOTER_SPEC)
sys.modules[PROMOTER_SPEC.name] = pilot_and_promote
PROMOTER_SPEC.loader.exec_module(pilot_and_promote)
ALERT_SPEC = importlib.util.spec_from_file_location(
"release_alert_monitor", ROOT / "scripts" / "release_alert_monitor.py"
)
assert ALERT_SPEC and ALERT_SPEC.loader
release_alert_monitor = importlib.util.module_from_spec(ALERT_SPEC)
sys.modules[ALERT_SPEC.name] = release_alert_monitor
ALERT_SPEC.loader.exec_module(release_alert_monitor)
class ReleasePipelineTests(unittest.TestCase):
@staticmethod
def alert_settings(state_dir: Path) -> release_alert_monitor.Settings:
return release_alert_monitor.Settings(
actions_url="https://forgejo.invalid/actions/runs",
releases_url="https://forgejo.invalid/releases",
workflow_id="candidate.yml",
bot_env_file=state_dir / "vpnbot.env",
chat_id="6483277608",
promoter_state_dir=state_dir / "promoter",
state_dir=state_dir / "alerts",
stall_seconds=7200,
reminder_seconds=3600,
retry_seconds=60,
request_timeout_seconds=20,
)
def test_repository_patch_set_is_exact_and_stable(self) -> None:
rows = release_pipeline.read_patch_rows(ROOT)
self.assertEqual(len(rows), 3)
self.assertRegex(release_pipeline.patchset_sha256(rows), r"^[0-9a-f]{64}$")
def test_http_requests_identify_the_release_robot_with_project_contact(self) -> None:
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b"ok"
response.__exit__.return_value = False
with mock.patch.object(
release_pipeline.urllib.request,
"urlopen",
return_value=response,
) as urlopen:
self.assertEqual(
release_pipeline.request_bytes("https://example.invalid/release"),
b"ok",
)
request = urlopen.call_args.args[0]
self.assertEqual(
request.get_header("User-agent"),
release_pipeline.HTTP_USER_AGENT,
)
self.assertIn("https://git.zapret.moe/", release_pipeline.HTTP_USER_AGENT)
def test_read_request_retries_remote_disconnect_but_write_does_not(self) -> None:
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b"feed"
response.__exit__.return_value = False
with (
mock.patch.object(
release_pipeline.urllib.request,
"urlopen",
side_effect=[
release_pipeline.http.client.RemoteDisconnected(),
response,
],
) as urlopen,
mock.patch.object(release_pipeline.time, "sleep") as sleep,
):
self.assertEqual(
release_pipeline.request_bytes("https://example.invalid/feed"),
b"feed",
)
self.assertEqual(urlopen.call_count, 2)
sleep.assert_called_once_with(0.5)
with mock.patch.object(
release_pipeline.urllib.request,
"urlopen",
side_effect=release_pipeline.http.client.RemoteDisconnected(),
) as urlopen:
with self.assertRaisesRegex(release_pipeline.PipelineError, "request failed"):
release_pipeline.request_bytes(
"https://example.invalid/release",
method="POST",
payload=b"{}",
)
urlopen.assert_called_once()
def test_create_release_ignores_mutation_response_assets_and_refetches_exact_tag(self) -> None:
tag = "v26.7.28-vpnbot.9-candidate.1"
target = "a" * 40
mutation_response = {
"id": 77,
"tag_name": tag,
"assets": [
{"name": "unrelated.jpg"},
{"name": "unrelated.jpg"},
],
}
canonical = {
"id": 77,
"tag_name": tag,
"target_commitish": target,
"draft": True,
"prerelease": True,
"assets": [],
}
with (
mock.patch.object(
release_pipeline,
"request_json",
return_value=mutation_response,
),
mock.patch.object(
release_pipeline,
"release_by_tag",
return_value=canonical,
) as by_tag,
):
created = release_pipeline.create_release(
"https://forgejo.invalid/releases",
token="secret",
tag=tag,
target=target,
prerelease=True,
title="candidate",
body="body",
)
self.assertIs(created, canonical)
by_tag.assert_called_once_with(
"https://forgejo.invalid/releases",
tag,
token="secret",
)
def test_create_release_rejects_refetched_identity_mismatch(self) -> None:
with (
mock.patch.object(
release_pipeline,
"request_json",
return_value={"id": 77},
),
mock.patch.object(
release_pipeline,
"release_by_tag",
return_value={
"id": 78,
"tag_name": "v26.7.28-vpnbot.9-candidate.1",
"target_commitish": "a" * 40,
"draft": True,
"prerelease": True,
},
),
):
with self.assertRaisesRegex(release_pipeline.PipelineError, "changed after creation"):
release_pipeline.create_release(
"https://forgejo.invalid/releases",
token="secret",
tag="v26.7.28-vpnbot.9-candidate.1",
target="a" * 40,
prerelease=True,
title="candidate",
body="body",
)
def test_latest_official_release_uses_only_safe_atom_link(self) -> None:
feed = b"""<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns='http://www.w3.org/2005/Atom'>
<entry>
<link rel='alternate' href='https://attacker.invalid/XTLS/Xray-core/releases/tag/v99.1.1'/>
</entry>
<entry>
<link rel='alternate' href='https://github.com/XTLS/Xray-core/releases/tag/v26.7.28'/>
</entry>
</feed>
"""
with mock.patch.object(release_pipeline, "request_bytes", return_value=feed) as request:
self.assertEqual(release_pipeline.latest_official_release(), "v26.7.28")
request.assert_called_once_with(
release_pipeline.OFFICIAL_RELEASES_FEED,
accept="application/atom+xml, application/xml;q=0.9",
)
def test_latest_official_release_rejects_invalid_feed(self) -> None:
with mock.patch.object(release_pipeline, "request_bytes", return_value=b"not xml"):
with self.assertRaisesRegex(release_pipeline.PipelineError, "invalid XML"):
release_pipeline.latest_official_release()
def test_official_git_metadata_comes_from_exact_temporary_tag(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
upstream = Path(raw_tmp) / "upstream"
upstream.mkdir()
subprocess.run(["git", "-C", str(upstream), "init", "-q"], check=True)
(upstream / "go.mod").write_text(
"module github.com/xtls/xray-core\n\ngo 1.26.4\n",
encoding="utf-8",
)
subprocess.run(["git", "-C", str(upstream), "add", "go.mod"], check=True)
environment = dict(os.environ)
environment.update(
{
"GIT_AUTHOR_DATE": "2026-07-28T07:59:48+00:00",
"GIT_COMMITTER_DATE": "2026-07-28T07:59:48+00:00",
}
)
subprocess.run(
[
"git",
"-C",
str(upstream),
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"commit",
"-q",
"-m",
"official tag",
],
check=True,
env=environment,
)
subprocess.run(
["git", "-C", str(upstream), "tag", "v26.7.28"], check=True
)
expected_commit = subprocess.check_output(
["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True
).strip()
with mock.patch.object(release_pipeline, "OFFICIAL_REPOSITORY", str(upstream)):
commit, epoch, commit_time, go_version = (
release_pipeline.official_git_metadata("v26.7.28")
)
self.assertEqual(commit, expected_commit)
self.assertEqual(epoch, 1785225588)
self.assertEqual(commit_time, "2026-07-28T07:59:48Z")
self.assertEqual(go_version, "1.26.4")
def test_official_git_failure_is_fail_closed(self) -> None:
with mock.patch.object(
release_pipeline.subprocess,
"run",
side_effect=subprocess.TimeoutExpired(["git", "fetch"], 180),
):
with self.assertRaisesRegex(release_pipeline.PipelineError, "git init"):
release_pipeline.official_git_metadata("v26.7.28")
def test_manifest_rejects_candidate_that_does_not_belong_to_proven(self) -> None:
assets = {}
for name, profile in release_pipeline.ARTIFACT_PROFILES.items():
assets[name] = {
"sha256": "d" * 64,
"size": 1,
**profile,
}
assets[f"{name}.dgst"] = {"sha256": "e" * 64, "size": 1}
manifest = {
"schema_version": release_pipeline.SCHEMA_VERSION,
"capability": release_pipeline.CAPABILITY,
"live_user_audit_capability": (
release_pipeline.LIVE_USER_AUDIT_CAPABILITY
),
"build_profile": release_pipeline.BUILD_PROFILE,
"upstream": {
"repository": release_pipeline.OFFICIAL_REPOSITORY,
"tag": "v26.7.28",
"commit": "a" * 40,
"commit_time": "2026-07-28T00:00:00Z",
"source_date_epoch": 1785196800,
},
"patch_repository": {"commit": "b" * 40},
"patches": {
"set_sha256": "c" * 64,
"items": [
{"name": f"000{index}-patch.patch", "sha256": str(index) * 64}
for index in range(1, 4)
],
},
"release": {
"candidate_tag": "v26.7.28-vpnbot.8-candidate.1",
"proven_tag": "v26.7.28-vpnbot.7",
},
"toolchain": {"go_version": "1.26"},
"assets": assets,
}
with self.assertRaisesRegex(release_pipeline.PipelineError, "does not belong"):
release_pipeline.validate_manifest(manifest)
def test_build_profile_change_forces_a_new_release_edition(self) -> None:
manifest = {
"upstream": {"commit": "a" * 40},
"patches": {"set_sha256": "b" * 64},
"capability": release_pipeline.CAPABILITY,
"build_profile": "legacy-single-amd64",
}
self.assertFalse(
release_pipeline.same_source_and_patches(
manifest,
"a" * 40,
"b" * 64,
)
)
def test_live_user_audit_capability_forces_a_new_release_edition(self) -> None:
manifest = {
"upstream": {"commit": "a" * 40},
"patches": {"set_sha256": "b" * 64},
"capability": release_pipeline.CAPABILITY,
"build_profile": release_pipeline.BUILD_PROFILE,
}
self.assertFalse(
release_pipeline.same_source_and_patches(
manifest,
"a" * 40,
"b" * 64,
)
)
manifest["live_user_audit_capability"] = (
release_pipeline.LIVE_USER_AUDIT_CAPABILITY
)
self.assertTrue(
release_pipeline.same_source_and_patches(
manifest,
"a" * 40,
"b" * 64,
)
)
def test_legacy_manifest_remains_readable_but_cannot_match_new_profile(self) -> None:
assets = {}
for name in (
"Xray-linux-64.zip",
"Xray-linux-arm64-v8a.zip",
"Xray-linux-arm32-v7a.zip",
):
assets[name] = {"sha256": "d" * 64, "size": 1}
assets[f"{name}.dgst"] = {"sha256": "e" * 64, "size": 1}
manifest = {
"schema_version": 1,
"capability": release_pipeline.CAPABILITY,
"upstream": {
"repository": release_pipeline.OFFICIAL_REPOSITORY,
"tag": "v26.7.28",
"commit": "a" * 40,
"commit_time": "2026-07-28T00:00:00Z",
"source_date_epoch": 1785196800,
},
"patch_repository": {"commit": "b" * 40},
"patches": {
"set_sha256": "c" * 64,
"items": [
{"name": f"000{index}-patch.patch", "sha256": str(index) * 64}
for index in range(1, 4)
],
},
"release": {
"candidate_tag": "v26.7.28-vpnbot.4-candidate.1",
"proven_tag": "v26.7.28-vpnbot.4",
},
"toolchain": {"go_version": "1.26.4"},
"assets": assets,
}
release_pipeline.validate_manifest(manifest)
self.assertFalse(
release_pipeline.same_source_and_patches(
manifest, "a" * 40, "c" * 64
)
)
def test_build_script_produces_baseline_and_goamd64_v3(self) -> None:
source = (ROOT / "scripts" / "build-release.sh").read_text(
encoding="utf-8"
)
self.assertIn("build_target Xray-linux-64.zip amd64 \"\" v1", source)
self.assertIn("build_target Xray-linux-64-v3.zip amd64 \"\" v3", source)
self.assertIn('go_environment+=("GOAMD64=${goamd64}")', source)
def test_build_script_captures_version_before_capability_checks(self) -> None:
source = (ROOT / "scripts" / "build-release.sh").read_text(
encoding="utf-8"
)
self.assertIn(
'version_statement="$("${package_directory}/xray" version)"',
source,
)
self.assertIn(
"[[ \"$version_statement\" == *'VPnBot capability: "
"vpnbot-live-user-audit-v1'* ]]",
source,
)
self.assertNotIn('xray" version \\\n | grep -Fq', source)
def test_proof_is_bound_to_the_exact_manifest(self) -> None:
manifest_bytes = b'{"schema_version":1}\n'
manifest = {"release": {"candidate_tag": "v26.7.28-vpnbot.4-candidate.1"}}
proof = {
"schema_version": 1,
"result": "passed",
"candidate_tag": "v26.7.28-vpnbot.4-candidate.1",
"manifest_sha256": release_pipeline.sha256_bytes(manifest_bytes),
"checks": {"canary": True},
}
release_pipeline.validate_proof(proof, manifest, manifest_bytes)
proof["manifest_sha256"] = "0" * 64
with self.assertRaisesRegex(release_pipeline.PipelineError, "manifest hash"):
release_pipeline.validate_proof(proof, manifest, manifest_bytes)
def test_atomic_write_does_not_leave_the_temporary_file(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
path = Path(raw_tmp) / "state.json"
release_pipeline.write_atomic(path, b"{}\n", mode=0o600)
self.assertEqual(path.read_bytes(), b"{}\n")
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(list(path.parent.glob(".*.new")), [])
def test_legacy_version_statement_maps_to_latest_same_base_proven(self) -> None:
settings = pilot_and_promote.Settings(
releases_url="https://forgejo.invalid/releases",
token_file=Path("/token"),
canary_node="canary",
canary_host="127.0.0.1",
canary_port=10222,
canary_user="root",
identity_file=Path("/key"),
known_hosts_file=Path("/known_hosts"),
updater_path="/usr/local/bin/vpnbot-xray-core-updater",
xray_path="/opt/vpnbot/xray-core/bin/xray",
config_dir="/opt/vpnbot/xray-core/config",
service_name="vpnbot-xray.service",
state_dir=Path("/state"),
canary_script=Path("/canary.py"),
timeout_seconds=900,
)
releases = [
{"tag_name": "v26.8.1-vpnbot.1", "draft": False, "prerelease": False},
{"tag_name": "v26.7.28-vpnbot.3", "draft": False, "prerelease": False},
]
statement = (
"Xray 26.7.28 (Xray, Penetrates Everything.) 0d9b32c (go1.26.4 linux/amd64)\n"
"VPnBot capability: vpnbot-active-revoke-v3"
)
with mock.patch.object(release_pipeline, "list_forgejo_releases", return_value=releases):
tag = pilot_and_promote.resolve_previous_proven_tag(settings, "token", statement)
self.assertEqual(tag, "v26.7.28-vpnbot.3")
def test_remote_version_keeps_capability_lines(self) -> None:
settings = pilot_and_promote.Settings(
releases_url="https://forgejo.invalid/releases",
token_file=Path("/token"),
canary_node="canary",
canary_host="127.0.0.1",
canary_port=10222,
canary_user="root",
identity_file=Path("/key"),
known_hosts_file=Path("/known_hosts"),
updater_path="/usr/local/bin/vpnbot-xray-core-updater",
xray_path="/opt/vpnbot/xray-core/bin/xray",
config_dir="/opt/vpnbot/xray-core/config",
service_name="vpnbot-xray.service",
state_dir=Path("/state"),
canary_script=Path("/canary.py"),
timeout_seconds=900,
)
output = (
b"Xray 26.7.28 (Xray, Penetrates Everything.) 0d9b32c (go1.26.4 linux/amd64)\n"
b"VPnBot capability: vpnbot-active-revoke-v3\n"
)
completed = __import__("subprocess").CompletedProcess([], 0, output, b"")
with mock.patch.object(pilot_and_promote, "remote_command", return_value=completed):
statement, tag = pilot_and_promote.remote_version(settings)
self.assertEqual(tag, "v26.7.28")
self.assertIn(release_pipeline.CAPABILITY, statement)
def test_release_alert_uses_only_latest_main_candidate_workflow(self) -> None:
runs = [
{
"id": 20,
"workflow_id": "candidate.yml",
"prettyref": "main",
"status": "failure",
"title": "new Xray",
"html_url": "https://forgejo.invalid/actions/runs/20",
},
{
"id": 19,
"workflow_id": "candidate.yml",
"prettyref": "main",
"status": "success",
},
{
"id": 99,
"workflow_id": "ci.yml",
"prettyref": "main",
"status": "failure",
},
]
condition = release_alert_monitor.evaluate_patch_pipeline(runs, "candidate.yml")
self.assertEqual(condition.status, "problem")
self.assertEqual(condition.signature, "run:20:failure")
runs[0]["status"] = "running"
self.assertEqual(
release_alert_monitor.evaluate_patch_pipeline(runs, "candidate.yml").status,
"pending",
)
runs[0]["status"] = "success"
self.assertEqual(
release_alert_monitor.evaluate_patch_pipeline(runs, "candidate.yml").status,
"healthy",
)
def test_release_alert_reads_structured_public_run_when_api_is_empty(self) -> None:
listing = """
<div class="flex-item tw-items-center">
<div><a href="/zapretkvn/vpnbot-xray-patches/actions/runs/25">run</a></div>
<b>#25</b>
<a class="ui label run-list-ref gt-ellipsis" data-tooltip-content="main">main</a>
<relative-time datetime="2026-08-09T09:17:31+03:00"></relative-time>
</div>
"""
state = {
"state": {
"run": {
"status": "waiting",
"title": "Follow official Xray dev releases",
"commit": {"branch": {"name": "main"}},
}
}
}
detail = (
'<div data-initial-post-response="'
+ __import__("html").escape(json.dumps(state), quote=True)
+ '"></div>'
)
with mock.patch.object(
release_alert_monitor,
"fetch_text",
side_effect=(listing, detail),
):
runs = release_alert_monitor.fetch_actions_html(
"https://git.zapret.moe/api/v1/repos/"
"zapretkvn/vpnbot-xray-patches/actions/runs",
"candidate.yml",
20,
)
self.assertEqual(len(runs), 1)
self.assertEqual(runs[0]["id"], 25)
self.assertEqual(runs[0]["status"], "waiting")
self.assertEqual(runs[0]["prettyref"], "main")
def test_release_alert_escalates_a_stalled_pending_workflow(self) -> None:
condition = release_alert_monitor.evaluate_patch_pipeline(
[
{
"id": 25,
"workflow_id": "candidate.yml",
"prettyref": "main",
"status": "waiting",
"title": "scheduled",
"created_at": "2026-08-09T06:00:00Z",
"html_url": "https://forgejo.invalid/actions/runs/25",
}
],
"candidate.yml",
now_epoch=int(
__import__("datetime").datetime(
2026,
8,
9,
9,
0,
tzinfo=__import__("datetime").timezone.utc,
).timestamp()
),
stall_seconds=7200,
)
self.assertEqual(condition.status, "problem")
self.assertEqual(condition.signature, "run:25:stalled:waiting")
self.assertIn("runner", condition.action)
def test_candidate_stall_is_bound_to_publication_time(self) -> None:
candidate = release_alert_monitor.Candidate(
tag="v26.8.1-vpnbot.1-candidate.1",
proven_tag="v26.8.1-vpnbot.1",
published_epoch=1_000,
release_url="https://forgejo.invalid/candidate",
)
before = release_alert_monitor.evaluate_candidate_stall([candidate], 8_199, 7_200)
stalled = release_alert_monitor.evaluate_candidate_stall([candidate], 8_200, 7_200)
self.assertEqual(before.status, "healthy")
self.assertEqual(stalled.status, "problem")
self.assertIn(candidate.tag, stalled.detail)
def test_canary_failure_distinguishes_failed_rollback_and_retry(self) -> None:
candidate = release_alert_monitor.Candidate(
tag="v26.8.1-vpnbot.1-candidate.1",
proven_tag="v26.8.1-vpnbot.1",
published_epoch=1_000,
release_url="https://forgejo.invalid/candidate",
)
failed_state = {
"phase": "failed_rollback_failed",
"failed_at": "2026-08-07T00:00:00Z",
"error": "pilot failed; rollback failed",
}
failed = release_alert_monitor.evaluate_canary(
[candidate], {candidate.tag: failed_state}
)
self.assertEqual(failed.status, "problem")
self.assertEqual(failed.severity, "critical")
self.assertIn("ОТКАТ ТОЖЕ НЕ УДАЛСЯ", failed.detail)
retrying = dict(failed_state, phase="installing")
self.assertEqual(
release_alert_monitor.evaluate_canary(
[candidate], {candidate.tag: retrying}
).status,
"pending",
)
def test_alert_state_deduplicates_pending_and_sends_one_recovery(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
settings = self.alert_settings(Path(raw_tmp))
sent: list[str] = []
def sender(text: str) -> int:
sent.append(text)
return len(sent) + 100
problem = release_alert_monitor.Condition(
name="canary_failed",
status="problem",
signature="candidate:failed",
headline="Canary failed",
detail="diagnostic",
action="inspect",
)
release_alert_monitor.reconcile(settings, [problem], sender, 1_000)
release_alert_monitor.reconcile(settings, [problem], sender, 1_100)
release_alert_monitor.reconcile(
settings,
[release_alert_monitor.Condition(name="canary_failed", status="pending")],
sender,
1_200,
)
self.assertEqual(len(sent), 1)
healthy = release_alert_monitor.Condition(
name="canary_failed", status="healthy"
)
release_alert_monitor.reconcile(settings, [healthy], sender, 1_300)
release_alert_monitor.reconcile(settings, [healthy], sender, 1_400)
self.assertEqual(len(sent), 2)
self.assertIn("состояние выпуска восстановлено", sent[-1])
def test_failed_delivery_waits_before_retry(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
settings = self.alert_settings(Path(raw_tmp))
attempts = 0
def failing_sender(_text: str) -> int:
nonlocal attempts
attempts += 1
raise release_pipeline.PipelineError("delivery failed")
problem = release_alert_monitor.Condition(
name="patch_pipeline",
status="problem",
signature="run:42:failure",
headline="Workflow failed",
detail="diagnostic",
action="inspect",
)
with self.assertRaisesRegex(release_pipeline.PipelineError, "delivery failed"):
release_alert_monitor.reconcile(settings, [problem], failing_sender, 1_000)
release_alert_monitor.reconcile(settings, [problem], failing_sender, 1_030)
self.assertEqual(attempts, 1)
def test_published_proven_removes_candidate_from_monitoring(self) -> None:
candidate_release = {
"tag_name": "v26.8.1-vpnbot.1-candidate.1",
"draft": False,
"prerelease": True,
"published_at": "2026-08-07T10:00:00Z",
"html_url": "https://forgejo.invalid/candidate",
}
proven_release = {
"tag_name": "v26.8.1-vpnbot.1",
"draft": False,
"prerelease": False,
}
manifest = {
"release": {
"candidate_tag": candidate_release["tag_name"],
"proven_tag": proven_release["tag_name"],
}
}
with mock.patch.object(
release_pipeline, "read_release_manifest", return_value=manifest
):
candidates = release_alert_monitor.unproven_candidates(
[candidate_release, proven_release]
)
self.assertEqual(candidates, [])
def test_direct_monitor_loads_only_known_root_only_environment(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
env_path = Path(raw_tmp) / "release-alert.env"
env_path.write_text(
"VPNBOT_XRAY_ALERT_CHAT_ID=6483277608\n"
"VPNBOT_XRAY_ALERT_STALL_SECONDS='7200'\n",
encoding="utf-8",
)
env_path.chmod(0o600)
with mock.patch.dict(
os.environ,
{
"VPNBOT_XRAY_ALERT_CHAT_ID": "",
"VPNBOT_XRAY_ALERT_STALL_SECONDS": "",
},
clear=False,
):
os.environ.pop("VPNBOT_XRAY_ALERT_CHAT_ID")
os.environ.pop("VPNBOT_XRAY_ALERT_STALL_SECONDS")
release_alert_monitor.load_alert_environment(env_path)
self.assertEqual(
os.environ["VPNBOT_XRAY_ALERT_CHAT_ID"], "6483277608"
)
self.assertEqual(
os.environ["VPNBOT_XRAY_ALERT_STALL_SECONDS"], "7200"
)
def test_monitor_lock_is_root_only(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
lock_path = Path(raw_tmp) / "monitor.lock"
with release_alert_monitor.monitor_lock(lock_path) as lock:
self.assertFalse(lock.closed)
self.assertEqual(lock_path.stat().st_mode & 0o777, 0o600)
TOKEN = "123456789:" + "A" * 35
class TelegramEgressContractTests(unittest.TestCase):
"""The observer reaches Telegram through the host egress contract."""
@staticmethod
def runtime(**overrides: object) -> release_alert_monitor.BotRuntime:
values: dict[str, object] = {
"token": TOKEN,
"ip_family": release_alert_monitor.socket.AF_INET,
"fallback_ipv4s": ("149.154.167.220",),
"egress_health_path": Path("/nonexistent/telegram-egress.json"),
}
values.update(overrides)
return release_alert_monitor.BotRuntime(**values) # type: ignore[arg-type]
@staticmethod
def write_env(directory: Path, text: str) -> Path:
path = directory / "vpnbot.env"
path.write_text(text, encoding="utf-8")
path.chmod(0o600)
return path
@staticmethod
def write_projection(directory: Path, payload: dict[str, object]) -> Path:
path = directory / "telegram-egress.json"
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def assert_code(self, raised: unittest.case._AssertRaisesContext, code: str) -> None: # type: ignore[name-defined]
self.assertEqual(raised.exception.code, code)
self.assertNotIn(TOKEN, str(raised.exception))
def test_runtime_reads_the_bot_egress_policy_next_to_the_token(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
path = self.write_env(
Path(raw_tmp),
f"VPNBOT_BOT_TOKEN={TOKEN}\n"
"VPNBOT_TELEGRAM_IP_FAMILY=ipv4\n"
"VPNBOT_TELEGRAM_API_FALLBACK_IPV4S='149.154.167.220; 149.154.167.220,149.154.175.50'\n"
"UNRELATED=1\n",
)
runtime = release_alert_monitor.load_bot_runtime(path)
self.assertEqual(runtime.token, TOKEN)
self.assertEqual(runtime.ip_family, release_alert_monitor.socket.AF_INET)
self.assertEqual(runtime.fallback_ipv4s, ("149.154.167.220", "149.154.175.50"))
self.assertEqual(
runtime.egress_health_path,
release_alert_monitor.DEFAULT_TELEGRAM_EGRESS_HEALTH_PATH,
)
def test_runtime_defaults_to_ipv4_like_the_bot(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
path = self.write_env(
Path(raw_tmp),
f"VPNBOT_BOT_TOKEN={TOKEN}\nVPNBOT_TELEGRAM_IP_FAMILY=\n",
)
runtime = release_alert_monitor.load_bot_runtime(path)
self.assertEqual(runtime.ip_family, release_alert_monitor.socket.AF_INET)
self.assertEqual(runtime.fallback_ipv4s, ())
def test_runtime_rejects_an_invalid_egress_policy_with_a_typed_code(self) -> None:
for line in (
"VPNBOT_TELEGRAM_IP_FAMILY=carrier-pigeon",
"VPNBOT_TELEGRAM_API_FALLBACK_IPV4S=2001:67c:4e8::1",
"VPNBOT_TELEGRAM_API_FALLBACK_IPV4S=not-an-ip",
"VPNBOT_TELEGRAM_EGRESS_HEALTH_PATH=relative/egress.json",
):
with self.subTest(line=line), tempfile.TemporaryDirectory() as raw_tmp:
path = self.write_env(Path(raw_tmp), f"VPNBOT_BOT_TOKEN={TOKEN}\n{line}\n")
with self.assertRaises(release_alert_monitor.TelegramDeliveryError) as raised:
release_alert_monitor.load_bot_runtime(path)
self.assert_code(raised, release_alert_monitor.TELEGRAM_EGRESS_CONFIG_INVALID)
def test_runtime_refuses_a_duplicated_egress_key(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
path = self.write_env(
Path(raw_tmp),
f"VPNBOT_BOT_TOKEN={TOKEN}\n"
"VPNBOT_TELEGRAM_IP_FAMILY=ipv4\n"
"VPNBOT_TELEGRAM_IP_FAMILY=ipv6\n",
)
with self.assertRaisesRegex(release_pipeline.PipelineError, "duplicate"):
release_alert_monitor.load_bot_runtime(path)
def test_fresh_projection_yields_only_bot_api_relay_ports(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
path = self.write_projection(
Path(raw_tmp),
{
"schema": 1,
"updated_at_monotonic": 1_000.0,
"healthy_ports_by_host": {
"api.telegram.org": [18444, 18443],
"t.me": [18445],
},
},
)
self.assertEqual(
release_alert_monitor.healthy_relay_ports(path, monotonic=lambda: 1_010.0),
(18443, 18444),
)
# A frozen writer is not evidence: stale means no relay.
self.assertEqual(
release_alert_monitor.healthy_relay_ports(path, monotonic=lambda: 1_046.0),
(),
)
def test_absent_or_foreign_projection_yields_no_relay(self) -> None:
with tempfile.TemporaryDirectory() as raw_tmp:
directory = Path(raw_tmp)
self.assertEqual(
release_alert_monitor.healthy_relay_ports(directory / "absent.json"), ()
)
for payload in (
{"schema": 2, "updated_at_monotonic": 1.0, "healthy_ports_by_host": {}},
{"schema": 1, "updated_at_monotonic": True, "healthy_ports_by_host": {}},
{
"schema": 1,
"updated_at_monotonic": 1.0,
"healthy_ports_by_host": {"api.telegram.org": [80]},
},
):
with self.subTest(payload=payload):
path = self.write_projection(directory, payload)
self.assertEqual(
release_alert_monitor.healthy_relay_ports(path, monotonic=lambda: 2.0),
(),
)
(directory / "broken.json").write_text("{", encoding="utf-8")
self.assertEqual(
release_alert_monitor.healthy_relay_ports(directory / "broken.json"), ()
)
def test_healthy_relay_is_used_exclusively(self) -> None:
def resolve(*_args: object) -> list[object]:
raise AssertionError("direct DNS must not be consulted beside a healthy relay")
routes = release_alert_monitor.telegram_routes(
self.runtime(),
resolve=resolve,
relay_ports=lambda _path: (18443, 18444),
)
self.assertEqual(
[(route.kind, route.address, route.port) for route in routes],
[("relay", "127.0.0.1", 18443), ("relay", "127.0.0.1", 18444)],
)
def test_direct_path_is_ipv4_only_with_fallbacks_first(self) -> None:
socket_module = release_alert_monitor.socket
calls: list[tuple[object, ...]] = []
def resolve(*args: object) -> list[object]:
calls.append(args)
return [
(socket_module.AF_INET, socket_module.SOCK_STREAM, 6, "", ("149.154.166.110", 443)),
(socket_module.AF_INET, socket_module.SOCK_STREAM, 6, "", ("149.154.167.220", 443)),
(socket_module.AF_INET6, socket_module.SOCK_STREAM, 6, "", ("2001:67c:4e8:f004::9", 443, 0, 0)),
]
routes = release_alert_monitor.telegram_routes(
self.runtime(), resolve=resolve, relay_ports=lambda _path: ()
)
self.assertEqual(
[(route.kind, route.address, route.family) for route in routes],
[
("direct", "149.154.167.220", socket_module.AF_INET),
("direct", "149.154.166.110", socket_module.AF_INET),
],
)
self.assertEqual(calls[0][:3], ("api.telegram.org", 443, socket_module.AF_INET))
def test_dns_failure_uses_fallbacks_and_without_them_is_no_route(self) -> None:
def resolve(*_args: object) -> list[object]:
raise OSError("temporary failure in name resolution")
routes = release_alert_monitor.telegram_routes(
self.runtime(), resolve=resolve, relay_ports=lambda _path: ()
)
self.assertEqual([route.address for route in routes], ["149.154.167.220"])
with self.assertRaises(release_alert_monitor.TelegramDeliveryError) as raised:
release_alert_monitor.telegram_routes(
self.runtime(fallback_ipv4s=()),
resolve=resolve,
relay_ports=lambda _path: (),
)
self.assert_code(raised, release_alert_monitor.TELEGRAM_NO_ROUTE)
def test_sender_moves_to_the_next_route_only_before_sending(self) -> None:
routes = [
release_alert_monitor.TelegramRoute("relay", "127.0.0.1", port, release_alert_monitor.socket.AF_INET)
for port in (18443, 18444)
]
posted: list[int] = []
def post(route: object, path: str, payload: bytes, **_kwargs: object) -> object:
posted.append(route.port) # type: ignore[attr-defined]
self.assertEqual(path, f"/bot{TOKEN}/sendMessage")
self.assertEqual(json.loads(payload)["chat_id"], "6483277608")
if route.port == 18443: # type: ignore[attr-defined]
return None
return 200, b'{"ok": true, "result": {"message_id": 77}}'
with tempfile.TemporaryDirectory() as raw_tmp:
send = release_alert_monitor.telegram_sender(
ReleasePipelineTests.alert_settings(Path(raw_tmp)),
self.runtime(),
routes=lambda _runtime: routes,
post=post,
tls_context_factory=lambda: None, # type: ignore[arg-type,return-value]
)
self.assertEqual(send("hello"), 77)
self.assertEqual(posted, [18443, 18444])
def test_sender_reports_connect_failure_when_no_route_connects(self) -> None:
routes = [
release_alert_monitor.TelegramRoute("direct", "149.154.167.220", 443, release_alert_monitor.socket.AF_INET)
]
with tempfile.TemporaryDirectory() as raw_tmp:
send = release_alert_monitor.telegram_sender(
ReleasePipelineTests.alert_settings(Path(raw_tmp)),
self.runtime(),
routes=lambda _runtime: routes,
post=lambda *_a, **_k: None,
tls_context_factory=lambda: None, # type: ignore[arg-type,return-value]
)
with self.assertRaises(release_alert_monitor.TelegramDeliveryError) as raised:
send("hello")
self.assert_code(raised, release_alert_monitor.TELEGRAM_CONNECT_FAILED)
self.assertIn("149.154.167.220:443", str(raised.exception))
def test_ambiguous_delivery_is_never_resent_on_another_route(self) -> None:
routes = [
release_alert_monitor.TelegramRoute("relay", "127.0.0.1", port, release_alert_monitor.socket.AF_INET)
for port in (18443, 18444)
]
posted: list[int] = []
def post(route: object, *_args: object, **_kwargs: object) -> object:
posted.append(route.port) # type: ignore[attr-defined]
raise release_alert_monitor.TelegramDeliveryError(
release_alert_monitor.TELEGRAM_DELIVERY_AMBIGUOUS, "no response"
)
with tempfile.TemporaryDirectory() as raw_tmp:
send = release_alert_monitor.telegram_sender(
ReleasePipelineTests.alert_settings(Path(raw_tmp)),
self.runtime(),
routes=lambda _runtime: routes,
post=post,
tls_context_factory=lambda: None, # type: ignore[arg-type,return-value]
)
with self.assertRaises(release_alert_monitor.TelegramDeliveryError) as raised:
send("hello")
self.assertEqual(raised.exception.code, release_alert_monitor.TELEGRAM_DELIVERY_AMBIGUOUS)
self.assertEqual(posted, [18443])
def test_sender_types_http_and_rejection_answers(self) -> None:
cases = (
((403, b'{"ok": false}'), release_alert_monitor.TELEGRAM_HTTP_STATUS),
((200, b"not json"), release_alert_monitor.TELEGRAM_RESPONSE_INVALID),
((200, b'{"ok": false, "result": {"message_id": 1}}'), release_alert_monitor.TELEGRAM_REJECTED),
)
route = release_alert_monitor.TelegramRoute("relay", "127.0.0.1", 18443, release_alert_monitor.socket.AF_INET)
for answer, code in cases:
with self.subTest(code=code), tempfile.TemporaryDirectory() as raw_tmp:
send = release_alert_monitor.telegram_sender(
ReleasePipelineTests.alert_settings(Path(raw_tmp)),
self.runtime(),
routes=lambda _runtime: [route],
post=lambda *_a, _answer=answer, **_k: _answer,
tls_context_factory=lambda: None, # type: ignore[arg-type,return-value]
)
with self.assertRaises(release_alert_monitor.TelegramDeliveryError) as raised:
send("hello")
self.assert_code(raised, code)
def test_pinned_connection_keeps_telegram_sni_and_certificate_over_a_relay(self) -> None:
import http.server
import ssl
import threading
with tempfile.TemporaryDirectory() as raw_tmp:
directory = Path(raw_tmp)
cert = directory / "cert.pem"
key = directory / "key.pem"
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(key), "-out", str(cert), "-days", "1",
"-subj", "/CN=api.telegram.org",
"-addext", "subjectAltName=DNS:api.telegram.org",
],
check=True,
capture_output=True,
)
seen: dict[str, object] = {}
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self) -> None:
length = int(self.headers["Content-Length"])
seen["path"] = self.path
seen["body"] = json.loads(self.rfile.read(length))
body = b'{"ok": true, "result": {"message_id": 501}}'
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args: object) -> None:
return None
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain(cert, key)
def remember_sni(_sock: object, name: str | None, _ctx: object) -> None:
seen["sni"] = name
server_context.sni_callback = remember_sni
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
server.socket = server_context.wrap_socket(server.socket, server_side=True)
thread = threading.Thread(target=server.handle_request, daemon=True)
thread.start()
try:
route = release_alert_monitor.TelegramRoute(
"relay", "127.0.0.1", server.server_address[1], release_alert_monitor.socket.AF_INET
)
send = release_alert_monitor.telegram_sender(
ReleasePipelineTests.alert_settings(directory),
self.runtime(),
routes=lambda _runtime: [route],
tls_context_factory=lambda: ssl.create_default_context(cafile=str(cert)),
)
self.assertEqual(send("hello"), 501)
finally:
thread.join(timeout=10)
server.server_close()
self.assertEqual(seen["sni"], "api.telegram.org")
self.assertEqual(seen["path"], f"/bot{TOKEN}/sendMessage")
self.assertEqual(seen["body"]["text"], "hello") # type: ignore[index]
def test_refused_relay_port_is_a_connect_failure_not_an_ambiguous_send(self) -> None:
import ssl
probe = release_alert_monitor.socket.socket()
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
route = release_alert_monitor.TelegramRoute(
"relay", "127.0.0.1", port, release_alert_monitor.socket.AF_INET
)
self.assertIsNone(
release_alert_monitor.post_telegram(
route,
"/botX/sendMessage",
b"{}",
timeout=2,
tls_context=ssl.create_default_context(),
)
)
if __name__ == "__main__":
unittest.main()