feat(identifiers): prepare verified cutover batches

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-22 14:57:56 +02:00
parent bdf3af19e2
commit 5e14d09bdf
10 changed files with 3097 additions and 0 deletions

View file

@ -312,6 +312,20 @@ def main(argv: list[str] | None = None) -> int:
)
p_id_verify.add_argument("--plan", required=True)
p_id_verify.add_argument("--repo", default=None, help="Verify one repository atomic unit")
p_id_batch = identifier_sub.add_parser(
"migration-batch-plan",
help="Pin a clean synchronized repository batch for explicit approval",
)
p_id_batch.add_argument("--plan", required=True)
p_id_batch.add_argument("--repo", action="append", required=True, dest="repos")
p_id_batch.add_argument("--output", default=None)
p_id_batch.add_argument("--force", action="store_true")
p_id_batch_verify = identifier_sub.add_parser(
"migration-batch-verify",
help="Verify a saved batch seal and repeat source/Git preflight",
)
p_id_batch_verify.add_argument("--plan", required=True)
p_id_batch_verify.add_argument("--batch", required=True)
p_id_files = identifier_sub.add_parser(
"migration-files",
help="Validate or execute one repository's sealed UUID file rewrite",
@ -686,7 +700,9 @@ def main(argv: list[str] | None = None) -> int:
load_fleet_namespace,
migrate_repository_identifier_files,
plan_identifier_migration,
plan_identifier_migration_batch,
scan_live_identifier_collisions,
verify_identifier_migration_batch,
verify_identifier_migration_plan,
)
@ -730,6 +746,37 @@ def main(argv: list[str] | None = None) -> int:
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
elif args.identifier_command == "migration-batch-plan":
try:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
if not isinstance(plan, dict):
raise TypeError("migration plan must be a JSON object")
result = plan_identifier_migration_batch(plan, repo_slugs=args.repos)
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
if args.output:
output = Path(args.output)
if output.exists() and not args.force:
print(
json.dumps(
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
indent=2,
)
)
return 1
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
elif args.identifier_command == "migration-batch-verify":
try:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
batch = json.loads(Path(args.batch).read_text(encoding="utf-8"))
if not isinstance(plan, dict) or not isinstance(batch, dict):
raise TypeError("migration plan and batch must be JSON objects")
result = verify_identifier_migration_batch(batch, plan=plan)
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
else:
try:
result = plan_identifier_migration(Path(args.root), namespace)

View file

@ -6,6 +6,7 @@ import hashlib
import json
import os
import re
import subprocess
import tempfile
import uuid
from collections import defaultdict
@ -296,6 +297,224 @@ def verify_identifier_migration_plan(
}
def _git_cutover_preflight(repo: Path, *, expected_head_sha: str | None) -> dict[str, Any]:
errors: list[str] = []
def git(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
current_head = head_sha(repo)
if current_head is None:
errors.append("repository has no Git HEAD")
elif current_head != expected_head_sha:
errors.append("Git HEAD changed after migration planning")
status = git("status", "--porcelain")
if status.returncode != 0:
errors.append(status.stderr.strip() or "could not inspect Git worktree")
elif status.stdout.strip():
errors.append("worktree is not clean")
upstream = git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
upstream_name = upstream.stdout.strip() if upstream.returncode == 0 else None
behind: int | None = None
ahead: int | None = None
if upstream_name is None:
errors.append("current branch has no upstream")
else:
counts = git("rev-list", "--left-right", "--count", "@{u}...HEAD")
try:
behind, ahead = (int(value) for value in counts.stdout.split())
except (TypeError, ValueError):
errors.append(counts.stderr.strip() or "could not compare HEAD with upstream")
else:
if behind or ahead:
errors.append("branch does not exactly match its upstream")
remote = git("remote", "get-url", "origin")
origin = remote.stdout.strip() if remote.returncode == 0 else None
if origin is None:
errors.append("origin remote is missing")
elif any(
marker in origin
for marker in ("gitea-remote", "gitea.coulomb.social", "92.205.130.254")
):
errors.append("origin targets the retired Gitea lineage")
return {
"ok": not errors,
"head_sha": current_head,
"expected_head_sha": expected_head_sha,
"upstream": upstream_name,
"behind": behind,
"ahead": ahead,
"origin": origin,
"errors": errors,
}
def plan_identifier_migration_batch(
plan: dict[str, Any], *, repo_slugs: list[str]
) -> dict[str, Any]:
"""Pin a reviewed repository batch after source and Git preflight.
The result is an approval package, not apply authority. Live database and
file mutation still require an explicit decision citing ``batch_sha256``.
"""
plan_sha256 = _verified_plan_seal(plan)
requested = [slug.strip() for slug in repo_slugs if slug.strip()]
errors: list[dict[str, str]] = []
if not requested:
errors.append({"scope": "batch", "reason": "at least one repository is required"})
if len(set(requested)) != len(requested):
errors.append({"scope": "batch", "reason": "repository list contains duplicates"})
repositories_by_slug: dict[str, list[dict[str, Any]]] = defaultdict(list)
for repository in plan.get("repositories", []):
repositories_by_slug[str(repository.get("repo") or "")].append(repository)
batch_repositories: list[dict[str, Any]] = []
totals = {"repositories": 0, "records": 0, "replace": 0, "assign": 0, "unchanged": 0}
for slug in requested:
matches = repositories_by_slug.get(slug, [])
if len(matches) != 1:
errors.append(
{
"scope": slug,
"reason": "repository must occur exactly once in the source plan",
}
)
continue
repository = matches[0]
source_verification = verify_identifier_migration_plan(plan, repo_slug=slug)
repo = Path(str(repository.get("path") or "")).resolve()
git_preflight = _git_cutover_preflight(
repo,
expected_head_sha=repository.get("planned_head_sha"),
)
mappings = list(repository.get("mappings") or [])
action_counts = {
action: sum(mapping.get("action") == action for mapping in mappings)
for action in ("replace", "assign", "unchanged")
}
actionable = action_counts["replace"] + action_counts["assign"]
if actionable == 0:
errors.append({"scope": slug, "reason": "repository has no migration action"})
for error in source_verification["errors"]:
errors.append({"scope": slug, "reason": error["reason"]})
for reason in git_preflight["errors"]:
errors.append({"scope": slug, "reason": reason})
batch_repositories.append(
{
"repo": slug,
"path": str(repo),
"planned_head_sha": repository.get("planned_head_sha"),
"source_fingerprint": repository.get("source_fingerprint"),
"source_verified": source_verification["ok"],
"git_preflight": git_preflight,
"mapping_counts": {"records": len(mappings), **action_counts},
"ready": source_verification["ok"] and git_preflight["ok"] and actionable > 0,
}
)
totals["repositories"] += 1
totals["records"] += len(mappings)
for action, count in action_counts.items():
totals[action] += count
result = {
"schema": "repo-manager.identifier-migration-batch.v1",
"ok": not errors,
"ready_for_approval": not errors,
"apply_authorized": False,
"approval_required": True,
"namespace": plan.get("namespace"),
"source_plan_sha256": plan_sha256,
"batch_policy": "repository-atomic, sequential, stop on first failure",
"rollback_order": "reverse files if written, then reverse central projection",
"generated_at": utc_now_text(),
"totals": totals,
"repositories": batch_repositories,
"errors": errors,
}
canonical = json.dumps(result, sort_keys=True, separators=(",", ":")).encode("utf-8")
result["batch_sha256"] = hashlib.sha256(canonical).hexdigest()
return result
def verify_identifier_migration_batch(
batch: dict[str, Any], *, plan: dict[str, Any]
) -> dict[str, Any]:
"""Verify a saved batch seal and repeat every selected source/Git check."""
errors: list[dict[str, str]] = []
expected_batch_sha256 = batch.get("batch_sha256")
unsealed = {key: value for key, value in batch.items() if key != "batch_sha256"}
canonical = json.dumps(unsealed, sort_keys=True, separators=(",", ":")).encode("utf-8")
actual_batch_sha256 = hashlib.sha256(canonical).hexdigest()
if batch.get("schema") != "repo-manager.identifier-migration-batch.v1":
errors.append({"scope": "batch", "reason": "unsupported batch schema"})
if expected_batch_sha256 != actual_batch_sha256:
errors.append({"scope": "batch", "reason": "batch SHA-256 mismatch"})
try:
plan_sha256 = _verified_plan_seal(plan)
except (OSError, ValueError) as exc:
errors.append({"scope": "plan", "reason": str(exc)})
plan_sha256 = None
if batch.get("source_plan_sha256") != plan_sha256:
errors.append({"scope": "plan", "reason": "batch source plan SHA-256 mismatch"})
if batch.get("apply_authorized") is not False or batch.get("approval_required") is not True:
errors.append(
{
"scope": "batch",
"reason": "batch must remain unauthorized and approval-required",
}
)
saved_repositories = list(batch.get("repositories") or [])
repo_slugs = [str(item.get("repo") or "") for item in saved_repositories]
if not repo_slugs or any(not slug for slug in repo_slugs):
errors.append({"scope": "batch", "reason": "batch has no valid repository scope"})
fresh = None
else:
try:
fresh = plan_identifier_migration_batch(plan, repo_slugs=repo_slugs)
except (OSError, ValueError) as exc:
errors.append({"scope": "batch", "reason": str(exc)})
fresh = None
if fresh is not None:
errors.extend(fresh["errors"])
fresh_by_slug = {item["repo"]: item for item in fresh["repositories"]}
for saved in saved_repositories:
slug = str(saved.get("repo") or "")
current = fresh_by_slug.get(slug)
if current is None:
errors.append({"scope": slug, "reason": "repository disappeared from batch"})
continue
for field in ("planned_head_sha", "source_fingerprint", "mapping_counts"):
if saved.get(field) != current.get(field):
errors.append({"scope": slug, "reason": f"saved {field} differs from plan"})
return {
"schema": "repo-manager.identifier-migration-batch-verification.v1",
"ok": not errors,
"ready_for_decision": not errors,
"apply_authorized": False,
"approval_required": True,
"batch_sha256": expected_batch_sha256,
"source_plan_sha256": plan_sha256,
"repositories": repo_slugs,
"errors": errors,
}
def _verified_plan_seal(plan: dict[str, Any]) -> str:
if plan.get("schema") != "repo-manager.identifier-migration-plan.v1":
raise ValueError("unsupported identifier migration plan schema")