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:
@@ -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. "
|
||||
|
||||
@@ -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})
|
||||
)
|
||||
Reference in New Issue
Block a user