chore: lock migration doc and script in sync with current compose layout
This commit is contained in:
@@ -0,0 +1,272 @@
|
|||||||
|
"""Pin facts that ``docs/migration-to-minishop.md`` and
|
||||||
|
``scripts/migrate_to_minishop.sh`` rely on.
|
||||||
|
|
||||||
|
Both documents are written for a user upgrading from ``remnawave-tg-shop``
|
||||||
|
(v2.7.0 era) to the current split-arch ``remnawave-minishop`` (v3.4+). They
|
||||||
|
make concrete claims about:
|
||||||
|
|
||||||
|
* the set of container names produced by today's compose files;
|
||||||
|
* the set of volume names produced by today's compose files;
|
||||||
|
* the eras the migration script is allowed to stop containers from.
|
||||||
|
|
||||||
|
If any of these drift apart from reality, the migration document silently
|
||||||
|
goes stale. These tests fail loudly instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DOC_PATH = REPO_ROOT / "docs" / "migration-to-minishop.md"
|
||||||
|
SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh"
|
||||||
|
COMPOSE_FILES = (
|
||||||
|
REPO_ROOT / "docker-compose.yml",
|
||||||
|
REPO_ROOT / "deploy" / "compose" / "docker-compose-caddy.yml",
|
||||||
|
REPO_ROOT / "deploy" / "compose" / "docker-compose-remote-server.yml",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Names that the current architecture must produce in at least one compose file.
|
||||||
|
EXPECTED_CONTAINER_NAMES = {
|
||||||
|
"remnawave-minishop-backend",
|
||||||
|
"remnawave-minishop-worker",
|
||||||
|
"remnawave-minishop-frontend",
|
||||||
|
"remnawave-minishop-migrate",
|
||||||
|
"remnawave-minishop-postgres",
|
||||||
|
"remnawave-minishop-redis",
|
||||||
|
}
|
||||||
|
EXPECTED_VOLUME_NAMES = {
|
||||||
|
"remnawave-minishop-db-data",
|
||||||
|
"remnawave-minishop-redis-data",
|
||||||
|
"remnawave-minishop-shop-data",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> str:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _all_compose_text() -> str:
|
||||||
|
return "\n".join(_read(path) for path in COMPOSE_FILES if path.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationDocumentationFactsTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.doc = _read(DOC_PATH)
|
||||||
|
self.script = _read(SCRIPT_PATH)
|
||||||
|
self.compose = _all_compose_text()
|
||||||
|
|
||||||
|
def test_doc_lists_every_running_container_in_current_compose(self):
|
||||||
|
"""The architecture table must reflect what ``docker compose up``
|
||||||
|
actually produces today."""
|
||||||
|
missing = sorted(
|
||||||
|
name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
missing,
|
||||||
|
f"migration-to-minishop.md is missing container names from current compose: {missing}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_doc_lists_every_volume_in_current_compose(self):
|
||||||
|
missing = sorted(
|
||||||
|
name for name in EXPECTED_VOLUME_NAMES if name not in self.doc
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
missing,
|
||||||
|
f"migration-to-minishop.md is missing volume names from current compose: {missing}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_doc_warns_about_renamed_telegram_webhook_secret(self):
|
||||||
|
# This is the single rename most likely to bite a v2.7.0 → HEAD user.
|
||||||
|
self.assertIn("TELEGRAM_WEBHOOK_SECRET", self.doc)
|
||||||
|
self.assertIn("WEBHOOK_SECRET_TOKEN", self.doc)
|
||||||
|
|
||||||
|
def test_doc_says_webhook_base_url_is_required(self):
|
||||||
|
# Polling mode was dropped — without WEBHOOK_BASE_URL the bot refuses
|
||||||
|
# to start. A user migrating from v2.7.0 (where it was optional) must
|
||||||
|
# be told this explicitly.
|
||||||
|
block = self.doc.lower()
|
||||||
|
self.assertIn("webhook_base_url", block)
|
||||||
|
# "обязательна" is the marker text in the env-vars table.
|
||||||
|
self.assertIn("обязательн", block)
|
||||||
|
|
||||||
|
def test_doc_mentions_migrate_one_shot_service(self):
|
||||||
|
# The migrate sidecar is what makes schema migrations transparent on
|
||||||
|
# the second-stage upgrade. Don't bury it.
|
||||||
|
self.assertIn("migrate", self.doc)
|
||||||
|
# "one-shot" or "разовый" / "однораз" text variants accepted.
|
||||||
|
normalized = self.doc.lower()
|
||||||
|
self.assertTrue(
|
||||||
|
"one-shot" in normalized or "однораз" in normalized,
|
||||||
|
"migration doc must describe `migrate` as a one-shot service",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_doc_mentions_postgres_host_compose_override_caveat(self):
|
||||||
|
# Otherwise users follow the sed-fix step blindly and then panic
|
||||||
|
# because their .env still has the "wrong" hostname under compose.
|
||||||
|
self.assertIn("POSTGRES_HOST", self.doc)
|
||||||
|
# Russian: "переопределя…" / "перебивает" indicate the override is documented.
|
||||||
|
text = self.doc.lower()
|
||||||
|
self.assertTrue(
|
||||||
|
"переопредел" in text or "перебивает" in text,
|
||||||
|
"migration doc must explain that compose overrides POSTGRES_HOST",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_doc_describes_redis_data_and_shop_data_as_fresh(self):
|
||||||
|
# We do not migrate redis-data or shop-data — make sure that's said
|
||||||
|
# so users don't try to copy them from the old stack.
|
||||||
|
text = self.doc.lower()
|
||||||
|
self.assertIn("redis-data", text)
|
||||||
|
self.assertIn("shop-data", text)
|
||||||
|
# Some phrasing variant must say it's empty / fresh / new on purpose.
|
||||||
|
self.assertTrue(
|
||||||
|
any(marker in text for marker in ("пустым", "создаётся пуст", "новые,", "пуст —"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_doc_explains_reverse_proxy_no_longer_single_upstream(self):
|
||||||
|
# The note "rename remnawave-tg-shop → remnawave-minishop in your
|
||||||
|
# proxy config" used to be enough; after the split it's wrong.
|
||||||
|
# Make sure both backend:8080 and frontend:80 are documented.
|
||||||
|
self.assertIn("backend:8080", self.doc)
|
||||||
|
self.assertIn("frontend:80", self.doc)
|
||||||
|
|
||||||
|
|
||||||
|
def _known_containers_from_script() -> set[str]:
|
||||||
|
"""Parse the literal ``KNOWN_CONTAINERS`` array from the script source.
|
||||||
|
|
||||||
|
The array is defined with ``${OLD_PREFIX}`` / ``${NEW_PREFIX}`` placeholders
|
||||||
|
that we substitute here. Doing this statically (rather than sourcing the
|
||||||
|
script in bash) avoids running ``main`` and keeps the test independent of
|
||||||
|
a bash interpreter being available at runtime.
|
||||||
|
"""
|
||||||
|
text = _read(SCRIPT_PATH)
|
||||||
|
|
||||||
|
prefix_match = re.search(r'^OLD_PREFIX="([^"]+)"', text, flags=re.MULTILINE)
|
||||||
|
new_match = re.search(r'^NEW_PREFIX="([^"]+)"', text, flags=re.MULTILINE)
|
||||||
|
if not prefix_match or not new_match:
|
||||||
|
return set()
|
||||||
|
old_prefix = prefix_match.group(1)
|
||||||
|
new_prefix = new_match.group(1)
|
||||||
|
|
||||||
|
# ``\n)`` as a boundary: a non-greedy ``.*?\)`` would stop at the first
|
||||||
|
# close-paren in a comment like ``# (v2.7.0 upstream remnawave-tg-shop)``.
|
||||||
|
array_match = re.search(
|
||||||
|
r"KNOWN_CONTAINERS=\((.*?)\n\)",
|
||||||
|
text,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
if not array_match:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
body = array_match.group(1)
|
||||||
|
# Strip line comments and quotes, then expand the two placeholders.
|
||||||
|
names: set[str] = set()
|
||||||
|
for raw_line in body.splitlines():
|
||||||
|
line = raw_line.split("#", 1)[0].strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
for token in re.findall(r'"([^"]+)"', line):
|
||||||
|
expanded = token.replace("${OLD_PREFIX}", old_prefix).replace(
|
||||||
|
"${NEW_PREFIX}", new_prefix
|
||||||
|
)
|
||||||
|
names.add(expanded)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationScriptCoverageTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.script = _read(SCRIPT_PATH)
|
||||||
|
self.known = _known_containers_from_script()
|
||||||
|
|
||||||
|
def test_known_containers_covers_split_arch(self):
|
||||||
|
"""A re-run on a partially migrated stack must be able to stop the new
|
||||||
|
containers, otherwise ``docker compose up`` later fails with name
|
||||||
|
conflicts."""
|
||||||
|
missing = sorted(EXPECTED_CONTAINER_NAMES - self.known)
|
||||||
|
self.assertFalse(
|
||||||
|
missing,
|
||||||
|
f"KNOWN_CONTAINERS missing split-arch entries: {missing}\n"
|
||||||
|
f"actual: {sorted(self.known)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_known_containers_still_covers_legacy_eras(self):
|
||||||
|
# We must also keep stopping the original (v2.7.0) and intermediate
|
||||||
|
# (v3.1.x – v3.2.x) container names.
|
||||||
|
for legacy in ("remnawave-tg-shop", "remnawave-tg-shop-db", "remnawave-minishop-db"):
|
||||||
|
with self.subTest(container=legacy):
|
||||||
|
self.assertIn(legacy, self.known)
|
||||||
|
|
||||||
|
def test_script_is_syntactically_valid_bash(self):
|
||||||
|
# The script is curl|bash'ed from raw.githubusercontent in the docs,
|
||||||
|
# so a syntax break is a hard regression.
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
bash = shutil.which("bash")
|
||||||
|
if not bash: # pragma: no cover
|
||||||
|
self.skipTest("bash not available in PATH")
|
||||||
|
result = subprocess.run(
|
||||||
|
[bash, "-n", str(SCRIPT_PATH)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result.returncode,
|
||||||
|
0,
|
||||||
|
(
|
||||||
|
"bash -n flagged migrate_to_minishop.sh:\n"
|
||||||
|
f"stdout={result.stdout}\nstderr={result.stderr}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocComposeFileReferencesTests(unittest.TestCase):
|
||||||
|
"""The doc links the user to specific compose files — they must exist."""
|
||||||
|
|
||||||
|
def test_referenced_compose_files_exist(self):
|
||||||
|
doc = _read(DOC_PATH)
|
||||||
|
for relpath in (
|
||||||
|
"docker-compose.yml",
|
||||||
|
"deploy/compose/docker-compose-caddy.yml",
|
||||||
|
"deploy/compose/docker-compose-remote-server.yml",
|
||||||
|
):
|
||||||
|
with self.subTest(path=relpath):
|
||||||
|
self.assertIn(relpath, doc)
|
||||||
|
self.assertTrue(
|
||||||
|
(REPO_ROOT / relpath).is_file(),
|
||||||
|
f"{relpath} is referenced in migration-to-minishop.md but missing on disk",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_doc_references_migrator_module_path(self):
|
||||||
|
# The doc tells users to expect ``backend/db/migrator.py`` migrations
|
||||||
|
# to apply via the migrate service. If the file moves, the doc lies.
|
||||||
|
doc = _read(DOC_PATH)
|
||||||
|
self.assertIn("backend/db/migrator.py", doc)
|
||||||
|
self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationFootprintRegexTests(unittest.TestCase):
|
||||||
|
"""Spot-check that what compose actually defines matches what we documented."""
|
||||||
|
|
||||||
|
def test_every_compose_volume_documented(self):
|
||||||
|
"""If a future compose file introduces a new ``remnawave-minishop-*``
|
||||||
|
named volume, the migration doc must call out whether it carries data
|
||||||
|
from the old stack or starts fresh."""
|
||||||
|
doc = _read(DOC_PATH)
|
||||||
|
compose_text = _all_compose_text()
|
||||||
|
# Find every named volume of the form ``remnawave-minishop-<key>-data``.
|
||||||
|
defined = set(re.findall(r"remnawave-minishop-[\w-]+-data", compose_text))
|
||||||
|
# Caddy-only volumes only ship in the caddy compose file but are still
|
||||||
|
# documented; allow them either way.
|
||||||
|
for volume in defined:
|
||||||
|
with self.subTest(volume=volume):
|
||||||
|
self.assertIn(
|
||||||
|
volume,
|
||||||
|
doc,
|
||||||
|
f"new volume {volume} is defined in compose but missing from migration doc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user