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:
parent
bdf3af19e2
commit
5e14d09bdf
10 changed files with 3097 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue