replace ProspectOS with BrowserOnly build
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
DB_PATH=${1:-${PROSPECT_API_DB:-${DATA_DIR:-/data}/prospects.db}}
|
||||
BACKUP_DIR=${2:-${BACKUP_DIR:-/var/backups/prospect-platform}}
|
||||
RETENTION=${3:-${BACKUP_RETENTION:-30}}
|
||||
case "$RETENTION" in ''|*[!0-9]*) echo 'retention must be a non-negative integer' >&2; exit 2;; esac
|
||||
case "$BACKUP_DIR" in /*) ;; *) echo 'backup directory must be an absolute path' >&2; exit 2;; esac
|
||||
case "$DB_PATH" in *.env|*secret*|*credentials*) echo 'refusing secret-like database path' >&2; exit 2;; esac
|
||||
mkdir -p -- "$BACKUP_DIR"
|
||||
python3 - "$DB_PATH" "$BACKUP_DIR" "$RETENTION" <<'PY'
|
||||
import hashlib, os, sqlite3, sys, tempfile, time
|
||||
from pathlib import Path
|
||||
src, out_dir, retention = Path(sys.argv[1]).expanduser(), Path(sys.argv[2]).expanduser(), int(sys.argv[3])
|
||||
if not src.is_file() or not src.is_absolute(): raise SystemExit('database must be an existing absolute regular file')
|
||||
out_dir = out_dir.resolve()
|
||||
if src.resolve() == out_dir: raise SystemExit('backup directory must differ from database')
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{os.environ.get('BACKUP_PREFIX', 'prospects')}-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}-{os.getpid()}"
|
||||
final = out_dir / (name + '.db')
|
||||
fd, temp_name = tempfile.mkstemp(prefix='.backup-', suffix='.tmp', dir=out_dir)
|
||||
os.close(fd)
|
||||
try:
|
||||
with sqlite3.connect(f'file:{src}?mode=ro', uri=True) as source, sqlite3.connect(temp_name) as target:
|
||||
source.backup(target)
|
||||
target.execute('PRAGMA integrity_check')
|
||||
target.commit()
|
||||
with open(temp_name, 'rb') as handle:
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_name, 0o600); os.replace(temp_name, final)
|
||||
digest = hashlib.sha256(final.read_bytes()).hexdigest()
|
||||
sidecar = Path(str(final) + '.sha256')
|
||||
sidecar.write_text(f'{digest} {final.name}\n', encoding='ascii'); os.chmod(sidecar, 0o600)
|
||||
candidates = sorted([*out_dir.glob('prospects-*.db'), *out_dir.glob('pre-restore-*.db')], key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for old in candidates[retention:]:
|
||||
old.unlink(missing_ok=True); Path(str(old)+'.sha256').unlink(missing_ok=True)
|
||||
print(final)
|
||||
finally:
|
||||
Path(temp_name).unlink(missing_ok=True)
|
||||
PY
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline, deterministic pilot benchmark for Phase 16.
|
||||
|
||||
The benchmark never performs network I/O. It evaluates the Phase 6/9/8/10 pure
|
||||
functions against a versioned synthetic fixture and writes JSON suitable for CI
|
||||
or trend collection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
API = ROOT / "apps" / "api"
|
||||
FIXTURE_PATH = API / "fixtures" / "phase16.json"
|
||||
DEFAULT_REPORT = ROOT / "docs" / "benchmarks" / "phase16.latest.json"
|
||||
if str(API) not in sys.path:
|
||||
sys.path.insert(0, str(API))
|
||||
|
||||
from app.contact_extractor import extract_contacts
|
||||
from app.domain import match_businesses, normalize_business
|
||||
from app.scoring import DEFAULT_RULES, evaluate_score
|
||||
from app.website_scanner import classify_website
|
||||
|
||||
ACCEPTANCE_THRESHOLDS = {
|
||||
"normalization_accuracy": 1.0,
|
||||
"matching_precision": 0.90,
|
||||
"matching_recall": 0.90,
|
||||
"contact_precision": 0.85,
|
||||
"contact_recall": 0.85,
|
||||
"website_accuracy": 0.90,
|
||||
"score_reproducible": True,
|
||||
"tenant_leakage": 0,
|
||||
}
|
||||
|
||||
|
||||
def _pr(predicted: set, expected: set) -> dict:
|
||||
tp = len(predicted & expected)
|
||||
fp = len(predicted - expected)
|
||||
fn = len(expected - predicted)
|
||||
return {"true_positive": tp, "false_positive": fp, "false_negative": fn,
|
||||
"precision": round(tp / (tp + fp), 4) if tp + fp else (1.0 if not expected else 0.0),
|
||||
"recall": round(tp / (tp + fn), 4) if tp + fn else 1.0}
|
||||
|
||||
|
||||
def _timed(callable_, iterations: int = 1000) -> dict:
|
||||
samples = []
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter_ns()
|
||||
callable_()
|
||||
samples.append((time.perf_counter_ns() - start) / 1_000_000)
|
||||
ordered = sorted(samples)
|
||||
return {"iterations": iterations, "median_ms": round(statistics.median(samples), 4),
|
||||
"p95_ms": round(ordered[max(0, int(iterations * 0.95) - 1)], 4),
|
||||
"max_ms": round(max(samples), 4)}
|
||||
|
||||
|
||||
def run_benchmark(fixtures: dict | None = None, *, measure_latency: bool = True) -> dict:
|
||||
data = fixtures or json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
normalization_correct = 0
|
||||
for case in data.get("normalization", []):
|
||||
actual = normalize_business(case["input"])
|
||||
fields_ok = all(actual.get(field) == expected for field, expected in case["expected"].items())
|
||||
normalization_correct += int(fields_ok)
|
||||
normalization_total = len(data.get("normalization", []))
|
||||
|
||||
matching_predicted, matching_expected = set(), set()
|
||||
matching_false_positive = 0
|
||||
matching_cases = []
|
||||
for case in data["matching"]:
|
||||
expected = set(case["expected_ids"])
|
||||
predicted = {item["id"] for item in match_businesses(case["source"], case["candidates"], threshold=case["threshold"])}
|
||||
matching_predicted |= predicted
|
||||
matching_expected |= expected
|
||||
matching_false_positive += len(predicted - expected)
|
||||
matching_cases.append({"name": case["name"], "predicted_ids": sorted(predicted), "expected_ids": sorted(expected)})
|
||||
matching = _pr(matching_predicted, matching_expected)
|
||||
matching["cases"] = matching_cases
|
||||
matching["false_positive_cases"] = matching_false_positive
|
||||
|
||||
contact_predicted, contact_expected = set(), set()
|
||||
contact_cases = []
|
||||
for case in data["contacts"]:
|
||||
predicted = {(item["kind"], item["value"]) for item in extract_contacts(case["html"], case["source_url"])}
|
||||
expected = {tuple(item) for item in case["expected"]}
|
||||
contact_predicted |= predicted
|
||||
contact_expected |= expected
|
||||
contact_cases.append({"name": case["name"], "predicted_count": len(predicted), "expected_count": len(expected), "false_positives": sorted([list(x) for x in predicted - expected])})
|
||||
contacts = _pr(contact_predicted, contact_expected)
|
||||
contacts["cases"] = contact_cases
|
||||
|
||||
website_correct = sum(classify_website(c["status"], c["url"], c["body"], error=c.get("error")) == c["expected"] for c in data["websites"])
|
||||
website_total = len(data["websites"])
|
||||
website = {"correct": website_correct, "total": website_total, "accuracy": round(website_correct / website_total, 4) if website_total else 1.0}
|
||||
|
||||
score_case = data["scoring"][0]
|
||||
first_score = evaluate_score(score_case["signals"], DEFAULT_RULES)
|
||||
score_reproducible = all(evaluate_score(c["signals"], DEFAULT_RULES) == evaluate_score(c["signals"], DEFAULT_RULES) for c in data["scoring"])
|
||||
scoring = {"reproducible": score_reproducible, "fixture_scores": [{"name": c["name"], "score": evaluate_score(c["signals"], DEFAULT_RULES)["score"], "eligible": evaluate_score(c["signals"], DEFAULT_RULES)["eligible"]} for c in data["scoring"]]}
|
||||
if measure_latency:
|
||||
scoring["latency"] = _timed(lambda: evaluate_score(score_case["signals"], DEFAULT_RULES))
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def cached_score(payload: str) -> dict:
|
||||
return evaluate_score(json.loads(payload), DEFAULT_RULES)
|
||||
cache_payload = json.dumps(score_case["signals"], sort_keys=True, separators=(",", ":"))
|
||||
cached_score.cache_clear()
|
||||
if measure_latency:
|
||||
cold = _timed(lambda: (cached_score.cache_clear(), cached_score(cache_payload)), 100)
|
||||
warm = _timed(lambda: cached_score(cache_payload), 1000)
|
||||
cache = {"enabled": True, "cold": cold, "warm": warm, "speedup": round(cold["median_ms"] / warm["median_ms"], 4) if warm["median_ms"] else None}
|
||||
else:
|
||||
cache = {"enabled": True, "timing_omitted": True}
|
||||
|
||||
tenant_a = set(data["tenant_isolation"]["tenant_a"]["business_ids"])
|
||||
tenant_b = set(data["tenant_isolation"]["tenant_b"]["business_ids"])
|
||||
# This is the same allow-list operation required before a tenant query.
|
||||
visible_to_a = [ident for ident in sorted(tenant_a | tenant_b) if ident in tenant_a]
|
||||
leakage = len(set(visible_to_a) & tenant_b)
|
||||
tenant = {"tenant_a_visible_ids": visible_to_a, "tenant_b_ids": sorted(tenant_b), "leakage": leakage, "isolated": leakage == 0}
|
||||
|
||||
latency = {}
|
||||
if measure_latency:
|
||||
latency = {
|
||||
"normalization": _timed(lambda: normalize_business(data["normalization"][0]["input"])),
|
||||
"matching": _timed(lambda: match_businesses(data["matching"][0]["source"], data["matching"][0]["candidates"], threshold=data["matching"][0]["threshold"])),
|
||||
"contact_extraction": _timed(lambda: extract_contacts(data["contacts"][0]["html"], data["contacts"][0]["source_url"])),
|
||||
"website_classification": _timed(lambda: classify_website(data["websites"][0]["status"], data["websites"][0]["url"], data["websites"][0]["body"])),
|
||||
}
|
||||
|
||||
results = {
|
||||
"normalization": {"correct": normalization_correct, "total": normalization_total, "accuracy": round(normalization_correct / normalization_total, 4) if normalization_total else 1.0},
|
||||
"matching": matching, "contacts": contacts, "website_classification": website,
|
||||
"scoring": scoring, "cache": cache, "tenant_isolation": tenant, "latency": latency,
|
||||
}
|
||||
checks = {
|
||||
"normalization_accuracy": results["normalization"]["accuracy"] >= ACCEPTANCE_THRESHOLDS["normalization_accuracy"],
|
||||
"matching_precision": matching["precision"] >= ACCEPTANCE_THRESHOLDS["matching_precision"],
|
||||
"matching_recall": matching["recall"] >= ACCEPTANCE_THRESHOLDS["matching_recall"],
|
||||
"contact_precision": contacts["precision"] >= ACCEPTANCE_THRESHOLDS["contact_precision"],
|
||||
"contact_recall": contacts["recall"] >= ACCEPTANCE_THRESHOLDS["contact_recall"],
|
||||
"website_accuracy": website["accuracy"] >= ACCEPTANCE_THRESHOLDS["website_accuracy"],
|
||||
"score_reproducible": scoring["reproducible"], "tenant_leakage": leakage == 0,
|
||||
}
|
||||
return {"benchmark": "phase16", "version": 1, "fixture": str(FIXTURE_PATH.relative_to(ROOT)),
|
||||
"offline": True, "limitations": data["limitations"], "acceptance_thresholds": ACCEPTANCE_THRESHOLDS,
|
||||
"results": results, "checks": checks, "passed": all(checks.values())}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_REPORT)
|
||||
parser.add_argument("--no-latency", action="store_true", help="omit variable timing measurements")
|
||||
args = parser.parse_args()
|
||||
report = run_benchmark(measure_latency=not args.no_latency)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
return 0 if report["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,178 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the deterministic, local Phase 17 final-acceptance gate.
|
||||
|
||||
This is a release-evidence collector, not a deployment tool. It performs only
|
||||
local tests and static/configuration checks; it never sends outreach or calls a
|
||||
provider. Capacity figures are bounded synthetic smoke measurements, not
|
||||
production capacity claims.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
API = ROOT / "apps" / "api"
|
||||
REPORT = ROOT / "docs" / "benchmarks" / "final_acceptance.latest.json"
|
||||
SCHEMA = ROOT / "docs" / "benchmarks" / "final_acceptance.schema.json"
|
||||
|
||||
|
||||
def deterministic_json(value: object) -> str:
|
||||
return json.dumps(value, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
|
||||
|
||||
|
||||
def build_capacity_smoke() -> dict:
|
||||
"""Measure fixed, in-memory bounds with no clock or network dependence."""
|
||||
total = 1000
|
||||
requested = 100
|
||||
page = list(range(total))[0:requested]
|
||||
batch_input = 5000
|
||||
retained = min(batch_input, 100)
|
||||
return {
|
||||
"bounded": len(page) <= requested and retained <= 100,
|
||||
"limits": {"max_page_size": 100, "max_batch_items_retained": 100},
|
||||
"pagination": {
|
||||
"synthetic_total": total,
|
||||
"requested_page_size": requested,
|
||||
"returned_items": len(page),
|
||||
"has_more": total > requested,
|
||||
},
|
||||
"large_batch": {
|
||||
"input_items": batch_input,
|
||||
"retained_items": retained,
|
||||
"truncated": batch_input > retained,
|
||||
},
|
||||
"interpretation": "Deterministic in-memory smoke only; not a production capacity or throughput claim.",
|
||||
}
|
||||
|
||||
|
||||
def validate_report(report: dict) -> list[str]:
|
||||
required = {"report", "version", "scope", "passed", "checks", "capacity_smoke", "blockers", "limitations"}
|
||||
errors = [f"missing:{key}" for key in sorted(required - set(report))]
|
||||
if report.get("report") != "phase17-final-acceptance": errors.append("report:const")
|
||||
if type(report.get("version")) is not int or report.get("version") != 1: errors.append("version:type-or-const")
|
||||
if report.get("scope") != "local-repository-acceptance": errors.append("scope:const")
|
||||
if not isinstance(report.get("passed"), bool): errors.append("passed:type")
|
||||
if not isinstance(report.get("checks"), dict): errors.append("checks:type")
|
||||
if not isinstance(report.get("capacity_smoke"), dict): errors.append("capacity_smoke:type")
|
||||
if not isinstance(report.get("limitations"), list) or not all(isinstance(x, str) for x in report.get("limitations", [])):
|
||||
errors.append("limitations:type")
|
||||
blockers = report.get("blockers", [])
|
||||
if not isinstance(blockers, list) or not all(isinstance(x, dict) for x in blockers):
|
||||
errors.append("blockers:type")
|
||||
else:
|
||||
for index, blocker in enumerate(blockers):
|
||||
if not {"gate", "status", "reason"}.issubset(blocker): errors.append(f"blockers[{index}]:required")
|
||||
return errors
|
||||
|
||||
|
||||
def run_command(name: str, args: list[str], *, cwd: Path = ROOT, timeout: int = 300) -> dict:
|
||||
if shutil.which(args[0]) is None:
|
||||
return {"passed": False, "status": "unavailable", "command": args, "summary": f"{args[0]} not installed"}
|
||||
try:
|
||||
completed = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"passed": False, "status": "timeout", "command": args, "summary": f"{name} exceeded {timeout}s"}
|
||||
output = completed.stdout or ""
|
||||
match = re.search(r"Ran (\d+) tests?", output)
|
||||
summary = f"exit={completed.returncode}"
|
||||
if match: summary += f", tests={match.group(1)}"
|
||||
if completed.returncode and output:
|
||||
summary += ": " + " ".join(output.strip().splitlines()[-2:])[:500]
|
||||
return {"passed": completed.returncode == 0, "status": "passed" if completed.returncode == 0 else "failed", "command": args, "summary": summary}
|
||||
|
||||
|
||||
def validate_json_files() -> dict:
|
||||
files = [ROOT / "docs" / "benchmarks" / "phase16.latest.json", ROOT / "docs" / "benchmarks" / "phase16.schema.json", SCHEMA]
|
||||
results = {}
|
||||
parsed = {}
|
||||
for path in files:
|
||||
key = str(path.relative_to(ROOT))
|
||||
try:
|
||||
parsed[key] = json.loads(path.read_text(encoding="utf-8"))
|
||||
results[key] = {"passed": True, "status": "valid_json"}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
results[key] = {"passed": False, "status": "invalid_json", "summary": str(exc)}
|
||||
phase16 = parsed.get("docs/benchmarks/phase16.latest.json")
|
||||
if isinstance(phase16, dict):
|
||||
required = {"benchmark", "version", "offline", "limitations", "acceptance_thresholds", "results", "checks", "passed"}
|
||||
schema_ok = required.issubset(phase16) and phase16.get("benchmark") == "phase16" and phase16.get("version") == 1 and phase16.get("offline") is True and isinstance(phase16.get("passed"), bool)
|
||||
results["phase16_schema_contract"] = {"passed": schema_ok, "status": "schema_valid" if schema_ok else "schema_invalid"}
|
||||
else:
|
||||
results["phase16_schema_contract"] = {"passed": False, "status": "schema_invalid"}
|
||||
return {"passed": all(item["passed"] for item in results.values()), "files": results}
|
||||
|
||||
|
||||
def safety_checks() -> dict:
|
||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
config = (API / "app" / "config.py").read_text(encoding="utf-8")
|
||||
main = (API / "app" / "main.py").read_text(encoding="utf-8")
|
||||
tests = "\n".join(p.read_text(encoding="utf-8") for p in (API / "tests").glob("test_*.py"))
|
||||
checks = {
|
||||
"outreach_disabled": 'AUTOMATED_OUTREACH_ENABLED: "false"' in compose and '"false"' in config and 'outreach_enabled' in main,
|
||||
"no_send_network": '"network_send": False' in main and "urllib.request" not in main and "smtplib" not in main,
|
||||
"tenant_routes": "organization_id=?" in main and "session_user" in main and "require_auth" in main and "tenant" in tests.lower(),
|
||||
}
|
||||
return {"passed": all(checks.values()), "checks": checks}
|
||||
|
||||
|
||||
def git_state() -> dict:
|
||||
result = run_command("git-state", ["git", "status", "--short", "--branch"])
|
||||
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True, capture_output=True, check=False)
|
||||
porcelain = subprocess.run(["git", "status", "--porcelain"], cwd=ROOT, text=True, capture_output=True, check=False)
|
||||
return {
|
||||
"passed": result["passed"] and head.returncode == 0 and bool(head.stdout.strip()),
|
||||
"status": result["status"],
|
||||
"branch_status": result.get("summary", ""),
|
||||
"head": head.stdout.strip(),
|
||||
"working_tree_clean": porcelain.returncode == 0 and not porcelain.stdout.strip(),
|
||||
}
|
||||
|
||||
|
||||
def build_report() -> dict:
|
||||
checks = {
|
||||
"api_tests": run_command("api-tests", [sys.executable, "-m", "unittest", "discover", "-v", "-s", "apps/api/tests", "-t", "apps/api"]),
|
||||
"py_compile": run_command("py_compile", [sys.executable, "-m", "py_compile", *[str(p.relative_to(ROOT)) for p in sorted((ROOT / "scripts").glob("*.py"))], *[str(p.relative_to(ROOT)) for p in sorted((API / "app").glob("*.py"))]]),
|
||||
"benchmark_no_latency": run_command("benchmark-no-latency", [sys.executable, "scripts/benchmark_phase16.py", "--no-latency", "--output", "/tmp/prospect-phase16-acceptance.json"]),
|
||||
"shell_syntax": run_command("shell-syntax", ["bash", "-n", *[str(p.relative_to(ROOT)) for p in sorted((ROOT / "scripts").glob("*.sh"))]]),
|
||||
"json_validation": validate_json_files(),
|
||||
"compose_config": run_command("compose-config", ["docker", "compose", "-f", "docker-compose.yml", "config", "--quiet"]),
|
||||
"git_state": git_state(),
|
||||
"safety_invariants": safety_checks(),
|
||||
}
|
||||
blockers = [
|
||||
{"gate": "remote_auth", "status": "blocked", "reason": "Remote repository authentication and branch permission are not available to this local acceptance run."},
|
||||
{"gate": "deployment_access", "status": "blocked", "reason": "No production host, Docker/Compose, DNS/TLS, secrets, or deployment access is available; no deployment was attempted."},
|
||||
{"gate": "real_provider_legal", "status": "blocked", "reason": "Real source/provider enablement, consent/legal basis, terms, and operational approval remain explicit gates; outreach stays disabled."},
|
||||
]
|
||||
return {
|
||||
"report": "phase17-final-acceptance", "version": 1,
|
||||
"scope": "local-repository-acceptance",
|
||||
"passed": all(item.get("passed", False) for item in checks.values()),
|
||||
"checks": checks,
|
||||
"capacity_smoke": build_capacity_smoke(),
|
||||
"blockers": blockers,
|
||||
"limitations": ["Synthetic capacity smoke measurements do not establish production throughput, concurrency, durability, or availability."],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=REPORT)
|
||||
args = parser.parse_args()
|
||||
report = build_report()
|
||||
errors = validate_report(report)
|
||||
if errors: raise SystemExit("invalid acceptance report: " + ", ".join(errors))
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(deterministic_json(report), encoding="utf-8")
|
||||
print(deterministic_json(report), end="")
|
||||
return 0 if report["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
URL=${HEALTHCHECK_URL:-http://127.0.0.1:8000/api/v1/health/ready}
|
||||
TIMEOUT=${HEALTHCHECK_TIMEOUT:-5}
|
||||
python3 - "$URL" "$TIMEOUT" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
url, timeout = sys.argv[1], float(sys.argv[2])
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||
payload=json.loads(response.read())
|
||||
if response.status != 200 or payload.get('status') != 'ok' or payload.get('ready') is not True:
|
||||
raise RuntimeError('service is not ready')
|
||||
if payload.get('outreach_enabled') is not False: raise RuntimeError('outreach safety check failed')
|
||||
except Exception as exc:
|
||||
print(f'healthcheck failed: {type(exc).__name__}', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print('ok')
|
||||
PY
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
if [ "${3:-}" != "--confirm-restore" ]; then
|
||||
echo 'Refusing restore: pass --confirm-restore explicitly.' >&2
|
||||
exit 2
|
||||
fi
|
||||
SOURCE=${1:-}
|
||||
TARGET=${2:-${PROSPECT_API_DB:-${DATA_DIR:-/data}/prospects.db}}
|
||||
BACKUP_DIR=${BACKUP_DIR:-$(dirname -- "$SOURCE")}
|
||||
case "$SOURCE$TARGET" in *secret*|*credentials*|*.env*) echo 'refusing secret-like path' >&2; exit 2;; esac
|
||||
[ -f "$SOURCE" ] || { echo 'restore source does not exist' >&2; exit 2; }
|
||||
case "$TARGET" in /*) ;; *) echo 'restore target must be an absolute path' >&2; exit 2;; esac
|
||||
mkdir -p -- "$BACKUP_DIR"
|
||||
if [ -f "$TARGET" ]; then
|
||||
pre_restore=$("$(dirname -- "$0")/backup_sqlite.sh" "$TARGET" "$BACKUP_DIR" 30)
|
||||
pre_restore_db=$(printf '%s\n' "$pre_restore" | tail -n 1)
|
||||
pre_restore_name="$BACKUP_DIR/pre-restore-$(date -u +%Y%m%dT%H%M%SZ)-$$.db"
|
||||
mv -- "$pre_restore_db" "$pre_restore_name"
|
||||
if [ -f "$pre_restore_db.sha256" ]; then mv -- "$pre_restore_db.sha256" "$pre_restore_name.sha256"; fi
|
||||
fi
|
||||
python3 - "$SOURCE" "$TARGET" <<'PY'
|
||||
import hashlib, os, sqlite3, sys, tempfile
|
||||
from pathlib import Path
|
||||
source, target = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve()
|
||||
if not source.is_file(): raise SystemExit('restore source must be a regular file')
|
||||
sidecar = Path(str(source)+'.sha256')
|
||||
if sidecar.exists():
|
||||
expected = sidecar.read_text(encoding='ascii').split()[0]
|
||||
actual = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
if expected != actual: raise SystemExit('restore checksum mismatch')
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix='.restore-', suffix='.tmp', dir=target.parent); os.close(fd)
|
||||
try:
|
||||
with sqlite3.connect(f'file:{source}?mode=ro', uri=True) as src, sqlite3.connect(tmp) as dst:
|
||||
src.backup(dst); result = dst.execute('PRAGMA integrity_check').fetchone()[0]
|
||||
if result != 'ok': raise SystemExit('restored database integrity check failed')
|
||||
dst.commit()
|
||||
os.chmod(tmp, 0o600); os.replace(tmp, target)
|
||||
print(target)
|
||||
finally:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
PY
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cat >&2 <<'EOF'
|
||||
Rollback is a documented, non-destructive procedure. No rollback command is executed.
|
||||
|
||||
1. Identify the last known-good immutable image digest/tag and configuration revision.
|
||||
2. Confirm the database backup is recent and run scripts/healthcheck.sh against the candidate.
|
||||
3. Review the exact rendered config: docker compose config.
|
||||
4. Change the deployment's image tag/digest in the deployment system (or pin IMAGE_TAG), then restart the service through the approved change process.
|
||||
5. Verify /api/v1/health/ready, representative authenticated reads, logs, and outreach_enabled=false.
|
||||
6. Record the rollback reason, old/new image digests, config revision, backup/checksum, and operator.
|
||||
|
||||
This helper intentionally does not stop containers, delete images, restore databases, or alter production state.
|
||||
EOF
|
||||
Reference in New Issue
Block a user