diff --git a/alembic/versions/0127_instance_identity.py b/alembic/versions/0127_instance_identity.py new file mode 100644 index 00000000..a9f85837 --- /dev/null +++ b/alembic/versions/0127_instance_identity.py @@ -0,0 +1,39 @@ +"""instance identity — the id lineage a backup carries + +Revision ID: 0127 +Revises: 0126 +Create Date: 2026-11-05 + +One row naming this database's id lineage. Every full backup carries it, a +restore into a fresh instance adopts it (the restore keeps the original ids), +and rolling a live instance back requires the backup's to match, so a backup's +ids never land on unrelated rows. Made here with a random value, so every +instance has one before its first account exists. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0127" +down_revision = "0126" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "instance_identity", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("instance_id", sa.Text(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint("id = 1", name="ck_instance_identity_single_row"), + ) + op.execute( + "INSERT INTO instance_identity (id, instance_id) VALUES (1, gen_random_uuid()::text)" + ) + + +def downgrade() -> None: + op.drop_table("instance_identity") diff --git a/src/grimoire/models/__init__.py b/src/grimoire/models/__init__.py index 45e2e7c2..bfe87230 100644 --- a/src/grimoire/models/__init__.py +++ b/src/grimoire/models/__init__.py @@ -94,3 +94,4 @@ from grimoire.models.family import ( # noqa: E402, F401 FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference, Platform, ProjectPlatform, ) +from grimoire.models.instance import InstanceIdentity # noqa: E402, F401 diff --git a/src/grimoire/models/instance.py b/src/grimoire/models/instance.py new file mode 100644 index 00000000..7f91c77d --- /dev/null +++ b/src/grimoire/models/instance.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Integer, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from grimoire.models import Base + + +class InstanceIdentity(Base): + """The one row naming this database's id lineage. + + Two stores with the same instance id number their rows the same way: one + is the other, or a restore of it made with the original ids kept. That is + what lets a backup roll a live instance back by id, and what refuses a + backup whose ids would land on unrelated rows. Made by the migration, so + an instance has it before anyone registers. + """ + + __tablename__ = "instance_identity" + __table_args__ = (CheckConstraint("id = 1", name="ck_instance_identity_single_row"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + instance_id: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) diff --git a/src/grimoire/services/backup.py b/src/grimoire/services/backup.py index 0c4e1af5..d96317c7 100644 --- a/src/grimoire/services/backup.py +++ b/src/grimoire/services/backup.py @@ -111,7 +111,9 @@ logger = logging.getLogger(__name__) # v26 user_profiles: what each user wrote on their Profile page. # v27 drops user_profiles with the Profile page. Older archives carrying the # section still restore — the key is simply not read. -BACKUP_VERSION = 27 +# v28 instance_id in the header: the id lineage of the store the backup came +# from. A live instance rolls back only to a backup with its own id. +BACKUP_VERSION = 28 # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # below, these two lists must together account for the entire schema — which is @@ -195,6 +197,9 @@ _NOT_INCLUDED = [ # rebuilt from the repo archive by every coverage sync, and carries no # judgment — the first refresh after a restore recreates it exactly. "code_shape_consumers", + # Not a section: the full backup carries the one value in its header as + # `instance_id`, and a restore decides from it rather than copying a row. + "instance_identity", ] @@ -979,7 +984,10 @@ async def export_full_backup() -> dict: runs, which only works when unchanged rows serialise to the same bytes in the same place. """ + from grimoire.services.instance import get_instance_id + async with async_session() as session: + instance_id = await get_instance_id(session) users =(await session.execute(select(User).order_by(User.id))).scalars().all() projects = (await session.execute(select(Project).order_by(Project.id))).scalars().all() milestones = (await session.execute(select(Milestone).order_by(Milestone.id))).scalars().all() @@ -1077,6 +1085,7 @@ async def export_full_backup() -> dict: return { "version": BACKUP_VERSION, "scope": "full", + "instance_id": instance_id, "exported_at": datetime.now(timezone.utc).isoformat(), "_security_notice": ( "This backup contains hashed passwords. " diff --git a/src/grimoire/services/instance.py b/src/grimoire/services/instance.py new file mode 100644 index 00000000..5d1505e2 --- /dev/null +++ b/src/grimoire/services/instance.py @@ -0,0 +1,46 @@ +"""This instance's id lineage (models/instance.py says what it means).""" +from __future__ import annotations + +import uuid + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from grimoire.models import async_session +from grimoire.models.instance import InstanceIdentity + + +async def _read_or_make(session: AsyncSession) -> str: + current = (await session.execute(select(InstanceIdentity.instance_id))).scalar_one_or_none() + if current: + return current + # The migration makes the row; this covers a schema built some other way. + await session.execute( + insert(InstanceIdentity) + .values(id=1, instance_id=str(uuid.uuid4())) + .on_conflict_do_nothing(index_elements=["id"]) + ) + return (await session.execute(select(InstanceIdentity.instance_id))).scalar_one() + + +async def get_instance_id(session: AsyncSession | None = None) -> str: + """This instance's id. Inside `session` when given, so a caller's + transaction sees the same value it may be about to change.""" + if session is not None: + return await _read_or_make(session) + async with async_session() as own: + value = await _read_or_make(own) + await own.commit() + return value + + +async def adopt_instance_id(session: AsyncSession, value: str) -> None: + """Take a backup's id as this instance's own, inside the caller's + transaction: after a restore that kept the backup's original ids, this + store and the one the backup came from number their rows the same way.""" + await session.execute( + insert(InstanceIdentity) + .values(id=1, instance_id=value) + .on_conflict_do_update(index_elements=["id"], set_={"instance_id": value}) + ) diff --git a/tests/test_integration_instance_identity.py b/tests/test_integration_instance_identity.py new file mode 100644 index 00000000..70733425 --- /dev/null +++ b/tests/test_integration_instance_identity.py @@ -0,0 +1,42 @@ +"""Real-Postgres proof that every instance has one id lineage, that a full +backup carries it, and that adopting a backup's id replaces this one's.""" +import io + +import pytest + +from grimoire.models import async_session +from grimoire.services import backup +from grimoire.services.backup_file import read_archive, write_archive_to +from grimoire.services.instance import adopt_instance_id, get_instance_id + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + + +async def test_the_migration_gave_this_instance_an_id_and_it_stays_put(): + first = await get_instance_id() + assert first and len(first) >= 32 + assert await get_instance_id() == first + + +async def test_a_full_backup_carries_the_instance_id_through_the_tar(): + payload = await backup.export_full_backup() + assert payload["instance_id"] == await get_instance_id() + + out = io.BytesIO() + write_archive_to(payload, out, app_version="test") + out.seek(0) + assert read_archive(out)["instance_id"] == payload["instance_id"] + + +async def test_adopting_a_backups_id_replaces_this_instances(): + own = await get_instance_id() + try: + async with async_session() as s: + await adopt_instance_id(s, "adopted-lineage") + await s.commit() + assert await get_instance_id() == "adopted-lineage" + finally: + async with async_session() as s: + await adopt_instance_id(s, own) + await s.commit() + assert await get_instance_id() == own diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index 3ffe8b57..8a11721b 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -11,7 +11,7 @@ The full FK-remapping round-trip needs real Postgres and lives in the """ from datetime import datetime, timezone from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -22,7 +22,7 @@ def test_backup_version_is_current(): """The bump is the point of the test — a payload section added without moving the version produces backups that are structurally different and indistinguishable by inspection.""" - assert backup.BACKUP_VERSION == 27 + assert backup.BACKUP_VERSION == 28 def _exportable_note(**over): @@ -561,11 +561,15 @@ class _CM: @pytest.mark.asyncio async def test_export_full_backup_contains_every_declared_section(): - with patch("grimoire.services.backup.async_session", lambda: _CM()): + with patch("grimoire.services.backup.async_session", lambda: _CM()), patch( + "grimoire.services.instance.get_instance_id", AsyncMock(return_value="test-lineage"), + ): out = await backup.export_full_backup() assert out["version"] == backup.BACKUP_VERSION assert out["scope"] == "full" + # v28: the id lineage a live rollback is checked against. + assert out["instance_id"] == "test-lineage" assert "api_keys" in out["_not_included"] # The sections v2 silently dropped, the six v5 added, v6's # note_supersessions, and v7's code_shapes (all empty here).