generated from coulomb/repo-seed
Add Inter-Hub migration bundle importer
This commit is contained in:
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install test lint run openapi
|
.PHONY: install test lint run openapi migrate-validate
|
||||||
|
|
||||||
UV ?= /home/worsch/.local/bin/uv
|
UV ?= /home/worsch/.local/bin/uv
|
||||||
PYTHONPATH ?= src
|
PYTHONPATH ?= src
|
||||||
@@ -17,3 +17,7 @@ run:
|
|||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
PYTHONPATH=$(PYTHONPATH) $(UV) run python scripts/export_openapi.py contracts/openapi/core-hub.openapi.json
|
PYTHONPATH=$(PYTHONPATH) $(UV) run python scripts/export_openapi.py contracts/openapi/core-hub.openapi.json
|
||||||
|
|
||||||
|
|
||||||
|
migrate-validate:
|
||||||
|
PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/core_hub_migrate.py validate contracts/fixtures/migration/interhub-minimal.bundle.json
|
||||||
|
|||||||
66
contracts/fixtures/migration/interhub-minimal.bundle.json
Normal file
66
contracts/fixtures/migration/interhub-minimal.bundle.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "core-hub.migration.v1",
|
||||||
|
"source": "inter-hub-haskell",
|
||||||
|
"exportedAt": "2026-06-27T00:00:00Z",
|
||||||
|
"records": {
|
||||||
|
"hubs": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"slug": "ops-hub",
|
||||||
|
"name": "Ops Hub",
|
||||||
|
"domain": "ops.coulomb.social",
|
||||||
|
"hubKind": "domain",
|
||||||
|
"hubFamily": "vsm",
|
||||||
|
"vsmFunction": "OPS",
|
||||||
|
"vsmSystem": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hubCapabilityManifests": [
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"hubSlug": "ops-hub",
|
||||||
|
"manifestVersion": "1.0",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"apiConsumers": [
|
||||||
|
{
|
||||||
|
"id": "33333333-3333-4333-8333-333333333333",
|
||||||
|
"slug": "ops-hub",
|
||||||
|
"name": "ops-hub",
|
||||||
|
"hubCapabilityManifestId": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"keyPrefix": "ch_prefixonly"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"apiKeys": [
|
||||||
|
{
|
||||||
|
"id": "44444444-4444-4444-8444-444444444444",
|
||||||
|
"apiConsumerSlug": "ops-hub",
|
||||||
|
"keyPrefix": "ch_prefixonly",
|
||||||
|
"keyHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"scopes": "framework:read hub:ops-hub:write"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"id": "55555555-5555-4555-8555-555555555555",
|
||||||
|
"hubSlug": "ops-hub",
|
||||||
|
"name": "Readiness",
|
||||||
|
"widgetType": "ops-readiness-gate",
|
||||||
|
"capabilityRef": "ops:readiness",
|
||||||
|
"viewContext": "ops-hub/readiness",
|
||||||
|
"policyScope": "ops-registry"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactionEvents": [
|
||||||
|
{
|
||||||
|
"id": "66666666-6666-4666-8666-666666666666",
|
||||||
|
"widgetId": "55555555-5555-4555-8555-555555555555",
|
||||||
|
"eventType": "ops-endpoint-verified",
|
||||||
|
"metadata": {
|
||||||
|
"expectedStatus": 401
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
71
contracts/schemas/migration-bundle.schema.json
Normal file
71
contracts/schemas/migration-bundle.schema.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://core-hub.local/contracts/schemas/migration-bundle.schema.json",
|
||||||
|
"title": "Core Hub Migration Bundle",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"schemaVersion",
|
||||||
|
"source",
|
||||||
|
"records"
|
||||||
|
],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": {
|
||||||
|
"const": "core-hub.migration.v1"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"exportedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"records": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"hubs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"hubCapabilityManifests": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"apiConsumers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"apiKeys": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"interactionEvents": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,19 @@ Operations:
|
|||||||
- compatibility fixtures
|
- compatibility fixtures
|
||||||
- import/export runs
|
- import/export runs
|
||||||
|
|
||||||
|
## Initial Persisted Framework Tables
|
||||||
|
|
||||||
|
The first replacement slice persists the framework resources that current Inter-Hub
|
||||||
|
consumers need during bootstrap and smoke testing:
|
||||||
|
|
||||||
|
- `hubs`
|
||||||
|
- `hub_capability_manifests`
|
||||||
|
- `api_consumers`
|
||||||
|
- `api_keys` with hash and prefix only, never full key material
|
||||||
|
- `widgets`
|
||||||
|
- `interaction_events`
|
||||||
|
- `migration_runs` for import summaries, bundle hashes, counts, and diagnostics
|
||||||
|
|
||||||
## Migration Posture
|
## Migration Posture
|
||||||
|
|
||||||
- Import Inter-Hub schema facts through explicit migrations or import tools, not ad hoc SQL editing.
|
- Import Inter-Hub schema facts through explicit migrations or import tools, not ad hoc SQL editing.
|
||||||
|
|||||||
@@ -30,6 +30,67 @@ A Core Hub release cannot replace Inter-Hub until:
|
|||||||
- Run dual-read or shadow smokes.
|
- Run dual-read or shadow smokes.
|
||||||
- Switch traffic only after smoke evidence is recorded.
|
- Switch traffic only after smoke evidence is recorded.
|
||||||
|
|
||||||
|
## Migration Bundle v1
|
||||||
|
|
||||||
|
Core Hub staging imports use `core-hub.migration.v1` JSON bundles. The bundle is
|
||||||
|
the handoff contract from the Haskell Inter-Hub export path into the Python/FastAPI
|
||||||
|
replacement:
|
||||||
|
|
||||||
|
- `schemaVersion`: `core-hub.migration.v1`
|
||||||
|
- `source`: normally `inter-hub-haskell`
|
||||||
|
- `exportedAt`: export timestamp for operator traceability
|
||||||
|
- `records.hubs`
|
||||||
|
- `records.hubCapabilityManifests`
|
||||||
|
- `records.apiConsumers`
|
||||||
|
- `records.apiKeys`
|
||||||
|
- `records.widgets`
|
||||||
|
- `records.interactionEvents`
|
||||||
|
|
||||||
|
API key records may contain `keyPrefix`, `keyHash`, scopes, status, and lifecycle
|
||||||
|
metadata. They must not contain full key material, raw tokens, or secrets. Existing
|
||||||
|
runtime keys are not recoverable from Inter-Hub unless an approved custody path
|
||||||
|
provides them; migration preserves only non-secret evidence.
|
||||||
|
|
||||||
|
The reference schema is
|
||||||
|
`contracts/schemas/migration-bundle.schema.json`, with a minimal fixture at
|
||||||
|
`contracts/fixtures/migration/interhub-minimal.bundle.json`.
|
||||||
|
|
||||||
|
## Import Procedure
|
||||||
|
|
||||||
|
Validate a bundle without database access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=src uv run --extra dev python scripts/core_hub_migrate.py validate \
|
||||||
|
contracts/fixtures/migration/interhub-minimal.bundle.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run against the configured target database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CORE_HUB_DATABASE_URL=postgresql+asyncpg://... \
|
||||||
|
PYTHONPATH=src uv run --extra dev python scripts/core_hub_migrate.py import \
|
||||||
|
/secure/operator/path/interhub-export.bundle.json --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Import after the dry-run report is reviewed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CORE_HUB_DATABASE_URL=postgresql+asyncpg://... \
|
||||||
|
PYTHONPATH=src uv run --extra dev python scripts/core_hub_migrate.py import \
|
||||||
|
/secure/operator/path/interhub-export.bundle.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Each successful import writes a `migration_runs` row containing the source,
|
||||||
|
schema version, bundle SHA-256, row counts, warnings, and relationship checks.
|
||||||
|
|
||||||
|
## Rollback Procedure
|
||||||
|
|
||||||
|
For staging, reset the target database and replay the same bundle after fixing the
|
||||||
|
export or mapper. For production cutover, take a database snapshot before import,
|
||||||
|
keep Inter-Hub read-only during verification, and roll traffic back to Inter-Hub if
|
||||||
|
row counts, relationship checks, or consumer smokes fail. Do not delete or rotate
|
||||||
|
legacy key custody records during the rollback window.
|
||||||
|
|
||||||
## Haskell Retirement Gate
|
## Haskell Retirement Gate
|
||||||
|
|
||||||
Do not retire production Inter-Hub or the haskelseed/Haskell build lane until Core Hub serves the required compatibility surface and production traffic has moved.
|
Do not retire production Inter-Hub or the haskelseed/Haskell build lane until Core Hub serves the required compatibility surface and production traffic has moved.
|
||||||
|
|||||||
39
migrations/versions/20260627_0002_add_migration_runs.py
Normal file
39
migrations/versions/20260627_0002_add_migration_runs.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""add migration run ledger
|
||||||
|
|
||||||
|
Revision ID: 20260627_0002
|
||||||
|
Revises: 20260627_0001
|
||||||
|
Create Date: 2026-06-27
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260627_0002"
|
||||||
|
down_revision: str | None = "20260627_0001"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"migration_runs",
|
||||||
|
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||||
|
sa.Column("source", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("schema_version", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("bundle_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("dry_run", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("counts_json", sa.Text(), nullable=False),
|
||||||
|
sa.Column("diagnostics_json", sa.Text(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_migration_runs_source", "migration_runs", ["source"])
|
||||||
|
op.create_index("ix_migration_runs_bundle_sha256", "migration_runs", ["bundle_sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_migration_runs_bundle_sha256", table_name="migration_runs")
|
||||||
|
op.drop_index("ix_migration_runs_source", table_name="migration_runs")
|
||||||
|
op.drop_table("migration_runs")
|
||||||
52
scripts/core_hub_migrate.py
Normal file
52
scripts/core_hub_migrate.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core_hub.db import get_sessionmaker
|
||||||
|
from core_hub.migration import import_bundle, validate_bundle
|
||||||
|
|
||||||
|
|
||||||
|
def load_bundle(path: Path) -> dict[str, Any]:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
value = json.load(handle)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SystemExit("migration bundle must be a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(report: dict[str, Any]) -> None:
|
||||||
|
print(json.dumps(report, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
async def run_import(path: Path, dry_run: bool) -> dict[str, Any]:
|
||||||
|
bundle = load_bundle(path)
|
||||||
|
sessionmaker = get_sessionmaker()
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
return await import_bundle(session, bundle, dry_run=dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate or import Core Hub migration bundles.")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
validate_parser = subparsers.add_parser("validate", help="Validate a bundle without DB access.")
|
||||||
|
validate_parser.add_argument("bundle", type=Path)
|
||||||
|
|
||||||
|
import_parser = subparsers.add_parser("import", help="Import a bundle into the configured DB.")
|
||||||
|
import_parser.add_argument("bundle", type=Path)
|
||||||
|
import_parser.add_argument("--dry-run", action="store_true")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == "validate":
|
||||||
|
report = validate_bundle(load_bundle(args.bundle))
|
||||||
|
else:
|
||||||
|
report = asyncio.run(run_import(args.bundle, dry_run=args.dry_run))
|
||||||
|
print_report(report)
|
||||||
|
return 0 if report.get("ok") else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
419
src/core_hub/migration.py
Normal file
419
src/core_hub/migration.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
ApiConsumer,
|
||||||
|
ApiKey,
|
||||||
|
Hub,
|
||||||
|
HubCapabilityManifest,
|
||||||
|
InteractionEvent,
|
||||||
|
MigrationRun,
|
||||||
|
Widget,
|
||||||
|
uuid_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
MIGRATION_SCHEMA_VERSION = "core-hub.migration.v1"
|
||||||
|
COLLECTIONS = (
|
||||||
|
"hubs",
|
||||||
|
"hubCapabilityManifests",
|
||||||
|
"apiConsumers",
|
||||||
|
"apiKeys",
|
||||||
|
"widgets",
|
||||||
|
"interactionEvents",
|
||||||
|
)
|
||||||
|
SECRET_FIELD_NAMES = {"fullKey", "rawKey", "apiKey", "secret", "token"}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_body(payload: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_digest(bundle: dict[str, Any]) -> str:
|
||||||
|
canonical = json.dumps(bundle, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def records_for(bundle: dict[str, Any], collection: str) -> list[dict[str, Any]]:
|
||||||
|
records = bundle.get("records", {})
|
||||||
|
if not isinstance(records, dict):
|
||||||
|
return []
|
||||||
|
values = records.get(collection, [])
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
return [record for record in values if isinstance(record, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _input_counts(bundle: dict[str, Any]) -> dict[str, dict[str, int]]:
|
||||||
|
return {
|
||||||
|
collection: {"input": len(records_for(bundle, collection))} for collection in COLLECTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _work_counts(bundle: dict[str, Any]) -> dict[str, dict[str, int]]:
|
||||||
|
return {
|
||||||
|
collection: {
|
||||||
|
"input": len(records_for(bundle, collection)),
|
||||||
|
"created": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
for collection in COLLECTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require(record: dict[str, Any], fields: Iterable[str], path: str, errors: list[str]) -> None:
|
||||||
|
for field in fields:
|
||||||
|
if record.get(field) in (None, ""):
|
||||||
|
errors.append(f"{path}.{field} is required")
|
||||||
|
|
||||||
|
|
||||||
|
def _note_duplicate(value: str | None, seen: set[str], path: str, errors: list[str]) -> None:
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
if value in seen:
|
||||||
|
errors.append(f"duplicate {path}: {value}")
|
||||||
|
seen.add(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_secret_field(value: Any) -> bool:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
if key in SECRET_FIELD_NAMES:
|
||||||
|
return True
|
||||||
|
if _contains_secret_field(child):
|
||||||
|
return True
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(_contains_secret_field(child) for child in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship(status: str, name: str, message: str) -> dict[str, str]:
|
||||||
|
return {"name": name, "status": status, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bundle(bundle: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
relationships: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
if bundle.get("schemaVersion") != MIGRATION_SCHEMA_VERSION:
|
||||||
|
errors.append(
|
||||||
|
f"schemaVersion must be {MIGRATION_SCHEMA_VERSION}, got {bundle.get('schemaVersion')!r}"
|
||||||
|
)
|
||||||
|
if not isinstance(bundle.get("records", {}), dict):
|
||||||
|
errors.append("records must be an object")
|
||||||
|
|
||||||
|
hub_ids: set[str] = set()
|
||||||
|
hub_slugs: set[str] = set()
|
||||||
|
manifest_ids: set[str] = set()
|
||||||
|
consumer_ids: set[str] = set()
|
||||||
|
consumer_slugs: set[str] = set()
|
||||||
|
widget_ids: set[str] = set()
|
||||||
|
|
||||||
|
for index, record in enumerate(records_for(bundle, "hubs")):
|
||||||
|
path = f"records.hubs[{index}]"
|
||||||
|
_require(record, ("slug", "name"), path, errors)
|
||||||
|
_note_duplicate(record.get("id"), hub_ids, "hub id", errors)
|
||||||
|
_note_duplicate(record.get("slug"), hub_slugs, "hub slug", errors)
|
||||||
|
|
||||||
|
for index, record in enumerate(records_for(bundle, "hubCapabilityManifests")):
|
||||||
|
path = f"records.hubCapabilityManifests[{index}]"
|
||||||
|
_require(record, ("id",), path, errors)
|
||||||
|
_note_duplicate(record.get("id"), manifest_ids, "manifest id", errors)
|
||||||
|
if not record.get("hubId") and not record.get("hubSlug"):
|
||||||
|
errors.append(f"{path} must include hubId or hubSlug")
|
||||||
|
elif record.get("hubId") not in hub_ids and record.get("hubSlug") not in hub_slugs:
|
||||||
|
warnings.append(f"{path} references a hub outside this bundle")
|
||||||
|
|
||||||
|
for index, record in enumerate(records_for(bundle, "apiConsumers")):
|
||||||
|
path = f"records.apiConsumers[{index}]"
|
||||||
|
_require(record, ("name",), path, errors)
|
||||||
|
if not record.get("id") and not record.get("slug"):
|
||||||
|
errors.append(f"{path} must include id or slug for idempotent import")
|
||||||
|
_note_duplicate(record.get("id"), consumer_ids, "api consumer id", errors)
|
||||||
|
_note_duplicate(record.get("slug"), consumer_slugs, "api consumer slug", errors)
|
||||||
|
manifest_id = record.get("hubCapabilityManifestId")
|
||||||
|
if manifest_id and manifest_id not in manifest_ids:
|
||||||
|
warnings.append(
|
||||||
|
f"{path}.hubCapabilityManifestId references a manifest outside this bundle"
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, record in enumerate(records_for(bundle, "apiKeys")):
|
||||||
|
path = f"records.apiKeys[{index}]"
|
||||||
|
_require(record, ("id", "keyPrefix", "keyHash"), path, errors)
|
||||||
|
if _contains_secret_field(record):
|
||||||
|
errors.append(
|
||||||
|
f"{path} contains a secret-like key field; import only hashes and prefixes"
|
||||||
|
)
|
||||||
|
if not record.get("apiConsumerId") and not record.get("apiConsumerSlug"):
|
||||||
|
errors.append(f"{path} must include apiConsumerId or apiConsumerSlug")
|
||||||
|
elif (
|
||||||
|
record.get("apiConsumerId") not in consumer_ids
|
||||||
|
and record.get("apiConsumerSlug") not in consumer_slugs
|
||||||
|
):
|
||||||
|
warnings.append(f"{path} references an API consumer outside this bundle")
|
||||||
|
|
||||||
|
for index, record in enumerate(records_for(bundle, "widgets")):
|
||||||
|
path = f"records.widgets[{index}]"
|
||||||
|
_require(record, ("id", "name"), path, errors)
|
||||||
|
_note_duplicate(record.get("id"), widget_ids, "widget id", errors)
|
||||||
|
if not record.get("hubId") and not record.get("hubSlug"):
|
||||||
|
errors.append(f"{path} must include hubId or hubSlug")
|
||||||
|
elif record.get("hubId") not in hub_ids and record.get("hubSlug") not in hub_slugs:
|
||||||
|
warnings.append(f"{path} references a hub outside this bundle")
|
||||||
|
|
||||||
|
for index, record in enumerate(records_for(bundle, "interactionEvents")):
|
||||||
|
path = f"records.interactionEvents[{index}]"
|
||||||
|
_require(record, ("id", "eventType"), path, errors)
|
||||||
|
if not record.get("widgetId"):
|
||||||
|
errors.append(f"{path}.widgetId is required")
|
||||||
|
elif record.get("widgetId") not in widget_ids:
|
||||||
|
warnings.append(f"{path}.widgetId references a widget outside this bundle")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
relationships.append(_relationship("fail", "bundle_integrity", "validation errors present"))
|
||||||
|
else:
|
||||||
|
relationships.append(
|
||||||
|
_relationship("pass", "bundle_integrity", "required fields are present")
|
||||||
|
)
|
||||||
|
if warnings:
|
||||||
|
relationships.append(
|
||||||
|
_relationship(
|
||||||
|
"warn", "external_references", "some references must resolve in the target DB"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
relationships.append(
|
||||||
|
_relationship("pass", "external_references", "all references resolve within the bundle")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": not errors,
|
||||||
|
"schemaVersion": bundle.get("schemaVersion"),
|
||||||
|
"source": bundle.get("source", "unknown"),
|
||||||
|
"bundleSha256": bundle_digest(bundle),
|
||||||
|
"counts": _input_counts(bundle),
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
"relationshipChecks": relationships,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _one_or_none(session: AsyncSession, statement: Any) -> Any | None:
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def _hub_maps(session: AsyncSession) -> tuple[dict[str, Hub], dict[str, Hub]]:
|
||||||
|
result = await session.execute(select(Hub))
|
||||||
|
rows = list(result.scalars())
|
||||||
|
return {row.id: row for row in rows}, {row.slug: row for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _manifest_maps(session: AsyncSession) -> dict[str, HubCapabilityManifest]:
|
||||||
|
result = await session.execute(select(HubCapabilityManifest))
|
||||||
|
rows = list(result.scalars())
|
||||||
|
return {row.id: row for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _consumer_maps(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> tuple[dict[str, ApiConsumer], dict[str, ApiConsumer]]:
|
||||||
|
result = await session.execute(select(ApiConsumer))
|
||||||
|
rows = list(result.scalars())
|
||||||
|
return {row.id: row for row in rows}, {row.slug: row for row in rows if row.slug}
|
||||||
|
|
||||||
|
|
||||||
|
async def _widget_maps(session: AsyncSession) -> dict[str, Widget]:
|
||||||
|
result = await session.execute(select(Widget))
|
||||||
|
rows = list(result.scalars())
|
||||||
|
return {row.id: row for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_hub_id(
|
||||||
|
record: dict[str, Any], hubs_by_id: dict[str, Hub], hubs_by_slug: dict[str, Hub]
|
||||||
|
) -> str:
|
||||||
|
if record.get("hubId"):
|
||||||
|
return str(record["hubId"])
|
||||||
|
return hubs_by_slug[str(record["hubSlug"])].id
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_consumer_id(record: dict[str, Any], consumers_by_slug: dict[str, ApiConsumer]) -> str:
|
||||||
|
if record.get("apiConsumerId"):
|
||||||
|
return str(record["apiConsumerId"])
|
||||||
|
return consumers_by_slug[str(record["apiConsumerSlug"])].id
|
||||||
|
|
||||||
|
|
||||||
|
def _mark(counts: dict[str, dict[str, int]], collection: str, existing: Any | None) -> None:
|
||||||
|
key = "updated" if existing else "created"
|
||||||
|
counts[collection][key] += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def import_bundle(
|
||||||
|
session: AsyncSession, bundle: dict[str, Any], *, dry_run: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
report = validate_bundle(bundle)
|
||||||
|
report["dryRun"] = dry_run
|
||||||
|
report["counts"] = _work_counts(bundle)
|
||||||
|
if not report["ok"]:
|
||||||
|
return report
|
||||||
|
|
||||||
|
hubs_by_id, hubs_by_slug = await _hub_maps(session)
|
||||||
|
for record in records_for(bundle, "hubs"):
|
||||||
|
existing = hubs_by_id.get(record.get("id")) or hubs_by_slug.get(record.get("slug"))
|
||||||
|
_mark(report["counts"], "hubs", existing)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
row = existing or Hub(
|
||||||
|
id=record.get("id") or uuid_str(),
|
||||||
|
slug=record["slug"],
|
||||||
|
name=record["name"],
|
||||||
|
)
|
||||||
|
row.slug = record["slug"]
|
||||||
|
row.name = record["name"]
|
||||||
|
row.domain = record.get("domain")
|
||||||
|
row.hub_kind = record.get("hubKind")
|
||||||
|
row.hub_family = record.get("hubFamily")
|
||||||
|
row.vsm_function = record.get("vsmFunction")
|
||||||
|
row.vsm_system = record.get("vsmSystem")
|
||||||
|
row.status = record.get("status", "active")
|
||||||
|
row.description = record.get("description")
|
||||||
|
row.body_json = encode_body(record)
|
||||||
|
session.add(row)
|
||||||
|
if not dry_run:
|
||||||
|
await session.flush()
|
||||||
|
hubs_by_id, hubs_by_slug = await _hub_maps(session)
|
||||||
|
|
||||||
|
manifests_by_id = await _manifest_maps(session)
|
||||||
|
for record in records_for(bundle, "hubCapabilityManifests"):
|
||||||
|
existing = manifests_by_id.get(record.get("id"))
|
||||||
|
_mark(report["counts"], "hubCapabilityManifests", existing)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
hub_id = _resolved_hub_id(record, hubs_by_id, hubs_by_slug)
|
||||||
|
hub_slug = hubs_by_id[hub_id].slug if hub_id in hubs_by_id else record.get("hubSlug")
|
||||||
|
row = existing or HubCapabilityManifest(id=record["id"], body_json="{}")
|
||||||
|
row.hub_id = hub_id
|
||||||
|
row.hub_slug = hub_slug
|
||||||
|
row.manifest_version = record.get("manifestVersion", "0.1.0")
|
||||||
|
row.status = record.get("status", "draft")
|
||||||
|
row.body_json = encode_body(record)
|
||||||
|
session.add(row)
|
||||||
|
if not dry_run:
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
consumers_by_id, consumers_by_slug = await _consumer_maps(session)
|
||||||
|
for record in records_for(bundle, "apiConsumers"):
|
||||||
|
existing = consumers_by_id.get(record.get("id")) or consumers_by_slug.get(
|
||||||
|
record.get("slug")
|
||||||
|
)
|
||||||
|
_mark(report["counts"], "apiConsumers", existing)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
row = existing or ApiConsumer(
|
||||||
|
id=record.get("id") or uuid_str(),
|
||||||
|
slug=record.get("slug"),
|
||||||
|
name=record["name"],
|
||||||
|
)
|
||||||
|
row.slug = record.get("slug")
|
||||||
|
row.name = record["name"]
|
||||||
|
row.description = record.get("description")
|
||||||
|
row.hub_capability_manifest_id = record.get("hubCapabilityManifestId")
|
||||||
|
row.rate_limit_per_minute = record.get("rateLimitPerMinute")
|
||||||
|
row.quota_per_day = record.get("quotaPerDay")
|
||||||
|
row.key_prefix = record.get("keyPrefix")
|
||||||
|
row.key_hash = record.get("keyHash")
|
||||||
|
row.status = record.get("status", "active")
|
||||||
|
row.body_json = encode_body(record)
|
||||||
|
session.add(row)
|
||||||
|
if not dry_run:
|
||||||
|
await session.flush()
|
||||||
|
consumers_by_id, consumers_by_slug = await _consumer_maps(session)
|
||||||
|
|
||||||
|
for record in records_for(bundle, "apiKeys"):
|
||||||
|
existing = await session.get(ApiKey, record.get("id"))
|
||||||
|
if existing is None:
|
||||||
|
existing = await _one_or_none(
|
||||||
|
session, select(ApiKey).where(ApiKey.key_hash == record["keyHash"])
|
||||||
|
)
|
||||||
|
_mark(report["counts"], "apiKeys", existing)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
row = existing or ApiKey(
|
||||||
|
id=record["id"],
|
||||||
|
api_consumer_id=_resolved_consumer_id(record, consumers_by_slug),
|
||||||
|
)
|
||||||
|
row.api_consumer_id = _resolved_consumer_id(record, consumers_by_slug)
|
||||||
|
row.key_prefix = record["keyPrefix"]
|
||||||
|
row.key_hash = record["keyHash"]
|
||||||
|
row.scopes = record.get("scopes")
|
||||||
|
row.status = record.get("status", "active")
|
||||||
|
session.add(row)
|
||||||
|
|
||||||
|
widgets_by_id = await _widget_maps(session)
|
||||||
|
for record in records_for(bundle, "widgets"):
|
||||||
|
existing = widgets_by_id.get(record.get("id"))
|
||||||
|
_mark(report["counts"], "widgets", existing)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
row = existing or Widget(
|
||||||
|
id=record["id"],
|
||||||
|
hub_id=_resolved_hub_id(record, hubs_by_id, hubs_by_slug),
|
||||||
|
name=record["name"],
|
||||||
|
)
|
||||||
|
row.hub_id = _resolved_hub_id(record, hubs_by_id, hubs_by_slug)
|
||||||
|
row.name = record["name"]
|
||||||
|
row.widget_type = record.get("widgetType")
|
||||||
|
row.capability_ref = record.get("capabilityRef")
|
||||||
|
row.view_context = record.get("viewContext")
|
||||||
|
row.policy_scope = record.get("policyScope")
|
||||||
|
row.status = record.get("status", "active")
|
||||||
|
row.body_json = encode_body(record)
|
||||||
|
session.add(row)
|
||||||
|
if not dry_run:
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
for record in records_for(bundle, "interactionEvents"):
|
||||||
|
existing = await session.get(InteractionEvent, record.get("id"))
|
||||||
|
_mark(report["counts"], "interactionEvents", existing)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
row = existing or InteractionEvent(
|
||||||
|
id=record["id"],
|
||||||
|
widget_id=record["widgetId"],
|
||||||
|
event_type=record["eventType"],
|
||||||
|
)
|
||||||
|
row.widget_id = record["widgetId"]
|
||||||
|
row.event_type = record["eventType"]
|
||||||
|
row.view_context = record.get("viewContext")
|
||||||
|
row.metadata_json = encode_body(record.get("metadata", {}))
|
||||||
|
row.body_json = encode_body(record)
|
||||||
|
session.add(row)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
run = MigrationRun(
|
||||||
|
source=report["source"],
|
||||||
|
schema_version=report["schemaVersion"],
|
||||||
|
bundle_sha256=report["bundleSha256"],
|
||||||
|
dry_run=False,
|
||||||
|
status="imported",
|
||||||
|
counts_json=encode_body(report["counts"]),
|
||||||
|
diagnostics_json=encode_body(
|
||||||
|
{
|
||||||
|
"errors": report["errors"],
|
||||||
|
"warnings": report["warnings"],
|
||||||
|
"relationshipChecks": report["relationshipChecks"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(run)
|
||||||
|
report["migrationRunId"] = run.id
|
||||||
|
|
||||||
|
return report
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .db import Base
|
from .db import Base
|
||||||
@@ -102,3 +102,17 @@ class InteractionEvent(Base):
|
|||||||
metadata_json: Mapped[str] = mapped_column(Text(), default="{}")
|
metadata_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||||
body_json: Mapped[str] = mapped_column(Text(), default="{}")
|
body_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationRun(Base):
|
||||||
|
__tablename__ = "migration_runs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||||
|
source: Mapped[str] = mapped_column(String(120), index=True)
|
||||||
|
schema_version: Mapped[str] = mapped_column(String(80))
|
||||||
|
bundle_sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
dry_run: Mapped[bool] = mapped_column(Boolean(), default=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(40), default="imported")
|
||||||
|
counts_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||||
|
diagnostics_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ FOUNDATION_TABLES = {
|
|||||||
"api_keys",
|
"api_keys",
|
||||||
"widgets",
|
"widgets",
|
||||||
"interaction_events",
|
"interaction_events",
|
||||||
|
"migration_runs",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
116
tests/test_migration.py
Normal file
116
tests/test_migration.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
|
from core_hub.migration import import_bundle, validate_bundle
|
||||||
|
from core_hub.models import Hub, MigrationRun, Widget
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_bundle():
|
||||||
|
return {
|
||||||
|
"schemaVersion": "core-hub.migration.v1",
|
||||||
|
"source": "inter-hub-haskell",
|
||||||
|
"exportedAt": "2026-06-27T00:00:00Z",
|
||||||
|
"records": {
|
||||||
|
"hubs": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"slug": "ops-hub",
|
||||||
|
"name": "Ops Hub",
|
||||||
|
"domain": "ops.coulomb.social",
|
||||||
|
"hubKind": "domain",
|
||||||
|
"hubFamily": "vsm",
|
||||||
|
"vsmFunction": "OPS",
|
||||||
|
"vsmSystem": "1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hubCapabilityManifests": [
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"hubSlug": "ops-hub",
|
||||||
|
"manifestVersion": "1.0",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"apiConsumers": [
|
||||||
|
{
|
||||||
|
"id": "33333333-3333-4333-8333-333333333333",
|
||||||
|
"slug": "ops-hub",
|
||||||
|
"name": "ops-hub",
|
||||||
|
"hubCapabilityManifestId": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"keyPrefix": "ch_prefixonly",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"apiKeys": [
|
||||||
|
{
|
||||||
|
"id": "44444444-4444-4444-8444-444444444444",
|
||||||
|
"apiConsumerSlug": "ops-hub",
|
||||||
|
"keyPrefix": "ch_prefixonly",
|
||||||
|
"keyHash": "a" * 64,
|
||||||
|
"scopes": "framework:read hub:ops-hub:write",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"id": "55555555-5555-4555-8555-555555555555",
|
||||||
|
"hubSlug": "ops-hub",
|
||||||
|
"name": "Readiness",
|
||||||
|
"widgetType": "ops-readiness-gate",
|
||||||
|
"capabilityRef": "ops:readiness",
|
||||||
|
"viewContext": "ops-hub/readiness",
|
||||||
|
"policyScope": "ops-registry",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactionEvents": [
|
||||||
|
{
|
||||||
|
"id": "66666666-6666-4666-8666-666666666666",
|
||||||
|
"widgetId": "55555555-5555-4555-8555-555555555555",
|
||||||
|
"eventType": "ops-endpoint-verified",
|
||||||
|
"metadata": {"expectedStatus": 401},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_bundle_rejects_secret_key_material():
|
||||||
|
bundle = minimal_bundle()
|
||||||
|
bundle["records"]["apiKeys"][0]["fullKey"] = "do-not-import"
|
||||||
|
|
||||||
|
report = validate_bundle(bundle)
|
||||||
|
|
||||||
|
assert report["ok"] is False
|
||||||
|
assert any("secret-like" in error for error in report["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dry_run_reports_counts_without_writing(sqlite_engine):
|
||||||
|
sessionmaker = async_sessionmaker(sqlite_engine, expire_on_commit=False)
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
report = await import_bundle(session, minimal_bundle(), dry_run=True)
|
||||||
|
|
||||||
|
assert report["ok"] is True
|
||||||
|
assert report["counts"]["hubs"]["created"] == 1
|
||||||
|
assert (await session.execute(select(Hub))).scalars().first() is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_bundle_persists_records_and_run_summary(sqlite_engine):
|
||||||
|
sessionmaker = async_sessionmaker(sqlite_engine, expire_on_commit=False)
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
report = await import_bundle(session, minimal_bundle())
|
||||||
|
|
||||||
|
assert report["ok"] is True
|
||||||
|
assert report["migrationRunId"]
|
||||||
|
assert report["counts"]["widgets"]["created"] == 1
|
||||||
|
|
||||||
|
hub = (await session.execute(select(Hub))).scalars().one()
|
||||||
|
widget = (await session.execute(select(Widget))).scalars().one()
|
||||||
|
run = (await session.execute(select(MigrationRun))).scalars().one()
|
||||||
|
|
||||||
|
assert hub.slug == "ops-hub"
|
||||||
|
assert widget.hub_id == hub.id
|
||||||
|
assert json.loads(run.counts_json)["interactionEvents"]["created"] == 1
|
||||||
|
|
||||||
|
second = await import_bundle(session, minimal_bundle())
|
||||||
|
assert second["counts"]["hubs"]["updated"] == 1
|
||||||
|
assert second["counts"]["interactionEvents"]["updated"] == 1
|
||||||
@@ -20,12 +20,12 @@ Move production framework responsibility from Haskell Inter-Hub to Core Hub only
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CORE-WP-0005-T01
|
id: CORE-WP-0005-T01
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "01909b75-2229-41e2-a61a-c4c9ef84c232"
|
state_hub_task_id: "01909b75-2229-41e2-a61a-c4c9ef84c232"
|
||||||
```
|
```
|
||||||
|
|
||||||
Define export/import flow, migration batches, row-count checks, relationship checks, and rollback. Waits on CORE-WP-0002 and CORE-WP-0003.
|
Result 2026-06-27: Defined and implemented the `core-hub.migration.v1` bundle path with validation, dry-run/import CLI, idempotent framework-record import, row-count reports, relationship diagnostics, non-secret API key handling, persisted `migration_runs`, fixture/schema coverage, and rollback procedure.
|
||||||
|
|
||||||
## Run Staging Import
|
## Run Staging Import
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ priority: high
|
|||||||
state_hub_task_id: "e0ea0928-a3ea-4ece-bbb7-ee08f8a3279b"
|
state_hub_task_id: "e0ea0928-a3ea-4ece-bbb7-ee08f8a3279b"
|
||||||
```
|
```
|
||||||
|
|
||||||
Import production-safe or full approved Inter-Hub data into Core Hub staging. Record counts and discrepancies.
|
Import production-safe or full approved Inter-Hub data into Core Hub staging. The importer is ready; this now waits on an approved Inter-Hub export bundle and target staging database. Record counts and discrepancies from the generated migration report.
|
||||||
|
|
||||||
## Dual-Run Smokes
|
## Dual-Run Smokes
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user