feat(observability): add safe SigNoz metadata backup lane
This commit is contained in:
371
scripts/backup/signoz-metadata-export.py
Executable file
371
scripts/backup/signoz-metadata-export.py
Executable file
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a stable, secret-scanned SigNoz metadata API export bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from signoz_metadata_contract import (
|
||||
DEFAULT_POLICY,
|
||||
ApiClient,
|
||||
ContractError,
|
||||
canonical_json_bytes,
|
||||
load_policy,
|
||||
receipt,
|
||||
require_no_symlink_components,
|
||||
sha256_bytes,
|
||||
sha256_file,
|
||||
utc_now,
|
||||
validate_asset_document,
|
||||
validate_base_url,
|
||||
validate_identity,
|
||||
validate_new_private_destination,
|
||||
validate_token_reference,
|
||||
write_new_private_atomic,
|
||||
write_private,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Export an allowlisted SigNoz metadata subset without raw SQLite or "
|
||||
"Docker volume access."
|
||||
)
|
||||
)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--check", action="store_true")
|
||||
mode.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--base-url", required=True)
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--credential-file")
|
||||
parser.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
parser.add_argument("--trace-id", required=True)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--work-item-id", default="P0-OBS-002")
|
||||
parser.add_argument("--receipt-file")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def validate_destination(path: Path) -> None:
|
||||
if not path.is_absolute():
|
||||
raise ContractError("output_dir_must_be_absolute")
|
||||
if path.exists() or path.is_symlink():
|
||||
raise ContractError("output_dir_must_not_exist")
|
||||
parent = path.parent
|
||||
require_no_symlink_components(parent, label="output_parent")
|
||||
try:
|
||||
metadata = parent.lstat()
|
||||
except OSError as exc:
|
||||
raise ContractError("output_parent_unavailable") from exc
|
||||
if not parent.is_dir() or parent.is_symlink():
|
||||
raise ContractError("output_parent_must_be_real_directory")
|
||||
if metadata.st_uid != os.geteuid() or not os.access(parent, os.W_OK | os.X_OK):
|
||||
raise ContractError("output_parent_not_owned_and_writable")
|
||||
|
||||
|
||||
def emit_stdout(value: dict[str, Any]) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
def run_check(args: argparse.Namespace, policy: dict[str, Any]) -> dict[str, Any]:
|
||||
if args.credential_file:
|
||||
validate_token_reference(Path(args.credential_file), read_value=False)
|
||||
return receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal="check_pass_no_write",
|
||||
detail=(
|
||||
f"policy_assets_{len(policy['assets'])}_raw_sqlite_0_"
|
||||
"raw_volume_0_output_write_0"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def stable_read(
|
||||
client: ApiClient,
|
||||
policy: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, int]]:
|
||||
pass_documents: list[dict[str, Any]] = []
|
||||
pass_counts: list[dict[str, int]] = []
|
||||
for _ in range(policy["limits"]["stable_read_count"]):
|
||||
documents: dict[str, Any] = {}
|
||||
counts: dict[str, int] = {}
|
||||
for asset in policy["assets"]:
|
||||
document = client.request_json(asset["endpoint"])
|
||||
counts[asset["id"]] = validate_asset_document(document, asset, policy)
|
||||
documents[asset["id"]] = document
|
||||
pass_documents.append(documents)
|
||||
pass_counts.append(counts)
|
||||
|
||||
if canonical_json_bytes(pass_documents[0]) != canonical_json_bytes(
|
||||
pass_documents[1]
|
||||
):
|
||||
raise ContractError("source_metadata_drift_between_stable_reads")
|
||||
if pass_counts[0] != pass_counts[1]:
|
||||
raise ContractError("source_item_count_drift_between_stable_reads")
|
||||
return pass_documents[0], pass_counts[0]
|
||||
|
||||
|
||||
def create_bundle(
|
||||
args: argparse.Namespace,
|
||||
policy_path: Path,
|
||||
policy: dict[str, Any],
|
||||
documents: dict[str, Any],
|
||||
counts: dict[str, int],
|
||||
) -> dict[str, Any]:
|
||||
output_dir = Path(args.output_dir)
|
||||
temp_dir: Path | None = Path(
|
||||
tempfile.mkdtemp(prefix=f".{output_dir.name}.partial.", dir=output_dir.parent)
|
||||
)
|
||||
temp_dir.chmod(0o700)
|
||||
assets_dir = temp_dir / "assets"
|
||||
assets_dir.mkdir(mode=0o700)
|
||||
try:
|
||||
asset_receipts: list[dict[str, Any]] = []
|
||||
for asset in policy["assets"]:
|
||||
asset_id = asset["id"]
|
||||
payload = canonical_json_bytes(documents[asset_id])
|
||||
asset_path = assets_dir / f"{asset_id}.json"
|
||||
write_private(asset_path, payload)
|
||||
asset_receipts.append(
|
||||
{
|
||||
"id": asset_id,
|
||||
"classification": asset["classification"],
|
||||
"endpoint": asset["endpoint"],
|
||||
"file": f"assets/{asset_id}.json",
|
||||
"sha256": sha256_bytes(payload),
|
||||
"size_bytes": len(payload),
|
||||
"item_count": counts[asset_id],
|
||||
}
|
||||
)
|
||||
|
||||
portable_count = sum(
|
||||
item["classification"] == "portable" for item in asset_receipts
|
||||
)
|
||||
manifest = {
|
||||
"schema": "awoooi_signoz_metadata_export_bundle_v1",
|
||||
"trace_id": args.trace_id,
|
||||
"run_id": args.run_id,
|
||||
"work_item_id": args.work_item_id,
|
||||
"created_at": utc_now(),
|
||||
"source_runtime_id": policy["source_runtime"]["canonical_id"],
|
||||
"source_version": policy["source_runtime"]["signoz_version"],
|
||||
"source_base_url_sha256": sha256_bytes(
|
||||
validate_base_url(args.base_url).encode("utf-8")
|
||||
),
|
||||
"policy_sha256": sha256_file(policy_path),
|
||||
"stable_read_count": policy["limits"]["stable_read_count"],
|
||||
"stable_read_terminal": "pass",
|
||||
"raw_sqlite_read": False,
|
||||
"raw_volume_read": False,
|
||||
"sensitive_value_persisted": False,
|
||||
"assets": asset_receipts,
|
||||
"coverage": {
|
||||
"portable_asset_count": portable_count,
|
||||
"export_only_asset_count": len(asset_receipts) - portable_count,
|
||||
"forbidden_endpoint_count": len(policy["forbidden_endpoints"]),
|
||||
"full_sqlite_coverage": False,
|
||||
},
|
||||
"terminal": "portable_metadata_export_created_restore_unverified",
|
||||
"completion_claim": False,
|
||||
}
|
||||
write_private(temp_dir / "manifest.json", canonical_json_bytes(manifest))
|
||||
|
||||
receipt_rows = [
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="sensor_source",
|
||||
terminal="pass",
|
||||
detail="allowlisted_api_get_two_stable_reads",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="normalized_asset_identity",
|
||||
terminal="pass",
|
||||
detail=f"canonical_asset_count_{len(asset_receipts)}",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="source_of_truth_diff",
|
||||
terminal="pass",
|
||||
detail="second_read_matches_first_read_canonical_hash",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="ai_decision",
|
||||
terminal="pass",
|
||||
detail="candidate_action_export_portable_metadata_subset",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="risk_policy_decision",
|
||||
terminal="pass",
|
||||
detail="low_risk_read_only_source_no_raw_sqlite_no_secret_persistence",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="check_mode",
|
||||
terminal="pass",
|
||||
detail="policy_destination_and_credential_reference_preconditions_pass",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="bounded_execution",
|
||||
terminal="pass",
|
||||
detail=f"private_atomic_bundle_assets_{len(asset_receipts)}",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="rollback",
|
||||
terminal="not_required",
|
||||
detail="production_source_read_only_output_created_atomically",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal="partial_degraded",
|
||||
detail="portable_export_created_independent_restore_verifier_pending",
|
||||
),
|
||||
]
|
||||
receipt_payload = b"".join(canonical_json_bytes(item) for item in receipt_rows)
|
||||
write_private(temp_dir / "receipts.jsonl", receipt_payload)
|
||||
os.replace(temp_dir, output_dir)
|
||||
try:
|
||||
parent_descriptor = os.open(output_dir.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(parent_descriptor)
|
||||
finally:
|
||||
os.close(parent_descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
temp_dir = None
|
||||
return manifest
|
||||
finally:
|
||||
if temp_dir is not None and temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def run_apply(
|
||||
args: argparse.Namespace, policy_path: Path, policy: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if not args.receipt_file:
|
||||
raise ContractError("receipt_file_required_for_apply")
|
||||
if not args.credential_file:
|
||||
raise ContractError("credential_file_required_for_apply")
|
||||
token = validate_token_reference(Path(args.credential_file), read_value=True)
|
||||
if token is None:
|
||||
raise ContractError("credential_value_unavailable")
|
||||
client = ApiClient(
|
||||
base_url=args.base_url,
|
||||
header_name=policy["authentication"]["header_name"],
|
||||
token=token,
|
||||
timeout_seconds=policy["limits"]["request_timeout_seconds"],
|
||||
max_response_bytes=policy["limits"]["max_response_bytes"],
|
||||
)
|
||||
documents, counts = stable_read(client, policy)
|
||||
manifest = create_bundle(args, policy_path, policy, documents, counts)
|
||||
return receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal="partial_degraded",
|
||||
detail=(
|
||||
"bundle_created_manifest_hash_"
|
||||
f"{sha256_bytes(canonical_json_bytes(manifest))}_restore_pending"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def persist_optional_receipt(args: argparse.Namespace, value: dict[str, Any]) -> None:
|
||||
if args.receipt_file:
|
||||
write_new_private_atomic(
|
||||
Path(args.receipt_file),
|
||||
canonical_json_bytes(value),
|
||||
label="export_terminal_receipt",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
validate_identity(args.trace_id, label="trace_id")
|
||||
validate_identity(args.run_id, label="run_id")
|
||||
validate_identity(args.work_item_id, label="work_item_id")
|
||||
validate_base_url(args.base_url)
|
||||
validate_destination(Path(args.output_dir))
|
||||
if args.apply and not args.receipt_file:
|
||||
raise ContractError("receipt_file_required_for_apply")
|
||||
if args.receipt_file:
|
||||
validate_new_private_destination(
|
||||
Path(args.receipt_file), label="export_terminal_receipt"
|
||||
)
|
||||
policy_path = Path(args.policy)
|
||||
policy = load_policy(policy_path)
|
||||
if policy["work_item_id"] != args.work_item_id:
|
||||
raise ContractError("work_item_policy_mismatch")
|
||||
if args.check:
|
||||
result = run_check(args, policy)
|
||||
else:
|
||||
result = run_apply(args, policy_path, policy)
|
||||
persist_optional_receipt(args, result)
|
||||
emit_stdout(result)
|
||||
except (ContractError, OSError) as exc:
|
||||
try:
|
||||
validate_identity(args.trace_id, label="trace_id")
|
||||
validate_identity(args.run_id, label="run_id")
|
||||
validate_identity(args.work_item_id, label="work_item_id")
|
||||
failure = receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal=(
|
||||
"check_failed_no_write"
|
||||
if args.check
|
||||
else "failed_export_not_closable"
|
||||
),
|
||||
detail=(
|
||||
f"metadata_export_failed_{exc}_output_"
|
||||
f"{'created_unverified' if Path(args.output_dir).is_dir() else 'absent'}"
|
||||
),
|
||||
)
|
||||
persist_optional_receipt(args, failure)
|
||||
emit_stdout(failure)
|
||||
except (ContractError, OSError) as receipt_exc:
|
||||
print(f"RECEIPT_ERROR={receipt_exc}", file=sys.stderr)
|
||||
print(f"ERROR={exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
510
scripts/backup/signoz-metadata-restore-drill.py
Executable file
510
scripts/backup/signoz-metadata-restore-drill.py
Executable file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restore a verified SigNoz metadata subset into an isolated API target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
|
||||
# Child commands use fixed argv without a shell.
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from signoz_metadata_contract import (
|
||||
DEFAULT_POLICY,
|
||||
SHA256,
|
||||
ApiClient,
|
||||
ContractError,
|
||||
canonical_json_bytes,
|
||||
load_policy,
|
||||
read_json_object,
|
||||
receipt,
|
||||
semantic_items,
|
||||
sha256_bytes,
|
||||
validate_base_url,
|
||||
validate_asset_document,
|
||||
validate_identity,
|
||||
validate_new_private_destination,
|
||||
validate_token_reference,
|
||||
write_new_private_atomic,
|
||||
)
|
||||
|
||||
|
||||
VERIFIER = Path(__file__).with_name("verify-signoz-metadata-export.py")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--check", action="store_true")
|
||||
mode.add_argument("--apply", action="store_true")
|
||||
mode.add_argument("--verify-cleanup", action="store_true")
|
||||
parser.add_argument("--bundle-dir", required=True)
|
||||
parser.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
parser.add_argument("--target-base-url", required=True)
|
||||
parser.add_argument("--credential-file")
|
||||
parser.add_argument("--isolation-receipt", required=True)
|
||||
parser.add_argument("--trace-id", required=True)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--work-item-id", default="P0-OBS-002")
|
||||
parser.add_argument("--receipt-file")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def run_independent_bundle_verifier(args: argparse.Namespace) -> None:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(VERIFIER),
|
||||
"--bundle-dir",
|
||||
args.bundle_dir,
|
||||
"--policy",
|
||||
args.policy,
|
||||
"--expected-trace-id",
|
||||
args.trace_id,
|
||||
"--expected-run-id",
|
||||
args.run_id,
|
||||
"--expected-work-item-id",
|
||||
args.work_item_id,
|
||||
]
|
||||
# The verifier executable and argument vector are fixed.
|
||||
result = subprocess.run( # nosec B603
|
||||
command,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ContractError("independent_bundle_verifier_failed")
|
||||
try:
|
||||
verifier_receipt = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ContractError("independent_bundle_verifier_receipt_invalid") from exc
|
||||
if verifier_receipt.get("terminal") != "pass_export_verified_restore_pending":
|
||||
raise ContractError("independent_bundle_verifier_terminal_invalid")
|
||||
|
||||
|
||||
def validate_isolation_receipt(
|
||||
args: argparse.Namespace,
|
||||
policy: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
isolation_path = Path(args.isolation_receipt)
|
||||
try:
|
||||
isolation_metadata = isolation_path.lstat()
|
||||
except OSError as exc:
|
||||
raise ContractError("isolation_receipt_unavailable") from exc
|
||||
if not stat.S_ISREG(isolation_metadata.st_mode):
|
||||
raise ContractError("isolation_receipt_not_regular_file")
|
||||
if (
|
||||
isolation_metadata.st_uid != os.geteuid()
|
||||
or stat.S_IMODE(isolation_metadata.st_mode) != 0o600
|
||||
):
|
||||
raise ContractError("isolation_receipt_owner_or_mode_invalid")
|
||||
isolation = read_json_object(isolation_path, label="isolation_receipt")
|
||||
if isolation.get("schema") != "awoooi_signoz_metadata_restore_isolation_v1":
|
||||
raise ContractError("isolation_receipt_schema_invalid")
|
||||
for key, expected in (
|
||||
("trace_id", args.trace_id),
|
||||
("run_id", args.run_id),
|
||||
("work_item_id", args.work_item_id),
|
||||
):
|
||||
if isolation.get(key) != expected:
|
||||
raise ContractError(f"isolation_receipt_{key}_mismatch")
|
||||
|
||||
target = isolation.get("target")
|
||||
if not isinstance(target, dict):
|
||||
raise ContractError("isolation_target_missing")
|
||||
isolation_policy = policy["restore_isolation"]
|
||||
target_url = validate_base_url(args.target_base_url, loopback_only=True)
|
||||
if target.get("base_url_sha256") != sha256_bytes(target_url.encode("utf-8")):
|
||||
raise ContractError("isolation_target_url_hash_mismatch")
|
||||
canonical_id = target.get("canonical_id")
|
||||
if not isinstance(canonical_id, str) or not canonical_id.startswith(
|
||||
"ephemeral-signoz-metadata-restore-"
|
||||
):
|
||||
raise ContractError("isolation_target_canonical_id_invalid")
|
||||
if target.get("image_ref") != isolation_policy["required_image_ref"]:
|
||||
raise ContractError("isolation_target_image_ref_mismatch")
|
||||
image_id = target.get("image_id")
|
||||
if (
|
||||
not isinstance(image_id, str)
|
||||
or not image_id.startswith("sha256:")
|
||||
or not SHA256.fullmatch(image_id.removeprefix("sha256:"))
|
||||
):
|
||||
raise ContractError("isolation_target_image_id_invalid")
|
||||
required_values = {
|
||||
"image_present_locally": True,
|
||||
"image_pull_performed": False,
|
||||
"production_network_attached": False,
|
||||
"production_volume_attached": False,
|
||||
"ephemeral_volumes_only": True,
|
||||
"cleanup_controller_armed": True,
|
||||
"zero_residue_verifier_armed": True,
|
||||
"network_scope": "loopback_and_internal_only",
|
||||
"resource_label_key": isolation_policy["resource_label_key"],
|
||||
"resource_label_value": args.run_id,
|
||||
}
|
||||
for key, expected in required_values.items():
|
||||
if target.get(key) != expected:
|
||||
raise ContractError(f"isolation_target_{key}_invalid")
|
||||
return isolation
|
||||
|
||||
|
||||
def load_portable_assets(
|
||||
bundle_dir: Path,
|
||||
policy: dict[str, Any],
|
||||
) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]:
|
||||
result: list[tuple[dict[str, Any], list[dict[str, Any]]]] = []
|
||||
for asset in policy["assets"]:
|
||||
if asset["classification"] != "portable":
|
||||
continue
|
||||
path = bundle_dir / "assets" / f"{asset['id']}.json"
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ContractError(f"portable_asset_read_failed_{asset['id']}") from exc
|
||||
result.append((asset, semantic_items(document, asset)))
|
||||
return result
|
||||
|
||||
|
||||
def count_actions(
|
||||
assets: list[tuple[dict[str, Any], list[dict[str, Any]]]],
|
||||
policy: dict[str, Any],
|
||||
) -> int:
|
||||
action_count = sum(len(items) for _, items in assets)
|
||||
if action_count <= 0:
|
||||
raise ContractError("restore_source_portable_item_count_zero")
|
||||
if action_count > policy["limits"]["max_restore_actions"]:
|
||||
raise ContractError("restore_action_limit_exceeded")
|
||||
return action_count
|
||||
|
||||
|
||||
def make_client(
|
||||
args: argparse.Namespace,
|
||||
policy: dict[str, Any],
|
||||
*,
|
||||
read_token: bool,
|
||||
) -> ApiClient | None:
|
||||
if not args.credential_file:
|
||||
if read_token:
|
||||
raise ContractError("credential_file_required_for_restore_apply")
|
||||
return None
|
||||
token = validate_token_reference(Path(args.credential_file), read_value=read_token)
|
||||
if not read_token:
|
||||
return None
|
||||
if token is None:
|
||||
raise ContractError("credential_value_unavailable")
|
||||
return ApiClient(
|
||||
base_url=validate_base_url(args.target_base_url, loopback_only=True),
|
||||
header_name=policy["authentication"]["header_name"],
|
||||
token=token,
|
||||
timeout_seconds=policy["limits"]["request_timeout_seconds"],
|
||||
max_response_bytes=policy["limits"]["max_response_bytes"],
|
||||
)
|
||||
|
||||
|
||||
def run_check(
|
||||
args: argparse.Namespace,
|
||||
policy: dict[str, Any],
|
||||
assets: list[tuple[dict[str, Any], list[dict[str, Any]]]],
|
||||
) -> dict[str, Any]:
|
||||
make_client(args, policy, read_token=False)
|
||||
action_count = count_actions(assets, policy)
|
||||
return receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal="check_pass_no_write",
|
||||
detail=(
|
||||
f"isolated_target_contract_pass_restore_actions_{action_count}_"
|
||||
"production_write_0"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def read_target_stable(
|
||||
client: ApiClient,
|
||||
assets: list[tuple[dict[str, Any], list[dict[str, Any]]]],
|
||||
policy: dict[str, Any],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
snapshots: list[dict[str, list[dict[str, Any]]]] = []
|
||||
for _ in range(2):
|
||||
current: dict[str, list[dict[str, Any]]] = {}
|
||||
for asset, _ in assets:
|
||||
document = client.request_json(asset["endpoint"])
|
||||
validate_asset_document(document, asset, policy)
|
||||
current[asset["id"]] = semantic_items(document, asset)
|
||||
snapshots.append(current)
|
||||
if canonical_json_bytes(snapshots[0]) != canonical_json_bytes(snapshots[1]):
|
||||
raise ContractError("restore_target_drift_between_stable_reads")
|
||||
return snapshots[0]
|
||||
|
||||
|
||||
def run_apply(
|
||||
args: argparse.Namespace,
|
||||
policy: dict[str, Any],
|
||||
assets: list[tuple[dict[str, Any], list[dict[str, Any]]]],
|
||||
) -> dict[str, Any]:
|
||||
action_count = count_actions(assets, policy)
|
||||
client = make_client(args, policy, read_token=True)
|
||||
if client is None:
|
||||
raise ContractError("restore_client_unavailable")
|
||||
|
||||
before = read_target_stable(client, assets, policy)
|
||||
if any(before[asset["id"]] for asset, _ in assets):
|
||||
raise ContractError("restore_target_not_empty_fail_closed")
|
||||
|
||||
writes = 0
|
||||
for asset, items in assets:
|
||||
restore = asset["restore"]
|
||||
for item in items:
|
||||
client.request_write(restore["method"], restore["endpoint"], item)
|
||||
writes += 1
|
||||
if writes != action_count:
|
||||
raise ContractError("restore_write_count_mismatch")
|
||||
|
||||
after = read_target_stable(client, assets, policy)
|
||||
expected = {asset["id"]: items for asset, items in assets}
|
||||
if canonical_json_bytes(after) != canonical_json_bytes(expected):
|
||||
raise ContractError("restore_semantic_readback_mismatch")
|
||||
terminal = receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal="partial_degraded_cleanup_pending",
|
||||
detail=(
|
||||
f"isolated_restore_actions_{writes}_semantic_readback_pass_"
|
||||
"cleanup_and_zero_residue_verifier_pending"
|
||||
),
|
||||
)
|
||||
terminal["phase_receipts"] = [
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="sensor_source",
|
||||
terminal="pass",
|
||||
detail="independently_verified_portable_export_bundle",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="normalized_asset_identity",
|
||||
terminal="pass",
|
||||
detail=f"portable_assets_{len(assets)}_restore_actions_{writes}",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="source_of_truth_diff",
|
||||
terminal="pass",
|
||||
detail="isolated_target_two_read_stable_and_empty",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="ai_decision",
|
||||
terminal="pass",
|
||||
detail="candidate_action_restore_portable_subset_to_ephemeral_target",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="risk_policy_decision",
|
||||
terminal="pass",
|
||||
detail="high_risk_bounded_loopback_ephemeral_no_production_attachment",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="check_mode",
|
||||
terminal="pass",
|
||||
detail="isolation_bundle_action_limit_and_empty_target_pass",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="bounded_execution",
|
||||
terminal="pass",
|
||||
detail=f"restore_actions_{writes}_delete_actions_0",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="independent_post_verifier",
|
||||
terminal="pass",
|
||||
detail="two_read_semantic_parity_pass",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="rollback",
|
||||
terminal="cleanup_pending",
|
||||
detail="ephemeral_target_cleanup_controller_armed",
|
||||
),
|
||||
receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="learning_writeback",
|
||||
terminal="pending",
|
||||
detail="requires_zero_residue_terminal_before_learning_ack",
|
||||
),
|
||||
]
|
||||
return terminal
|
||||
|
||||
|
||||
def docker_label_count(resource: str, label: str) -> int:
|
||||
command_by_resource = {
|
||||
"container": ["docker", "ps", "-a", "--quiet", "--filter", f"label={label}"],
|
||||
"volume": ["docker", "volume", "ls", "--quiet", "--filter", f"label={label}"],
|
||||
"network": ["docker", "network", "ls", "--quiet", "--filter", f"label={label}"],
|
||||
}
|
||||
try:
|
||||
# The Docker inventory command is selected from the fixed map above.
|
||||
result = subprocess.run( # nosec B603
|
||||
command_by_resource[resource],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ContractError(f"cleanup_{resource}_inventory_failed") from exc
|
||||
if result.returncode != 0:
|
||||
raise ContractError(f"cleanup_{resource}_inventory_failed")
|
||||
return len([line for line in result.stdout.splitlines() if line.strip()])
|
||||
|
||||
|
||||
def require_target_unreachable(base_url: str) -> None:
|
||||
base_url = validate_base_url(base_url, loopback_only=True)
|
||||
request = urllib.request.Request(base_url, method="GET")
|
||||
try:
|
||||
# base_url is validated as loopback HTTP(S) immediately above.
|
||||
with urllib.request.urlopen( # nosec B310
|
||||
request, timeout=2
|
||||
):
|
||||
pass
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ContractError("cleanup_target_listener_still_present") from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return
|
||||
raise ContractError("cleanup_target_listener_still_present")
|
||||
|
||||
|
||||
def run_cleanup_verifier(
|
||||
args: argparse.Namespace,
|
||||
policy: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
label = f"{policy['restore_isolation']['resource_label_key']}={args.run_id}"
|
||||
counts = {
|
||||
resource: docker_label_count(resource, label)
|
||||
for resource in ("container", "volume", "network")
|
||||
}
|
||||
if any(counts.values()):
|
||||
raise ContractError("cleanup_resource_residue_present")
|
||||
require_target_unreachable(args.target_base_url)
|
||||
return receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal="pass_zero_residue_verified",
|
||||
detail="container_0_volume_0_network_0_listener_0",
|
||||
)
|
||||
|
||||
|
||||
def write_receipt(path: Path, value: dict[str, Any]) -> None:
|
||||
write_new_private_atomic(
|
||||
path,
|
||||
canonical_json_bytes(value),
|
||||
label="restore_terminal_receipt",
|
||||
)
|
||||
|
||||
|
||||
def emit_result(value: dict[str, Any]) -> None:
|
||||
print(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
validate_identity(args.trace_id, label="trace_id")
|
||||
validate_identity(args.run_id, label="run_id")
|
||||
validate_identity(args.work_item_id, label="work_item_id")
|
||||
validate_base_url(args.target_base_url, loopback_only=True)
|
||||
if args.apply and not args.receipt_file:
|
||||
raise ContractError("receipt_file_required_for_restore_apply")
|
||||
if args.receipt_file:
|
||||
validate_new_private_destination(
|
||||
Path(args.receipt_file), label="restore_terminal_receipt"
|
||||
)
|
||||
policy = load_policy(Path(args.policy))
|
||||
run_independent_bundle_verifier(args)
|
||||
validate_isolation_receipt(args, policy)
|
||||
assets = load_portable_assets(Path(args.bundle_dir), policy)
|
||||
if args.check:
|
||||
result = run_check(args, policy, assets)
|
||||
elif args.apply:
|
||||
result = run_apply(args, policy, assets)
|
||||
else:
|
||||
result = run_cleanup_verifier(args, policy)
|
||||
if args.receipt_file:
|
||||
write_receipt(Path(args.receipt_file), result)
|
||||
emit_result(result)
|
||||
except (ContractError, OSError, subprocess.TimeoutExpired) as exc:
|
||||
try:
|
||||
validate_identity(args.trace_id, label="trace_id")
|
||||
validate_identity(args.run_id, label="run_id")
|
||||
validate_identity(args.work_item_id, label="work_item_id")
|
||||
if args.check:
|
||||
failure_terminal = "check_failed_no_write"
|
||||
elif args.apply:
|
||||
failure_terminal = "failed_cleanup_required"
|
||||
else:
|
||||
failure_terminal = "cleanup_verifier_failed_residue_unknown"
|
||||
failure = receipt(
|
||||
trace_id=args.trace_id,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
phase="terminal",
|
||||
terminal=failure_terminal,
|
||||
detail=f"metadata_restore_drill_failed_{exc}",
|
||||
)
|
||||
if args.receipt_file:
|
||||
write_receipt(Path(args.receipt_file), failure)
|
||||
emit_result(failure)
|
||||
except (ContractError, OSError) as receipt_exc:
|
||||
print(f"RECEIPT_ERROR={receipt_exc}", file=sys.stderr)
|
||||
print(f"ERROR={exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
551
scripts/backup/signoz_metadata_contract.py
Executable file
551
scripts/backup/signoz_metadata_contract.py
Executable file
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared fail-closed primitives for the SigNoz metadata backup lane.
|
||||
|
||||
The contract intentionally works only through authenticated HTTP APIs. It
|
||||
never opens the SigNoz SQLite database or a Docker volume and never accepts a
|
||||
secret value on the command line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_POLICY = REPO_ROOT / "config" / "signoz" / "metadata-export-policy.json"
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
SAFE_ASSET_ID = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
||||
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
ALLOWED_TOKEN_MODES = {0o400, 0o600}
|
||||
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
||||
|
||||
|
||||
class ContractError(ValueError):
|
||||
"""A bounded policy, transport, or artifact contract failure."""
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(
|
||||
self,
|
||||
req: urllib.request.Request,
|
||||
fp: Any,
|
||||
code: int,
|
||||
msg: str,
|
||||
headers: Any,
|
||||
newurl: str,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return (
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def read_json_object(path: Path, *, label: str) -> dict[str, Any]:
|
||||
require_no_symlink_components(path, label=label)
|
||||
try:
|
||||
metadata = path.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 1024 * 1024:
|
||||
raise ContractError(f"{label}_size_or_type_invalid")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ContractError(f"{label}_read_failed") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ContractError(f"{label}_not_object")
|
||||
return value
|
||||
|
||||
|
||||
def _normalized_key(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", value.casefold())
|
||||
|
||||
|
||||
def require_no_symlink_components(path: Path, *, label: str) -> None:
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
current = path
|
||||
while True:
|
||||
try:
|
||||
metadata = current.lstat()
|
||||
except OSError as exc:
|
||||
raise ContractError(f"{label}_path_component_unavailable") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
raise ContractError(f"{label}_symlink_component_forbidden")
|
||||
if current == current.parent:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
|
||||
def load_policy(path: Path) -> dict[str, Any]:
|
||||
policy = read_json_object(path, label="policy")
|
||||
if policy.get("schema") != "awoooi_signoz_metadata_export_policy_v1":
|
||||
raise ContractError("unsupported_policy_schema")
|
||||
|
||||
runtime = policy.get("source_runtime")
|
||||
if not isinstance(runtime, dict):
|
||||
raise ContractError("policy_source_runtime_missing")
|
||||
if runtime.get("raw_database_access_allowed") is not False:
|
||||
raise ContractError("policy_raw_database_must_be_forbidden")
|
||||
if runtime.get("raw_volume_access_allowed") is not False:
|
||||
raise ContractError("policy_raw_volume_must_be_forbidden")
|
||||
if runtime.get("github_supply_chain_allowed") is not False:
|
||||
raise ContractError("policy_github_supply_chain_must_be_forbidden")
|
||||
if runtime.get("minimum_python_version") != "3.10":
|
||||
raise ContractError("policy_minimum_python_version_invalid")
|
||||
|
||||
limits = policy.get("limits")
|
||||
if not isinstance(limits, dict):
|
||||
raise ContractError("policy_limits_missing")
|
||||
for key in (
|
||||
"request_timeout_seconds",
|
||||
"max_response_bytes",
|
||||
"max_asset_count",
|
||||
"max_items_per_asset",
|
||||
"max_restore_actions",
|
||||
"stable_read_count",
|
||||
):
|
||||
value = limits.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
||||
raise ContractError(f"policy_limit_invalid_{key}")
|
||||
if limits["stable_read_count"] != 2:
|
||||
raise ContractError("policy_requires_exactly_two_stable_reads")
|
||||
|
||||
authentication = policy.get("authentication")
|
||||
if not isinstance(authentication, dict):
|
||||
raise ContractError("policy_authentication_missing")
|
||||
if authentication.get("header_name") != "SIGNOZ-API-KEY":
|
||||
raise ContractError("policy_auth_header_not_fixed")
|
||||
if authentication.get("secret_value_in_arguments_allowed") is not False:
|
||||
raise ContractError("policy_secret_argument_must_be_forbidden")
|
||||
if authentication.get("accepted_file_modes") != ["0400", "0600"]:
|
||||
raise ContractError("policy_credential_modes_invalid")
|
||||
|
||||
assets = policy.get("assets")
|
||||
if not isinstance(assets, list) or not assets:
|
||||
raise ContractError("policy_assets_missing")
|
||||
if len(assets) > limits["max_asset_count"]:
|
||||
raise ContractError("policy_asset_count_exceeds_limit")
|
||||
|
||||
forbidden_paths = {
|
||||
item.get("path")
|
||||
for item in policy.get("forbidden_endpoints", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
asset_ids: set[str] = set()
|
||||
asset_paths: set[str] = set()
|
||||
for asset in assets:
|
||||
if not isinstance(asset, dict):
|
||||
raise ContractError("policy_asset_not_object")
|
||||
asset_id = asset.get("id")
|
||||
endpoint = asset.get("endpoint")
|
||||
if not isinstance(asset_id, str) or not SAFE_ASSET_ID.fullmatch(asset_id):
|
||||
raise ContractError("policy_asset_id_unsafe")
|
||||
if asset_id in asset_ids:
|
||||
raise ContractError(f"policy_asset_duplicate_{asset_id}")
|
||||
asset_ids.add(asset_id)
|
||||
if not isinstance(endpoint, str) or not endpoint.startswith("/api/v1/"):
|
||||
raise ContractError(f"policy_asset_endpoint_unsafe_{asset_id}")
|
||||
if "?" in endpoint or "#" in endpoint or ".." in endpoint:
|
||||
raise ContractError(f"policy_asset_endpoint_unsafe_{asset_id}")
|
||||
if endpoint in forbidden_paths:
|
||||
raise ContractError(f"policy_forbidden_endpoint_allowlisted_{asset_id}")
|
||||
if endpoint in asset_paths:
|
||||
raise ContractError(f"policy_asset_endpoint_duplicate_{asset_id}")
|
||||
asset_paths.add(endpoint)
|
||||
if asset.get("method") != "GET":
|
||||
raise ContractError(f"policy_asset_export_must_be_get_{asset_id}")
|
||||
if asset.get("response_kind") not in {"collection", "document"}:
|
||||
raise ContractError(f"policy_asset_response_kind_invalid_{asset_id}")
|
||||
if asset.get("classification") not in {"portable", "export_only"}:
|
||||
raise ContractError(f"policy_asset_classification_invalid_{asset_id}")
|
||||
if asset.get("classification") == "portable":
|
||||
restore = asset.get("restore")
|
||||
if not isinstance(restore, dict):
|
||||
raise ContractError(f"policy_restore_missing_{asset_id}")
|
||||
if restore.get("method") not in {"POST", "PUT", "PATCH"}:
|
||||
raise ContractError(f"policy_restore_method_invalid_{asset_id}")
|
||||
if restore.get("strategy") != "collection_items":
|
||||
raise ContractError(f"policy_restore_strategy_invalid_{asset_id}")
|
||||
if restore.get("endpoint") != endpoint:
|
||||
raise ContractError(f"policy_restore_endpoint_mismatch_{asset_id}")
|
||||
if asset.get("response_kind") != "collection":
|
||||
raise ContractError(f"policy_portable_asset_not_collection_{asset_id}")
|
||||
|
||||
completion = policy.get("completion_contract")
|
||||
if not isinstance(completion, dict):
|
||||
raise ContractError("policy_completion_contract_missing")
|
||||
if completion.get("portable_export_alone_is_completion") is not False:
|
||||
raise ContractError("policy_export_must_not_claim_completion")
|
||||
if completion.get("full_sqlite_completion_claim_allowed") is not False:
|
||||
raise ContractError("policy_full_sqlite_claim_must_be_forbidden")
|
||||
if completion.get("durable_terminal_receipt_required_for_apply") is not True:
|
||||
raise ContractError("policy_durable_apply_receipt_must_be_required")
|
||||
if completion.get("failure_terminal_receipt_required") is not True:
|
||||
raise ContractError("policy_failure_receipt_must_be_required")
|
||||
restore_isolation = policy.get("restore_isolation")
|
||||
if not isinstance(restore_isolation, dict):
|
||||
raise ContractError("policy_restore_isolation_missing")
|
||||
if restore_isolation.get("image_id_sha256_required") is not True:
|
||||
raise ContractError("policy_restore_image_id_must_be_required")
|
||||
|
||||
forbidden_names = policy.get("forbidden_key_names")
|
||||
if not isinstance(forbidden_names, list) or not forbidden_names:
|
||||
raise ContractError("policy_forbidden_keys_missing")
|
||||
normalized = [_normalized_key(str(item)) for item in forbidden_names]
|
||||
if not all(normalized) or len(set(normalized)) != len(normalized):
|
||||
raise ContractError("policy_forbidden_keys_invalid")
|
||||
for pattern in policy.get("forbidden_value_patterns", []):
|
||||
try:
|
||||
re.compile(str(pattern))
|
||||
except re.error as exc:
|
||||
raise ContractError("policy_forbidden_value_pattern_invalid") from exc
|
||||
return policy
|
||||
|
||||
|
||||
def validate_identity(value: str, *, label: str) -> str:
|
||||
if not SAFE_ID.fullmatch(value):
|
||||
raise ContractError(f"{label}_unsafe")
|
||||
return value
|
||||
|
||||
|
||||
def validate_base_url(value: str, *, loopback_only: bool = False) -> str:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ContractError("base_url_contains_forbidden_components")
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise ContractError("base_url_path_must_be_empty")
|
||||
host = (parsed.hostname or "").casefold()
|
||||
is_loopback = host in LOOPBACK_HOSTS
|
||||
if loopback_only and not is_loopback:
|
||||
raise ContractError("restore_target_must_be_loopback")
|
||||
if parsed.scheme == "http" and not is_loopback:
|
||||
raise ContractError("plain_http_allowed_only_for_loopback")
|
||||
if parsed.scheme not in {"http", "https"} or not host:
|
||||
raise ContractError("base_url_scheme_or_host_invalid")
|
||||
try:
|
||||
parsed.port
|
||||
except ValueError as exc:
|
||||
raise ContractError("base_url_port_invalid") from exc
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def validate_token_reference(path: Path, *, read_value: bool) -> str | None:
|
||||
if not path.is_absolute():
|
||||
raise ContractError("credential_reference_must_be_absolute")
|
||||
require_no_symlink_components(path, label="credential_reference")
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ContractError("credential_reference_unavailable") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise ContractError("credential_reference_not_regular_file")
|
||||
if metadata.st_uid != os.geteuid():
|
||||
raise ContractError("credential_reference_owner_mismatch")
|
||||
if stat.S_IMODE(metadata.st_mode) not in ALLOWED_TOKEN_MODES:
|
||||
raise ContractError("credential_reference_mode_must_be_0400_or_0600")
|
||||
if metadata.st_size <= 0 or metadata.st_size > 4096:
|
||||
raise ContractError("credential_reference_size_invalid")
|
||||
if not read_value:
|
||||
return None
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ContractError("credential_reference_read_failed") from exc
|
||||
token = raw.rstrip(b"\r\n")
|
||||
if not token or len(token) > 4096 or any(byte < 33 or byte > 126 for byte in token):
|
||||
raise ContractError("credential_reference_value_shape_invalid")
|
||||
return token.decode("ascii")
|
||||
|
||||
|
||||
def scan_forbidden(value: Any, policy: dict[str, Any]) -> None:
|
||||
forbidden_keys = {
|
||||
_normalized_key(str(item)) for item in policy["forbidden_key_names"]
|
||||
}
|
||||
value_patterns = [
|
||||
re.compile(str(item)) for item in policy.get("forbidden_value_patterns", [])
|
||||
]
|
||||
|
||||
def walk(item: Any, pointer: str) -> None:
|
||||
if isinstance(item, dict):
|
||||
for key, child in item.items():
|
||||
if not isinstance(key, str):
|
||||
raise ContractError("json_object_key_not_string")
|
||||
if _normalized_key(key) in forbidden_keys:
|
||||
raise ContractError(
|
||||
f"forbidden_key_detected_at_{pointer or 'root'}"
|
||||
)
|
||||
walk(child, f"{pointer}/{key}")
|
||||
elif isinstance(item, list):
|
||||
for index, child in enumerate(item):
|
||||
walk(child, f"{pointer}/{index}")
|
||||
elif isinstance(item, str):
|
||||
if any(pattern.search(item) for pattern in value_patterns):
|
||||
raise ContractError(
|
||||
f"forbidden_value_pattern_detected_at_{pointer or 'root'}"
|
||||
)
|
||||
|
||||
walk(value, "")
|
||||
|
||||
|
||||
def resolve_pointer(value: Any, pointer: str) -> Any:
|
||||
if pointer == "":
|
||||
return value
|
||||
if not pointer.startswith("/"):
|
||||
raise ContractError("response_pointer_invalid")
|
||||
current = value
|
||||
for raw_part in pointer[1:].split("/"):
|
||||
part = raw_part.replace("~1", "/").replace("~0", "~")
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
raise ContractError("response_pointer_not_found")
|
||||
return current
|
||||
|
||||
|
||||
def validate_asset_document(
|
||||
value: Any,
|
||||
asset: dict[str, Any],
|
||||
policy: dict[str, Any],
|
||||
) -> int:
|
||||
scan_forbidden(value, policy)
|
||||
selected = resolve_pointer(value, str(asset.get("response_pointer", "")))
|
||||
if asset["response_kind"] == "collection":
|
||||
if not isinstance(selected, list):
|
||||
raise ContractError(f"asset_collection_shape_invalid_{asset['id']}")
|
||||
if len(selected) > policy["limits"]["max_items_per_asset"]:
|
||||
raise ContractError(f"asset_item_limit_exceeded_{asset['id']}")
|
||||
if any(not isinstance(item, dict) for item in selected):
|
||||
raise ContractError(f"asset_collection_item_invalid_{asset['id']}")
|
||||
return len(selected)
|
||||
if not isinstance(selected, dict):
|
||||
raise ContractError(f"asset_document_shape_invalid_{asset['id']}")
|
||||
return 1
|
||||
|
||||
|
||||
def semantic_items(value: Any, asset: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
selected = resolve_pointer(value, str(asset.get("response_pointer", "")))
|
||||
if not isinstance(selected, list):
|
||||
raise ContractError(f"asset_collection_shape_invalid_{asset['id']}")
|
||||
restore = asset.get("restore")
|
||||
if not isinstance(restore, dict):
|
||||
raise ContractError(f"asset_not_portable_{asset['id']}")
|
||||
strip_fields = set(restore.get("strip_top_level_fields", []))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in selected:
|
||||
if not isinstance(item, dict):
|
||||
raise ContractError(f"asset_collection_item_invalid_{asset['id']}")
|
||||
normalized.append(
|
||||
{key: value for key, value in item.items() if key not in strip_fields}
|
||||
)
|
||||
return sorted(normalized, key=lambda item: canonical_json_bytes(item))
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
header_name: str,
|
||||
token: str,
|
||||
timeout_seconds: int,
|
||||
max_response_bytes: int,
|
||||
) -> None:
|
||||
self.base_url = validate_base_url(base_url)
|
||||
self.header_name = header_name
|
||||
self.token = token
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.max_response_bytes = max_response_bytes
|
||||
self.opener = urllib.request.build_opener(_NoRedirect())
|
||||
|
||||
def _url(self, endpoint: str) -> str:
|
||||
if not endpoint.startswith("/api/v1/") or any(
|
||||
item in endpoint for item in ("..", "?", "#")
|
||||
):
|
||||
raise ContractError("api_endpoint_unsafe")
|
||||
return f"{self.base_url}{endpoint}"
|
||||
|
||||
def request_json(self, endpoint: str) -> Any:
|
||||
request = urllib.request.Request(
|
||||
self._url(endpoint),
|
||||
method="GET",
|
||||
headers={
|
||||
self.header_name: self.token,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "AWOOOI-P0-OBS-002-metadata-export/1",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=self.timeout_seconds) as response:
|
||||
status_code = int(response.status)
|
||||
content_type = response.headers.get_content_type()
|
||||
payload = response.read(self.max_response_bytes + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ContractError(f"api_http_status_{exc.code}") from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise ContractError("api_transport_failed") from exc
|
||||
if status_code != 200:
|
||||
raise ContractError(f"api_http_status_{status_code}")
|
||||
if content_type != "application/json":
|
||||
raise ContractError("api_content_type_not_json")
|
||||
if len(payload) > self.max_response_bytes:
|
||||
raise ContractError("api_response_size_exceeded")
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ContractError("api_response_json_invalid") from exc
|
||||
|
||||
def request_write(self, method: str, endpoint: str, payload: Any) -> None:
|
||||
if method not in {"POST", "PUT", "PATCH"}:
|
||||
raise ContractError("api_write_method_forbidden")
|
||||
body = canonical_json_bytes(payload)
|
||||
if len(body) > self.max_response_bytes:
|
||||
raise ContractError("api_request_size_exceeded")
|
||||
request = urllib.request.Request(
|
||||
self._url(endpoint),
|
||||
method=method,
|
||||
data=body,
|
||||
headers={
|
||||
self.header_name: self.token,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "AWOOOI-P0-OBS-002-metadata-restore/1",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=self.timeout_seconds) as response:
|
||||
status_code = int(response.status)
|
||||
response_payload = response.read(self.max_response_bytes + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ContractError(f"api_write_http_status_{exc.code}") from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise ContractError("api_write_transport_failed") from exc
|
||||
if status_code not in {200, 201, 202, 204}:
|
||||
raise ContractError(f"api_write_http_status_{status_code}")
|
||||
if len(response_payload) > self.max_response_bytes:
|
||||
raise ContractError("api_write_response_size_exceeded")
|
||||
|
||||
|
||||
def receipt(
|
||||
*,
|
||||
trace_id: str,
|
||||
run_id: str,
|
||||
work_item_id: str,
|
||||
phase: str,
|
||||
terminal: str,
|
||||
detail: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "awoooi_signoz_metadata_receipt_v1",
|
||||
"trace_id": trace_id,
|
||||
"run_id": run_id,
|
||||
"work_item_id": work_item_id,
|
||||
"observed_at": utc_now(),
|
||||
"phase": phase,
|
||||
"terminal": terminal,
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
def write_private(path: Path, payload: bytes) -> None:
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "wb", closefd=True) as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def validate_new_private_destination(path: Path, *, label: str) -> None:
|
||||
if not path.is_absolute():
|
||||
raise ContractError(f"{label}_path_must_be_absolute")
|
||||
require_no_symlink_components(path.parent, label=f"{label}_parent")
|
||||
try:
|
||||
parent_metadata = path.parent.lstat()
|
||||
except OSError as exc:
|
||||
raise ContractError(f"{label}_parent_unavailable") from exc
|
||||
if not stat.S_ISDIR(parent_metadata.st_mode):
|
||||
raise ContractError(f"{label}_parent_not_directory")
|
||||
if parent_metadata.st_uid != os.geteuid():
|
||||
raise ContractError(f"{label}_parent_owner_mismatch")
|
||||
if path.exists() or path.is_symlink():
|
||||
raise ContractError(f"{label}_path_exists")
|
||||
|
||||
|
||||
def write_new_private_atomic(path: Path, payload: bytes, *, label: str) -> None:
|
||||
validate_new_private_destination(path, label=label)
|
||||
temporary = path.with_name(f".{path.name}.partial.{os.getpid()}")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb", closefd=True) as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
os.replace(temporary, path)
|
||||
try:
|
||||
parent_descriptor = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(parent_descriptor)
|
||||
finally:
|
||||
os.close(parent_descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
602
scripts/backup/tests/test_signoz_metadata_export_contract.py
Normal file
602
scripts/backup/tests/test_signoz_metadata_export_contract.py
Normal file
@@ -0,0 +1,602 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
EXPORTER = ROOT / "scripts" / "backup" / "signoz-metadata-export.py"
|
||||
VERIFIER = ROOT / "scripts" / "backup" / "verify-signoz-metadata-export.py"
|
||||
RESTORE = ROOT / "scripts" / "backup" / "signoz-metadata-restore-drill.py"
|
||||
POLICY = ROOT / "config" / "signoz" / "metadata-export-policy.json"
|
||||
TRACE_ID = "trace-P0-OBS-002-metadata"
|
||||
RUN_ID = "run-P0-OBS-002-metadata"
|
||||
WORK_ITEM_ID = "P0-OBS-002"
|
||||
TOKEN = "test-only-signoz-token-reference"
|
||||
|
||||
|
||||
SOURCE_ASSETS: dict[str, Any] = {
|
||||
"/api/v1/dashboards": {"data": [{"id": "dash-1", "title": "Ops"}]},
|
||||
"/api/v1/rules": {"data": [{"id": "rule-1", "name": "Latency"}]},
|
||||
"/api/v1/explorer/views": {"data": [{"id": "view-1", "name": "Errors"}]},
|
||||
"/api/v1/roles": {"data": [{"id": "role-1", "name": "Viewer"}]},
|
||||
"/api/v1/org/preferences": {"data": [{"name": "theme", "value": "dark"}]},
|
||||
"/api/v1/user/preferences": {"data": [{"name": "timezone", "value": "UTC"}]},
|
||||
"/api/v1/global/config": {"data": {"setupCompleted": True}},
|
||||
}
|
||||
|
||||
|
||||
class MetadataHandler(BaseHTTPRequestHandler):
|
||||
assets: dict[str, Any] = {}
|
||||
drift_path: str | None = None
|
||||
get_counts: dict[str, int] = {}
|
||||
post_count = 0
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
return self.headers.get("SIGNOZ-API-KEY") == TOKEN
|
||||
|
||||
def _send_json(self, status: int, value: Any) -> None:
|
||||
payload = json.dumps(value, separators=(",", ":")).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if not self._authorized():
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path not in self.assets:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
self.get_counts[path] = self.get_counts.get(path, 0) + 1
|
||||
value = json.loads(json.dumps(self.assets[path]))
|
||||
if self.drift_path == path and self.get_counts[path] >= 2:
|
||||
value["data"].append({"id": "drift", "title": "changed"})
|
||||
self._send_json(200, value)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not self._authorized():
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path not in self.assets or not isinstance(
|
||||
self.assets[path].get("data"), list
|
||||
):
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
value = json.loads(self.rfile.read(length))
|
||||
if not isinstance(value, dict):
|
||||
self._send_json(400, {"error": "object required"})
|
||||
return
|
||||
type(self).post_count += 1
|
||||
restored = {"id": f"server-{type(self).post_count}", **value}
|
||||
self.assets[path]["data"].append(restored)
|
||||
self._send_json(201, {"data": restored})
|
||||
|
||||
|
||||
@contextmanager
|
||||
def metadata_server(
|
||||
assets: dict[str, Any],
|
||||
*,
|
||||
drift_path: str | None = None,
|
||||
) -> Iterator[tuple[str, type[MetadataHandler]]]:
|
||||
handler = type("BoundMetadataHandler", (MetadataHandler,), {})
|
||||
handler.assets = json.loads(json.dumps(assets))
|
||||
handler.drift_path = drift_path
|
||||
handler.get_counts = {}
|
||||
handler.post_count = 0
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = server.server_address
|
||||
yield f"http://{host}:{port}", handler
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def credential_file(tmp_path: Path, *, mode: int = 0o600) -> Path:
|
||||
path = tmp_path / "signoz-api-key.ref"
|
||||
path.write_text(f"{TOKEN}\n", encoding="ascii")
|
||||
path.chmod(mode)
|
||||
return path
|
||||
|
||||
|
||||
def export_command(
|
||||
*,
|
||||
base_url: str,
|
||||
output_dir: Path,
|
||||
credential: Path | None,
|
||||
apply: bool,
|
||||
receipt_file: Path | None = None,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(EXPORTER),
|
||||
"--apply" if apply else "--check",
|
||||
"--base-url",
|
||||
base_url,
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
"--trace-id",
|
||||
TRACE_ID,
|
||||
"--run-id",
|
||||
RUN_ID,
|
||||
"--work-item-id",
|
||||
WORK_ITEM_ID,
|
||||
]
|
||||
if credential is not None:
|
||||
command.extend(["--credential-file", str(credential)])
|
||||
if apply:
|
||||
terminal_path = receipt_file or output_dir.with_name(
|
||||
f"{output_dir.name}.terminal.json"
|
||||
)
|
||||
command.extend(["--receipt-file", str(terminal_path)])
|
||||
return command
|
||||
|
||||
|
||||
def create_bundle(tmp_path: Path) -> tuple[Path, Path]:
|
||||
credential = credential_file(tmp_path)
|
||||
bundle = tmp_path / "bundle"
|
||||
with metadata_server(SOURCE_ASSETS) as (base_url, _):
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url=base_url,
|
||||
output_dir=bundle,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return bundle, credential
|
||||
|
||||
|
||||
def isolation_receipt(tmp_path: Path, target_base_url: str) -> Path:
|
||||
policy = json.loads(POLICY.read_text(encoding="utf-8"))
|
||||
value = {
|
||||
"schema": "awoooi_signoz_metadata_restore_isolation_v1",
|
||||
"trace_id": TRACE_ID,
|
||||
"run_id": RUN_ID,
|
||||
"work_item_id": WORK_ITEM_ID,
|
||||
"target": {
|
||||
"canonical_id": f"ephemeral-signoz-metadata-restore-{RUN_ID}",
|
||||
"base_url_sha256": hashlib.sha256(
|
||||
target_base_url.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"image_ref": "signoz/signoz:v0.113.0",
|
||||
"image_id": f"sha256:{'a' * 64}",
|
||||
"image_present_locally": True,
|
||||
"image_pull_performed": False,
|
||||
"production_network_attached": False,
|
||||
"production_volume_attached": False,
|
||||
"ephemeral_volumes_only": True,
|
||||
"cleanup_controller_armed": True,
|
||||
"zero_residue_verifier_armed": True,
|
||||
"network_scope": "loopback_and_internal_only",
|
||||
"resource_label_key": policy["restore_isolation"]["resource_label_key"],
|
||||
"resource_label_value": RUN_ID,
|
||||
},
|
||||
}
|
||||
path = tmp_path / "isolation.json"
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
def restore_command(
|
||||
*,
|
||||
mode: str,
|
||||
bundle: Path,
|
||||
target_base_url: str,
|
||||
credential: Path | None,
|
||||
isolation: Path,
|
||||
receipt_file: Path | None = None,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(RESTORE),
|
||||
mode,
|
||||
"--bundle-dir",
|
||||
str(bundle),
|
||||
"--target-base-url",
|
||||
target_base_url,
|
||||
"--isolation-receipt",
|
||||
str(isolation),
|
||||
"--trace-id",
|
||||
TRACE_ID,
|
||||
"--run-id",
|
||||
RUN_ID,
|
||||
"--work-item-id",
|
||||
WORK_ITEM_ID,
|
||||
]
|
||||
if credential is not None:
|
||||
command.extend(["--credential-file", str(credential)])
|
||||
if mode == "--apply":
|
||||
terminal_path = receipt_file or bundle.parent / "restore-terminal.json"
|
||||
command.extend(["--receipt-file", str(terminal_path)])
|
||||
return command
|
||||
|
||||
|
||||
def test_policy_is_explicitly_non_raw_and_non_completion() -> None:
|
||||
policy = json.loads(POLICY.read_text(encoding="utf-8"))
|
||||
assert policy["source_runtime"]["raw_database_access_allowed"] is False
|
||||
assert policy["source_runtime"]["raw_volume_access_allowed"] is False
|
||||
assert policy["source_runtime"]["github_supply_chain_allowed"] is False
|
||||
assert policy["completion_contract"]["portable_export_alone_is_completion"] is False
|
||||
assert (
|
||||
policy["completion_contract"]["full_sqlite_completion_claim_allowed"] is False
|
||||
)
|
||||
assert {item["path"] for item in policy["forbidden_endpoints"]} >= {
|
||||
"/api/v1/pats",
|
||||
"/api/v1/domains",
|
||||
"/api/v1/channels",
|
||||
}
|
||||
|
||||
|
||||
def test_check_mode_writes_nothing(tmp_path: Path) -> None:
|
||||
output = tmp_path / "must-not-exist"
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url="https://signoz.invalid",
|
||||
output_dir=output,
|
||||
credential=None,
|
||||
apply=False,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout)["terminal"] == "check_pass_no_write"
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_apply_and_independent_verifier_pass(tmp_path: Path) -> None:
|
||||
bundle, _ = create_bundle(tmp_path)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(VERIFIER),
|
||||
"--bundle-dir",
|
||||
str(bundle),
|
||||
"--expected-trace-id",
|
||||
TRACE_ID,
|
||||
"--expected-run-id",
|
||||
RUN_ID,
|
||||
"--expected-work-item-id",
|
||||
WORK_ITEM_ID,
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout)["terminal"] == (
|
||||
"pass_export_verified_restore_pending"
|
||||
)
|
||||
manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["stable_read_count"] == 2
|
||||
assert manifest["raw_sqlite_read"] is False
|
||||
assert manifest["sensitive_value_persisted"] is False
|
||||
assert manifest["completion_claim"] is False
|
||||
assert oct((bundle / "manifest.json").stat().st_mode & 0o777) == "0o600"
|
||||
|
||||
|
||||
def test_secret_key_fails_closed_without_bundle(tmp_path: Path) -> None:
|
||||
assets = json.loads(json.dumps(SOURCE_ASSETS))
|
||||
assets["/api/v1/global/config"]["data"]["clientSecret"] = "must-not-persist"
|
||||
output = tmp_path / "bundle"
|
||||
credential = credential_file(tmp_path)
|
||||
with metadata_server(assets) as (base_url, _):
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url=base_url,
|
||||
output_dir=output,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "forbidden_key_detected" in result.stderr
|
||||
assert "must-not-persist" not in result.stderr
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_source_drift_fails_closed_without_bundle(tmp_path: Path) -> None:
|
||||
output = tmp_path / "bundle"
|
||||
credential = credential_file(tmp_path)
|
||||
with metadata_server(SOURCE_ASSETS, drift_path="/api/v1/dashboards") as (
|
||||
base_url,
|
||||
_,
|
||||
):
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url=base_url,
|
||||
output_dir=output,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "source_metadata_drift_between_stable_reads" in result.stderr
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_tamper_breaks_independent_verifier(tmp_path: Path) -> None:
|
||||
bundle, _ = create_bundle(tmp_path)
|
||||
asset = bundle / "assets" / "dashboards.json"
|
||||
asset.write_text('{"data":[]}\n', encoding="utf-8")
|
||||
asset.chmod(0o600)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(VERIFIER),
|
||||
"--bundle-dir",
|
||||
str(bundle),
|
||||
"--expected-trace-id",
|
||||
TRACE_ID,
|
||||
"--expected-run-id",
|
||||
RUN_ID,
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "manifest_asset_hash_mismatch_dashboards" in result.stderr
|
||||
|
||||
|
||||
def test_unexpected_bundle_file_breaks_independent_verifier(tmp_path: Path) -> None:
|
||||
bundle, _ = create_bundle(tmp_path)
|
||||
unexpected = bundle / "unlisted.json"
|
||||
unexpected.write_text('{"not":"manifested"}\n', encoding="utf-8")
|
||||
unexpected.chmod(0o600)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(VERIFIER),
|
||||
"--bundle-dir",
|
||||
str(bundle),
|
||||
"--expected-trace-id",
|
||||
TRACE_ID,
|
||||
"--expected-run-id",
|
||||
RUN_ID,
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "bundle_tree_contains_unexpected_entries" in result.stderr
|
||||
|
||||
|
||||
def test_restore_check_and_semantic_apply_are_bounded(tmp_path: Path) -> None:
|
||||
bundle, credential = create_bundle(tmp_path)
|
||||
target_assets = {
|
||||
"/api/v1/dashboards": {"data": []},
|
||||
"/api/v1/rules": {"data": []},
|
||||
"/api/v1/explorer/views": {"data": []},
|
||||
}
|
||||
with metadata_server(target_assets) as (target_base_url, handler):
|
||||
isolation = isolation_receipt(tmp_path, target_base_url)
|
||||
check_result = subprocess.run(
|
||||
restore_command(
|
||||
mode="--check",
|
||||
bundle=bundle,
|
||||
target_base_url=target_base_url,
|
||||
credential=credential,
|
||||
isolation=isolation,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert check_result.returncode == 0, check_result.stderr
|
||||
assert json.loads(check_result.stdout)["terminal"] == "check_pass_no_write"
|
||||
assert handler.post_count == 0
|
||||
|
||||
apply_result = subprocess.run(
|
||||
restore_command(
|
||||
mode="--apply",
|
||||
bundle=bundle,
|
||||
target_base_url=target_base_url,
|
||||
credential=credential,
|
||||
isolation=isolation,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert apply_result.returncode == 0, apply_result.stderr
|
||||
assert json.loads(apply_result.stdout)["terminal"] == (
|
||||
"partial_degraded_cleanup_pending"
|
||||
)
|
||||
phase_receipts = json.loads(apply_result.stdout)["phase_receipts"]
|
||||
assert [item["phase"] for item in phase_receipts] == [
|
||||
"sensor_source",
|
||||
"normalized_asset_identity",
|
||||
"source_of_truth_diff",
|
||||
"ai_decision",
|
||||
"risk_policy_decision",
|
||||
"check_mode",
|
||||
"bounded_execution",
|
||||
"independent_post_verifier",
|
||||
"rollback",
|
||||
"learning_writeback",
|
||||
]
|
||||
assert phase_receipts[-1]["terminal"] == "pending"
|
||||
assert handler.post_count == 3
|
||||
|
||||
|
||||
def test_restore_rejects_non_loopback_target_before_writes(tmp_path: Path) -> None:
|
||||
bundle, credential = create_bundle(tmp_path)
|
||||
target = "https://signoz.example.invalid"
|
||||
isolation = isolation_receipt(tmp_path, target)
|
||||
result = subprocess.run(
|
||||
restore_command(
|
||||
mode="--apply",
|
||||
bundle=bundle,
|
||||
target_base_url=target,
|
||||
credential=credential,
|
||||
isolation=isolation,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "restore_target_must_be_loopback" in result.stderr
|
||||
|
||||
|
||||
def test_cleanup_verifier_requires_zero_labeled_residue(tmp_path: Path) -> None:
|
||||
bundle, _ = create_bundle(tmp_path)
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
docker = fake_bin / "docker"
|
||||
docker.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
docker.chmod(0o755)
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
target = f"http://127.0.0.1:{port}"
|
||||
isolation = isolation_receipt(tmp_path, target)
|
||||
env = {**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}"}
|
||||
result = subprocess.run(
|
||||
restore_command(
|
||||
mode="--verify-cleanup",
|
||||
bundle=bundle,
|
||||
target_base_url=target,
|
||||
credential=None,
|
||||
isolation=isolation,
|
||||
),
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout)["terminal"] == "pass_zero_residue_verified"
|
||||
|
||||
|
||||
def test_credential_reference_mode_fails_before_network(tmp_path: Path) -> None:
|
||||
credential = credential_file(tmp_path, mode=0o644)
|
||||
output = tmp_path / "bundle"
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url="https://signoz.invalid",
|
||||
output_dir=output,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "credential_reference_mode_must_be_0400_or_0600" in result.stderr
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_credential_symlink_fails_before_network(tmp_path: Path) -> None:
|
||||
credential = credential_file(tmp_path)
|
||||
link = tmp_path / "credential-link"
|
||||
link.symlink_to(credential)
|
||||
output = tmp_path / "bundle"
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url="https://signoz.invalid",
|
||||
output_dir=output,
|
||||
credential=link,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "credential_reference_symlink_component_forbidden" in result.stderr
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_apply_failure_writes_terminal_receipt_without_secret(tmp_path: Path) -> None:
|
||||
assets = json.loads(json.dumps(SOURCE_ASSETS))
|
||||
secret_value = "must-not-appear-in-terminal-receipt"
|
||||
assets["/api/v1/global/config"]["data"]["clientSecret"] = secret_value
|
||||
output = tmp_path / "bundle"
|
||||
receipt_file = tmp_path / "terminal.json"
|
||||
credential = credential_file(tmp_path)
|
||||
with metadata_server(assets) as (base_url, _):
|
||||
command = export_command(
|
||||
base_url=base_url,
|
||||
output_dir=output,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
receipt_file=receipt_file,
|
||||
)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
terminal = json.loads(receipt_file.read_text(encoding="utf-8"))
|
||||
assert terminal["terminal"] == "failed_export_not_closable"
|
||||
assert secret_value not in receipt_file.read_text(encoding="utf-8")
|
||||
assert not output.exists()
|
||||
308
scripts/backup/verify-signoz-metadata-export.py
Executable file
308
scripts/backup/verify-signoz-metadata-export.py
Executable file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently verify a SigNoz metadata export bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from signoz_metadata_contract import (
|
||||
DEFAULT_POLICY,
|
||||
SHA256,
|
||||
ContractError,
|
||||
canonical_json_bytes,
|
||||
load_policy,
|
||||
receipt,
|
||||
require_no_symlink_components,
|
||||
sha256_file,
|
||||
validate_asset_document,
|
||||
validate_identity,
|
||||
write_new_private_atomic,
|
||||
)
|
||||
|
||||
|
||||
EXPECTED_PHASES = [
|
||||
"sensor_source",
|
||||
"normalized_asset_identity",
|
||||
"source_of_truth_diff",
|
||||
"ai_decision",
|
||||
"risk_policy_decision",
|
||||
"check_mode",
|
||||
"bounded_execution",
|
||||
"rollback",
|
||||
"terminal",
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bundle-dir", required=True)
|
||||
parser.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
parser.add_argument("--expected-trace-id", required=True)
|
||||
parser.add_argument("--expected-run-id", required=True)
|
||||
parser.add_argument("--expected-work-item-id", default="P0-OBS-002")
|
||||
parser.add_argument("--receipt-file")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def require_private_regular(path: Path, *, label: str) -> None:
|
||||
require_no_symlink_components(path, label=label)
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ContractError(f"{label}_unavailable") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise ContractError(f"{label}_not_private_regular_file")
|
||||
if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o600:
|
||||
raise ContractError(f"{label}_owner_or_mode_invalid")
|
||||
|
||||
|
||||
def require_private_directory(path: Path, *, label: str) -> None:
|
||||
require_no_symlink_components(path, label=label)
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ContractError(f"{label}_unavailable") from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ContractError(f"{label}_not_directory")
|
||||
if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
|
||||
raise ContractError(f"{label}_owner_or_mode_invalid")
|
||||
|
||||
|
||||
def load_bundle_json(path: Path, *, label: str) -> tuple[Any, bytes]:
|
||||
require_private_regular(path, label=label)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
value = json.loads(raw)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ContractError(f"{label}_json_invalid") from exc
|
||||
if raw != canonical_json_bytes(value):
|
||||
raise ContractError(f"{label}_not_canonical_json")
|
||||
return value, raw
|
||||
|
||||
|
||||
def validate_manifest_identity(
|
||||
manifest: dict[str, Any], args: argparse.Namespace
|
||||
) -> None:
|
||||
expected = {
|
||||
"trace_id": args.expected_trace_id,
|
||||
"run_id": args.expected_run_id,
|
||||
"work_item_id": args.expected_work_item_id,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if manifest.get(key) != value:
|
||||
raise ContractError(f"manifest_{key}_mismatch")
|
||||
if manifest.get("schema") != "awoooi_signoz_metadata_export_bundle_v1":
|
||||
raise ContractError("manifest_schema_invalid")
|
||||
if manifest.get("stable_read_count") != 2:
|
||||
raise ContractError("manifest_stable_read_count_invalid")
|
||||
if manifest.get("stable_read_terminal") != "pass":
|
||||
raise ContractError("manifest_stable_read_not_pass")
|
||||
if manifest.get("raw_sqlite_read") is not False:
|
||||
raise ContractError("manifest_raw_sqlite_read_not_false")
|
||||
if manifest.get("raw_volume_read") is not False:
|
||||
raise ContractError("manifest_raw_volume_read_not_false")
|
||||
if manifest.get("sensitive_value_persisted") is not False:
|
||||
raise ContractError("manifest_secret_persistence_not_false")
|
||||
if manifest.get("completion_claim") is not False:
|
||||
raise ContractError("manifest_illegal_completion_claim")
|
||||
source_url_hash = manifest.get("source_base_url_sha256")
|
||||
if not isinstance(source_url_hash, str) or not SHA256.fullmatch(source_url_hash):
|
||||
raise ContractError("manifest_source_url_hash_invalid")
|
||||
if manifest.get("terminal") != (
|
||||
"portable_metadata_export_created_restore_unverified"
|
||||
):
|
||||
raise ContractError("manifest_terminal_invalid")
|
||||
|
||||
|
||||
def verify_assets(
|
||||
bundle_dir: Path,
|
||||
manifest: dict[str, Any],
|
||||
policy: dict[str, Any],
|
||||
) -> tuple[int, int]:
|
||||
manifest_assets = manifest.get("assets")
|
||||
if not isinstance(manifest_assets, list):
|
||||
raise ContractError("manifest_assets_invalid")
|
||||
policy_assets = policy["assets"]
|
||||
if [item.get("id") for item in manifest_assets if isinstance(item, dict)] != [
|
||||
item["id"] for item in policy_assets
|
||||
]:
|
||||
raise ContractError("manifest_asset_identity_or_order_mismatch")
|
||||
assets_dir = bundle_dir / "assets"
|
||||
require_private_directory(assets_dir, label="assets_dir")
|
||||
expected_asset_files = {f"{item['id']}.json" for item in policy_assets}
|
||||
if {item.name for item in assets_dir.iterdir()} != expected_asset_files:
|
||||
raise ContractError("bundle_assets_tree_contains_unexpected_entries")
|
||||
|
||||
total_items = 0
|
||||
portable_items = 0
|
||||
for asset, recorded in zip(policy_assets, manifest_assets, strict=True):
|
||||
if not isinstance(recorded, dict):
|
||||
raise ContractError("manifest_asset_entry_invalid")
|
||||
asset_id = asset["id"]
|
||||
expected_relative = f"assets/{asset_id}.json"
|
||||
if recorded.get("file") != expected_relative:
|
||||
raise ContractError(f"manifest_asset_file_mismatch_{asset_id}")
|
||||
if recorded.get("endpoint") != asset["endpoint"]:
|
||||
raise ContractError(f"manifest_asset_endpoint_mismatch_{asset_id}")
|
||||
if recorded.get("classification") != asset["classification"]:
|
||||
raise ContractError(f"manifest_asset_classification_mismatch_{asset_id}")
|
||||
|
||||
asset_path = bundle_dir / "assets" / f"{asset_id}.json"
|
||||
value, raw = load_bundle_json(asset_path, label=f"asset_{asset_id}")
|
||||
if recorded.get("sha256") != sha256_file(asset_path):
|
||||
raise ContractError(f"manifest_asset_hash_mismatch_{asset_id}")
|
||||
if recorded.get("size_bytes") != len(raw):
|
||||
raise ContractError(f"manifest_asset_size_mismatch_{asset_id}")
|
||||
item_count = validate_asset_document(value, asset, policy)
|
||||
if recorded.get("item_count") != item_count:
|
||||
raise ContractError(f"manifest_asset_item_count_mismatch_{asset_id}")
|
||||
total_items += item_count
|
||||
if asset["classification"] == "portable":
|
||||
portable_items += item_count
|
||||
return total_items, portable_items
|
||||
|
||||
|
||||
def verify_export_receipts(
|
||||
path: Path,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
require_private_regular(path, label="export_receipts")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
lines = raw.decode("utf-8").splitlines()
|
||||
rows = [json.loads(line) for line in lines]
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ContractError("export_receipts_invalid") from exc
|
||||
if len(rows) != len(EXPECTED_PHASES):
|
||||
raise ContractError("export_receipt_count_invalid")
|
||||
if raw != b"".join(canonical_json_bytes(row) for row in rows):
|
||||
raise ContractError("export_receipts_not_canonical_jsonl")
|
||||
if [row.get("phase") for row in rows if isinstance(row, dict)] != EXPECTED_PHASES:
|
||||
raise ContractError("export_receipt_phase_order_invalid")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise ContractError("export_receipt_not_object")
|
||||
if row.get("schema") != "awoooi_signoz_metadata_receipt_v1":
|
||||
raise ContractError("export_receipt_schema_invalid")
|
||||
if row.get("trace_id") != args.expected_trace_id:
|
||||
raise ContractError("export_receipt_trace_id_mismatch")
|
||||
if row.get("run_id") != args.expected_run_id:
|
||||
raise ContractError("export_receipt_run_id_mismatch")
|
||||
if row.get("work_item_id") != args.expected_work_item_id:
|
||||
raise ContractError("export_receipt_work_item_id_mismatch")
|
||||
if rows[-1].get("terminal") != "partial_degraded":
|
||||
raise ContractError("export_receipt_terminal_must_remain_partial")
|
||||
|
||||
|
||||
def write_receipt_file(path: Path, value: dict[str, Any]) -> None:
|
||||
write_new_private_atomic(
|
||||
path,
|
||||
canonical_json_bytes(value),
|
||||
label="verifier_receipt",
|
||||
)
|
||||
|
||||
|
||||
def emit_result(value: dict[str, Any]) -> None:
|
||||
print(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
validate_identity(args.expected_trace_id, label="trace_id")
|
||||
validate_identity(args.expected_run_id, label="run_id")
|
||||
validate_identity(args.expected_work_item_id, label="work_item_id")
|
||||
policy_path = Path(args.policy)
|
||||
policy = load_policy(policy_path)
|
||||
bundle_dir = Path(args.bundle_dir)
|
||||
if not bundle_dir.is_absolute():
|
||||
raise ContractError("bundle_dir_must_be_absolute_real_directory")
|
||||
require_private_directory(bundle_dir, label="bundle_dir")
|
||||
if {item.name for item in bundle_dir.iterdir()} != {
|
||||
"assets",
|
||||
"manifest.json",
|
||||
"receipts.jsonl",
|
||||
}:
|
||||
raise ContractError("bundle_tree_contains_unexpected_entries")
|
||||
|
||||
manifest_value, _ = load_bundle_json(
|
||||
bundle_dir / "manifest.json", label="manifest"
|
||||
)
|
||||
if not isinstance(manifest_value, dict):
|
||||
raise ContractError("manifest_not_object")
|
||||
validate_manifest_identity(manifest_value, args)
|
||||
if manifest_value.get("policy_sha256") != sha256_file(policy_path):
|
||||
raise ContractError("manifest_policy_hash_mismatch")
|
||||
if (
|
||||
manifest_value.get("source_version")
|
||||
!= (policy["source_runtime"]["signoz_version"])
|
||||
):
|
||||
raise ContractError("manifest_source_version_mismatch")
|
||||
total_items, portable_items = verify_assets(bundle_dir, manifest_value, policy)
|
||||
coverage = manifest_value.get("coverage")
|
||||
if not isinstance(coverage, dict):
|
||||
raise ContractError("manifest_coverage_invalid")
|
||||
portable_assets = sum(
|
||||
item["classification"] == "portable" for item in policy["assets"]
|
||||
)
|
||||
if coverage != {
|
||||
"portable_asset_count": portable_assets,
|
||||
"export_only_asset_count": len(policy["assets"]) - portable_assets,
|
||||
"forbidden_endpoint_count": len(policy["forbidden_endpoints"]),
|
||||
"full_sqlite_coverage": False,
|
||||
}:
|
||||
raise ContractError("manifest_coverage_mismatch")
|
||||
verify_export_receipts(bundle_dir / "receipts.jsonl", args)
|
||||
|
||||
result = receipt(
|
||||
trace_id=args.expected_trace_id,
|
||||
run_id=args.expected_run_id,
|
||||
work_item_id=args.expected_work_item_id,
|
||||
phase="independent_export_verifier",
|
||||
terminal="pass_export_verified_restore_pending",
|
||||
detail=(
|
||||
f"asset_count_{len(policy['assets'])}_items_{total_items}_"
|
||||
f"portable_items_{portable_items}_secret_scan_pass"
|
||||
),
|
||||
)
|
||||
if args.receipt_file:
|
||||
write_receipt_file(Path(args.receipt_file), result)
|
||||
emit_result(result)
|
||||
except (ContractError, OSError) as exc:
|
||||
try:
|
||||
validate_identity(args.expected_trace_id, label="trace_id")
|
||||
validate_identity(args.expected_run_id, label="run_id")
|
||||
validate_identity(args.expected_work_item_id, label="work_item_id")
|
||||
failure = receipt(
|
||||
trace_id=args.expected_trace_id,
|
||||
run_id=args.expected_run_id,
|
||||
work_item_id=args.expected_work_item_id,
|
||||
phase="independent_export_verifier",
|
||||
terminal="failed",
|
||||
detail=f"bundle_verification_failed_{exc}",
|
||||
)
|
||||
if args.receipt_file:
|
||||
write_receipt_file(Path(args.receipt_file), failure)
|
||||
emit_result(failure)
|
||||
except (ContractError, OSError) as receipt_exc:
|
||||
print(f"RECEIPT_ERROR={receipt_exc}", file=sys.stderr)
|
||||
print(f"ERROR={exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user