feat(6845): every instance has an id lineage, and every full backup carries it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / integration (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 22s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / integration (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 22s
instance_identity is one row made by migration 0127 with a random id, so an instance has it before its first account. A full backup carries it in its header as instance_id (the tar manifest header and the JSON top level); BACKUP_VERSION is 28. The table rides in _NOT_INCLUDED: a restore decides from the header value rather than copying a row. Two stores with the same id number their rows the same way. That is what lets a later step roll a live instance back by original id, refuse a backup whose ids would land on unrelated rows, and have a fresh-instance restore adopt the backup's id (adopt_instance_id). Milestone 582, step 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -94,3 +94,4 @@ from grimoire.models.family import ( # noqa: E402, F401
|
|||||||
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
|
||||||
FamilyIdeaReference, Platform, ProjectPlatform,
|
FamilyIdeaReference, Platform, ProjectPlatform,
|
||||||
)
|
)
|
||||||
|
from grimoire.models.instance import InstanceIdentity # noqa: E402, F401
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -111,7 +111,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# v26 user_profiles: what each user wrote on their Profile page.
|
# v26 user_profiles: what each user wrote on their Profile page.
|
||||||
# v27 drops user_profiles with the Profile page. Older archives carrying the
|
# v27 drops user_profiles with the Profile page. Older archives carrying the
|
||||||
# section still restore — the key is simply not read.
|
# 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
|
# 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
|
# 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
|
# rebuilt from the repo archive by every coverage sync, and carries no
|
||||||
# judgment — the first refresh after a restore recreates it exactly.
|
# judgment — the first refresh after a restore recreates it exactly.
|
||||||
"code_shape_consumers",
|
"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
|
runs, which only works when unchanged rows serialise to the same bytes in
|
||||||
the same place.
|
the same place.
|
||||||
"""
|
"""
|
||||||
|
from grimoire.services.instance import get_instance_id
|
||||||
|
|
||||||
async with async_session() as session:
|
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()
|
users =(await session.execute(select(User).order_by(User.id))).scalars().all()
|
||||||
projects = (await session.execute(select(Project).order_by(Project.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()
|
milestones = (await session.execute(select(Milestone).order_by(Milestone.id))).scalars().all()
|
||||||
@@ -1077,6 +1085,7 @@ async def export_full_backup() -> dict:
|
|||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
"scope": "full",
|
"scope": "full",
|
||||||
|
"instance_id": instance_id,
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"_security_notice": (
|
"_security_notice": (
|
||||||
"This backup contains hashed passwords. "
|
"This backup contains hashed passwords. "
|
||||||
|
|||||||
@@ -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})
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -11,7 +11,7 @@ The full FK-remapping round-trip needs real Postgres and lives in the
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
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
|
"""The bump is the point of the test — a payload section added without
|
||||||
moving the version produces backups that are structurally different and
|
moving the version produces backups that are structurally different and
|
||||||
indistinguishable by inspection."""
|
indistinguishable by inspection."""
|
||||||
assert backup.BACKUP_VERSION == 27
|
assert backup.BACKUP_VERSION == 28
|
||||||
|
|
||||||
|
|
||||||
def _exportable_note(**over):
|
def _exportable_note(**over):
|
||||||
@@ -561,11 +561,15 @@ class _CM:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_export_full_backup_contains_every_declared_section():
|
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()
|
out = await backup.export_full_backup()
|
||||||
|
|
||||||
assert out["version"] == backup.BACKUP_VERSION
|
assert out["version"] == backup.BACKUP_VERSION
|
||||||
assert out["scope"] == "full"
|
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"]
|
assert "api_keys" in out["_not_included"]
|
||||||
# The sections v2 silently dropped, the six v5 added, v6's
|
# The sections v2 silently dropped, the six v5 added, v6's
|
||||||
# note_supersessions, and v7's code_shapes (all empty here).
|
# note_supersessions, and v7's code_shapes (all empty here).
|
||||||
|
|||||||
Reference in New Issue
Block a user