feat(sync): 添加已验证的备份与恢复操作

This commit is contained in:
2026-09-09 15:16:52 +08:00
parent e44ead1342
commit a0ba7f55f7
9 changed files with 1141 additions and 27 deletions
@@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py `
报告目录包含 `summary.json``case-manifest.json``junit.xml``cases/<ID>.json` 和脱敏的 `logs/<ID>.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。 报告目录包含 `summary.json``case-manifest.json``junit.xml``cases/<ID>.json` 和脱敏的 `logs/<ID>.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04/S-05/S-06 Sync 服务 driver 已登记;其余 18 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。 当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04/S-05/S-06/S-07 Sync 服务 driver 已登记;其余 17 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
+560
View File
@@ -0,0 +1,560 @@
"""S-07 empty deployment, backup/restore, and migration safety acceptance."""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from sync_production_stack import (
ROOT,
SERVICE,
SyncProductionStack,
call,
sha256,
wait_http,
)
FILE_COUNT = 10_000
NOTE_COUNT = 9_990
NOTE_BYTES = 4 * 1024
ATTACHMENT_COUNT = 10
ATTACHMENT_BYTES = 100 * 1024 * 1024
ONE_GIB = 1024**3
def result(case_id: str, status: str, reason: str, facts: dict) -> dict:
passed = status == "PASSED"
assertions = [
(
"an empty PostgreSQL and MinIO instance initializes automatically",
facts.get("automatic_initialization") is True,
),
(
"repeated initialization preserves every existing database row and object",
facts.get("initialization_runs") == 2
and facts.get("initialization_digest_preserved") is True,
),
(
"the backup contains at least one GiB and exactly 10000 historical files",
facts.get("file_count") == FILE_COUNT
and facts.get("object_count") == FILE_COUNT
and facts.get("object_bytes", 0) >= ONE_GIB,
),
(
"the source instance is deleted and all restored object hashes verify",
facts.get("source_deleted") is True
and facts.get("verified_objects") == FILE_COUNT
and facts.get("restored_digest_matches") is True,
),
(
"restore reaches a ready service within 30 minutes from a backup under 24 hours old",
facts.get("rto_ms", 1_800_001) <= 1_800_000
and facts.get("backup_age_seconds", 86_401) <= 86_400,
),
(
"a rejected migration leaves the complete old-data digest unchanged",
facts.get("migration_failures") == 1
and facts.get("migration_digest_preserved") is True,
),
]
evidence = (
"real PostgreSQL 17, fixed MinIO, production backup/restore commands, "
"and full SHA-256 verification"
)
return {
"schema": 1,
"case_id": case_id,
"status": status,
"reason": reason,
"assertions": [
{
"name": name,
"status": "PASSED" if passed and actual else "FAILED",
"evidence": evidence,
}
for name, actual in assertions
],
"metrics": {
"file_count": facts.get("file_count", 0),
"object_count": facts.get("object_count", 0),
"object_bytes": facts.get("object_bytes", 0),
"verified_objects": facts.get("verified_objects", 0),
"rto_ms": facts.get("rto_ms", 0),
"backup_age_seconds": facts.get("backup_age_seconds", 0),
"initialization_runs": facts.get("initialization_runs", 0),
"migration_failures": facts.get("migration_failures", 0),
},
"files": [
{"path": relative, "sha256": sha256(ROOT / relative)}
for relative in (
"server sync/compose.yaml",
"server sync/sync_server/__main__.py",
"server sync/sync_server/database.py",
"server sync/sync_server/operations.py",
"server sync/sync_server/storage.py",
"scripts/acceptance_cases/sync_production_stack.py",
"scripts/acceptance_cases/s07_sync_backup.py",
)
],
"revisions": [
{
"scope": "runtime",
"postgres": facts.get("postgres_version"),
"minio": facts.get("minio_version"),
},
{
"scope": "dataset",
"files": facts.get("file_count", 0),
"objects": facts.get("object_count", 0),
"bytes": facts.get("object_bytes", 0),
},
{
"scope": "recovery",
"rto_ms": facts.get("rto_ms", 0),
"backup_age_seconds": facts.get("backup_age_seconds", 0),
"verified_objects": facts.get("verified_objects", 0),
},
{
"scope": "last checkpoint",
"stage": facts.get("active_stage"),
"iteration": facts.get("active_iteration", -1),
},
],
}
def _note_data(index: int) -> bytes:
line = f"# OpenNexus S-07 note {index:05d}\nseed=20260908\n".encode()
return (line * (NOTE_BYTES // len(line) + 1))[:NOTE_BYTES]
def _stable_id(kind: str, index: int, length: int = 32) -> str:
return hashlib.sha256(f"OpenNexus:S-07:{kind}:{index}".encode()).hexdigest()[:length]
def seed_worker() -> int:
sys.path.insert(0, str(SERVICE))
from sqlalchemy import text
from sync_server.database import Database
from sync_server.storage import S3Objects
db = Database(os.environ["SYNC_DATABASE_URL"])
objects = S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"])
vault_id = os.environ["S07_VAULT_ID"]
device_id = os.environ["S07_DEVICE_ID"]
created = int(time.time())
def put_note(index: int) -> dict:
data = _note_data(index)
digest = hashlib.sha256(data).hexdigest()
objects.put(f"{vault_id}/{digest}", data)
return {
"hash": digest,
"size": len(data),
"path": f"notes/{index:05d}.md",
}
with ThreadPoolExecutor(max_workers=16) as pool:
records = list(pool.map(put_note, range(NOTE_COUNT)))
scratch = Path(os.environ["S07_SCRATCH_FILE"])
try:
for index in range(ATTACHMENT_COUNT):
block = hashlib.sha256(f"OpenNexus:S-07:attachment:{index}".encode()).digest()
block = block * (1024 * 1024 // len(block))
digest = hashlib.sha256()
with scratch.open("wb") as output:
for _ in range(ATTACHMENT_BYTES // len(block)):
output.write(block)
digest.update(block)
content_hash = digest.hexdigest()
objects.put_file(f"{vault_id}/{content_hash}", scratch, content_hash)
records.append(
{
"hash": content_hash,
"size": ATTACHMENT_BYTES,
"path": f"attachments/{index:02d}.bin",
}
)
finally:
scratch.unlink(missing_ok=True)
object_rows = [
{"vault": vault_id, "hash": item["hash"], "size": item["size"], "created": created}
for item in records
]
revision_rows = []
file_rows = []
for sequence, item in enumerate(records, start=1):
file_id = _stable_id("file", sequence)
operation_id = _stable_id("operation", sequence)
revision_rows.append(
{
"vault": vault_id,
"sequence": sequence,
"file": file_id,
"base": 0,
"path": item["path"],
"path_key": item["path"].casefold(),
"hash": item["hash"],
"size": item["size"],
"device": device_id,
"operation": operation_id,
"fingerprint": _stable_id("fingerprint", sequence, 64),
}
)
file_rows.append(
{
"vault": vault_id,
"file": file_id,
"sequence": sequence,
"path_key": item["path"].casefold(),
}
)
total_bytes = sum(item["size"] for item in records)
with db.transaction() as conn:
existing = conn.execute(
text("SELECT COUNT(*) FROM objects WHERE vault_id=:vault"), {"vault": vault_id}
).scalar_one()
if existing:
raise RuntimeError("S07_DATASET_NOT_EMPTY")
for start in range(0, len(records), 1000):
conn.execute(
text(
"INSERT INTO objects(vault_id,hash,size,created) "
"VALUES (:vault,:hash,:size,:created)"
),
object_rows[start : start + 1000],
)
conn.execute(
text(
"INSERT INTO revisions(vault_id,sequence,file_id,base_revision,path,path_key,"
"operation,hash,size,device_id,operation_id,fingerprint) VALUES "
"(:vault,:sequence,:file,:base,:path,:path_key,'put',:hash,:size,:device,"
":operation,:fingerprint)"
),
revision_rows[start : start + 1000],
)
conn.execute(
text(
"INSERT INTO files(vault_id,file_id,sequence,path_key,deleted) "
"VALUES (:vault,:file,:sequence,:path_key,0)"
),
file_rows[start : start + 1000],
)
conn.execute(
text("UPDATE vaults SET sequence=:sequence,used=:used,quota=:quota WHERE id=:vault"),
{
"sequence": FILE_COUNT,
"used": total_bytes,
"quota": total_bytes + ONE_GIB,
"vault": vault_id,
},
)
print(json.dumps({"files": len(records), "objects": len(records), "bytes": total_bytes}))
return 0
def digest_worker(mode: str) -> int:
sys.path.insert(0, str(SERVICE))
from sqlalchemy import text
from sync_server.database import Database
from sync_server.storage import S3Objects
db = Database(os.environ["SYNC_DATABASE_URL"])
objects = S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"])
database_digest = hashlib.sha256()
with db.engine.connect() as conn:
tables = [
row[0]
for row in conn.execute(
text(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema='public' ORDER BY table_name"
)
)
]
for table in tables:
if not table.replace("_", "").isalnum():
raise RuntimeError("S07_TABLE_NAME_INVALID")
rows = [
json.dumps(dict(row), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
for row in conn.execute(text(f'SELECT * FROM "{table}"')).mappings()
]
database_digest.update(table.encode())
for encoded in sorted(rows):
database_digest.update(encoded.encode())
catalog = [
{"vault_id": row.vault_id, "hash": row.hash, "size": int(row.size)}
for row in conn.execute(
text("SELECT vault_id,hash,size FROM objects ORDER BY vault_id,hash")
)
]
def verify(item: dict) -> str:
key = f"{item['vault_id']}/{item['hash']}"
if mode == "full":
response = objects.client.get_object(Bucket=objects.bucket, Key=key)
digest = hashlib.sha256()
size = 0
with response["Body"] as body:
for chunk in iter(lambda: body.read(1024 * 1024), b""):
digest.update(chunk)
size += len(chunk)
if digest.hexdigest() != item["hash"] or size != item["size"]:
raise RuntimeError("S07_OBJECT_DIGEST_MISMATCH")
else:
response = objects.client.head_object(Bucket=objects.bucket, Key=key)
if (
int(response["ContentLength"]) != item["size"]
or response.get("Metadata", {}).get("sha256") != item["hash"]
):
raise RuntimeError("S07_OBJECT_HEAD_MISMATCH")
return f"{key}:{item['size']}"
with ThreadPoolExecutor(max_workers=16) as pool:
verified = list(pool.map(verify, catalog))
catalog_digest = hashlib.sha256("\n".join(verified).encode()).hexdigest()
combined = hashlib.sha256(
(database_digest.hexdigest() + ":" + catalog_digest).encode()
).hexdigest()
print(
json.dumps(
{
"digest": combined,
"database_digest": database_digest.hexdigest(),
"catalog_digest": catalog_digest,
"verified_objects": len(verified),
"object_bytes": sum(item["size"] for item in catalog),
}
)
)
return 0
def worker_entry() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--worker", choices=["seed", "digest-head", "digest-full"], required=True)
args = parser.parse_args()
if args.worker == "seed":
return seed_worker()
return digest_worker("full" if args.worker == "digest-full" else "head")
def run_worker(stack: SyncProductionStack, worker: str, **environment: str) -> dict:
child_env = dict(stack.service_env)
child_env.update(environment)
completed = subprocess.run(
[str(stack.server_python), str(Path(__file__).resolve()), "--worker", worker],
cwd=ROOT,
env=child_env,
capture_output=True,
text=True,
check=True,
timeout=1800,
)
return json.loads(completed.stdout.splitlines()[-1])
def run_operation(stack: SyncProductionStack, command: str, directory: Path) -> dict:
completed = subprocess.run(
[
str(stack.server_python),
"-m",
"sync_server",
command,
"--directory",
str(directory),
"--io-workers",
"16",
],
cwd=SERVICE,
env=stack.service_env,
capture_output=True,
text=True,
check=True,
timeout=2400,
)
return json.loads(completed.stdout.splitlines()[-1])
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
facts: dict = {}
reason = ""
status = "FAILED"
source_stack = None
restored_stack = None
source_username = ""
source_password = ""
try:
config = json.loads(Path(args.config).read_text(encoding="utf-8"))
data_root = Path(os.environ["OPENNEXUS_ACCEPTANCE_DATA_ROOT"]).resolve()
data_root.mkdir(parents=True, exist_ok=True)
backup = data_root / "s07-backup"
facts["active_stage"] = "initialize-empty"
source_stack = SyncProductionStack(config, data_root, "s07-source").start_dependencies()
source_stack.initialize()
source_username = source_stack.username
source_password = source_stack.password
facts.update(
{
"automatic_initialization": source_stack.sql_scalar(
"SELECT version FROM schema_version"
)
== 1,
"postgres_version": source_stack.postgres_version,
"minio_version": source_stack.minio_version,
"initialization_runs": 1,
}
)
source_stack.add_user_with_password(source_stack.username, source_stack.password)
source_stack.start_sync()
wait_http(source_stack.origin + "/ready", timeout=60)
owner = source_stack.login(source_stack.username, source_stack.password, "S-07 owner")
owner_auth = {"Authorization": "Bearer " + owner["access_token"]}
_, _, vault_id = source_stack.create_vault_for_auth(owner_auth, "S-07 dataset")
source_stack.stop_sync()
facts["active_stage"] = "seed-dataset"
seeded = run_worker(
source_stack,
"seed",
S07_VAULT_ID=vault_id,
S07_DEVICE_ID=owner["device_id"],
S07_SCRATCH_FILE=str(source_stack.stack / "s07-seed-object.bin"),
)
facts.update(
{
"file_count": seeded["files"],
"object_count": seeded["objects"],
"object_bytes": seeded["bytes"],
}
)
assert seeded["files"] == FILE_COUNT and seeded["objects"] == FILE_COUNT
assert seeded["bytes"] >= ONE_GIB
facts["active_stage"] = "repeat-initialize"
before_initialize = run_worker(source_stack, "digest-head")
source_stack.initialize()
facts["initialization_runs"] = 2
after_initialize = run_worker(source_stack, "digest-head")
facts["initialization_digest_preserved"] = (
before_initialize["digest"] == after_initialize["digest"]
)
assert facts["initialization_digest_preserved"]
facts["active_stage"] = "backup"
backup_info = run_operation(source_stack, "backup", backup)
manifest = json.loads((backup / "manifest.json").read_text(encoding="utf-8"))
assert backup_info["status"] == "BACKUP_COMPLETE"
assert manifest["object_count"] == FILE_COUNT and manifest["object_bytes"] >= ONE_GIB
facts["active_stage"] = "delete-source"
source_root = source_stack.stack.resolve()
source_stack.stop()
source_stack = None
if source_root.parent != data_root or source_root.name != "s07-source-production-stack":
raise RuntimeError("S07_SOURCE_DELETE_BOUNDARY")
shutil.rmtree(source_root)
facts["source_deleted"] = not source_root.exists()
assert facts["source_deleted"]
facts["active_stage"] = "restore-empty"
rto_started = time.monotonic()
restored_stack = SyncProductionStack(
config, data_root, "s07-restored"
).start_dependencies()
restore_info = run_operation(restored_stack, "restore", backup)
restored_stack.start_sync()
wait_http(restored_stack.origin + "/ready", timeout=60)
facts["rto_ms"] = int((time.monotonic() - rto_started) * 1000)
facts["backup_age_seconds"] = restore_info["backup_age_seconds"]
facts["verified_objects"] = restore_info["verified_objects"]
assert facts["rto_ms"] <= 1_800_000 and facts["backup_age_seconds"] <= 86_400
assert facts["verified_objects"] == FILE_COUNT
restored_digest = run_worker(restored_stack, "digest-head")
facts["restored_digest_matches"] = (
restored_digest["digest"] == before_initialize["digest"]
)
assert facts["restored_digest_matches"]
restored_owner = restored_stack.login(
source_username, source_password, "S-07 restored owner"
)
restored_auth = {"Authorization": "Bearer " + restored_owner["access_token"]}
restored_base = restored_stack.origin + "/sync/v1/vaults/" + vault_id
sample = next(item for item in manifest["objects"] if item["size"] == NOTE_BYTES)
downloaded = call(
"GET", restored_base + "/objects/" + sample["hash"], headers=restored_auth
)
assert downloaded[0] == 200 and hashlib.sha256(downloaded[1]).hexdigest() == sample["hash"]
facts["active_stage"] = "migration-failure"
restored_stack.stop_sync()
changed = restored_stack.sql_scalar(
"WITH changed AS (UPDATE schema_version SET version=999 RETURNING 1) "
"SELECT COUNT(*) FROM changed"
)
assert changed == 1
before_migration = run_worker(restored_stack, "digest-full")
migration = subprocess.run(
[str(restored_stack.server_python), "-m", "sync_server", "migrate"],
cwd=SERVICE,
env=restored_stack.service_env,
capture_output=True,
text=True,
timeout=90,
)
facts["migration_failures"] = int(migration.returncode != 0)
after_migration = run_worker(restored_stack, "digest-head")
facts["migration_digest_preserved"] = (
before_migration["digest"] == after_migration["digest"]
)
assert facts["migration_failures"] == 1 and facts["migration_digest_preserved"]
restored_stack.sql_scalar(
"WITH changed AS (UPDATE schema_version SET version=1 RETURNING 1) "
"SELECT COUNT(*) FROM changed"
)
restored_stack.start_sync()
wait_http(restored_stack.origin + "/ready", timeout=60)
facts.update({"active_stage": "complete", "active_iteration": FILE_COUNT})
status = "PASSED"
except BaseException as error:
reason = "S07_ORACLE_FAILED:" + type(error).__name__
finally:
if source_stack is not None:
source_stack.stop()
if restored_stack is not None:
restored_stack.stop()
payload = result(
case_id,
status if case_id == "S-07" else "FAILED",
reason or ("" if case_id == "S-07" else "CASE_ID_MISMATCH"),
facts,
)
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0 if payload["status"] == "PASSED" else 1
if __name__ == "__main__":
if "--worker" in sys.argv:
raise SystemExit(worker_entry())
raise SystemExit(main())
@@ -147,6 +147,14 @@ class SyncProductionStack:
self.service_env = {} self.service_env = {}
def start(self) -> "SyncProductionStack": def start(self) -> "SyncProductionStack":
self.start_dependencies()
self.initialize()
self.add_user_with_password(self.username, self.password)
self.start_sync()
wait_http(self.origin + "/ready", timeout=60)
return self
def start_dependencies(self) -> "SyncProductionStack":
self.stack.mkdir() self.stack.mkdir()
self.minio_data.mkdir() self.minio_data.mkdir()
self.staging.mkdir() self.staging.mkdir()
@@ -214,22 +222,22 @@ class SyncProductionStack:
"ACCEPTANCE_PASSWORD": self.password, "ACCEPTANCE_PASSWORD": self.password,
} }
) )
return self
def initialize(self) -> None:
subprocess.run( subprocess.run(
[ [str(self.server_python), "-m", "sync_server", "initialize"],
str(self.server_python),
"-c",
"import os,boto3; from sync_server.database import Database; "
"d=Database(os.environ['SYNC_DATABASE_URL']); d.migrate(); "
"d.add_user(os.environ['ACCEPTANCE_USERNAME'],os.environ['ACCEPTANCE_PASSWORD']); "
"boto3.client('s3',endpoint_url=os.environ['SYNC_S3_ENDPOINT']).create_bucket(Bucket=os.environ['SYNC_S3_BUCKET'])",
],
cwd=SERVICE, cwd=SERVICE,
env=self.service_env, env=self.service_env,
stdout=self.sync_log, stdout=self.sync_log,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
check=True, check=True,
timeout=60, timeout=90,
) )
def start_sync(self) -> None:
if self.sync is not None and self.sync.poll() is None:
return
self.sync = subprocess.Popen( self.sync = subprocess.Popen(
[str(self.server_python), "-m", "sync_server", "serve", "--workers", "2"], [str(self.server_python), "-m", "sync_server", "serve", "--workers", "2"],
cwd=SERVICE, cwd=SERVICE,
@@ -237,8 +245,10 @@ class SyncProductionStack:
stdout=self.sync_log, stdout=self.sync_log,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
) )
wait_http(self.origin + "/ready", timeout=60)
return self def stop_sync(self) -> None:
stop_tree(self.sync)
self.sync = None
def login(self, username: str, password: str, label: str) -> dict: def login(self, username: str, password: str, label: str) -> dict:
session = None session = None
@@ -293,6 +303,10 @@ class SyncProductionStack:
def add_user(self, prefix: str) -> tuple[str, str]: def add_user(self, prefix: str) -> tuple[str, str]:
username = prefix + "-" + secrets.token_hex(8) username = prefix + "-" + secrets.token_hex(8)
password = secrets.token_urlsafe(32) password = secrets.token_urlsafe(32)
self.add_user_with_password(username, password)
return username, password
def add_user_with_password(self, username: str, password: str) -> None:
environment = dict(self.service_env) environment = dict(self.service_env)
environment.update( environment.update(
{"ACCEPTANCE_EXTRA_USERNAME": username, "ACCEPTANCE_EXTRA_PASSWORD": password} {"ACCEPTANCE_EXTRA_USERNAME": username, "ACCEPTANCE_EXTRA_PASSWORD": password}
@@ -312,7 +326,6 @@ class SyncProductionStack:
check=True, check=True,
timeout=30, timeout=30,
) )
return username, password
def start_postgres(self) -> None: def start_postgres(self) -> None:
subprocess.run( subprocess.run(
@@ -419,7 +432,7 @@ class SyncProductionStack:
raise RuntimeError("PRODUCTION_STACK_EXITED") raise RuntimeError("PRODUCTION_STACK_EXITED")
def stop(self) -> None: def stop(self) -> None:
stop_tree(self.sync) self.stop_sync()
self.stop_minio() self.stop_minio()
if self.postgres_started: if self.postgres_started:
try: try:
+15
View File
@@ -110,6 +110,21 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
), ),
"required_artifacts": ("postgres_initdb", "minio_server"), "required_artifacts": ("postgres_initdb", "minio_server"),
}, },
"S-07": {
"driver": "scripts/acceptance_cases/s07_sync_backup.py",
"timeout_seconds": 3600,
"required_metrics": (
"file_count",
"object_count",
"object_bytes",
"verified_objects",
"rto_ms",
"backup_age_seconds",
"initialization_runs",
"migration_failures",
),
"required_artifacts": ("postgres_initdb", "minio_server"),
},
} }
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]{2,127}") ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]{2,127}")
RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{2,63}") RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{2,63}")
+18 -4
View File
@@ -15,16 +15,30 @@ uv run pytest
## 自托管准备 ## 自托管准备
1.`.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码。 1.`.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码。
2. 启动 `docker compose up -d database objects`。由管理员在 MinIO 建立 `notesagent` 私有 Bucket,并创建仅能访问该 Bucket 的同步账号;服务不使用 MinIO root 身份。 2. 执行 `docker compose up -d`。一次性 `initialize` 服务等待依赖后幂等创建 schema 与 `opennexus` Bucket;重复运行只检查并补齐缺失资源,不覆盖已有行或对象。长期运行的 `sync` 服务继续使用只限该 Bucket 的同步账号不使用 MinIO root 身份。
3. 执行 `docker compose run --rm sync /service/.venv/bin/python -m sync_server create-user`,密码交互输入,不放命令参数。 3. 执行 `docker compose run --rm sync /service/.venv/bin/python -m sync_server create-user`,密码交互输入,不放命令参数。
4. 启动 `docker compose up -d sync`使用 Caddy 示例配置 TLS。8080 仅绑定本机,不直接公开明文 HTTP。 4. 使用 Caddy 示例配置 TLS。8080 仅绑定本机,不直接公开明文 HTTP。
5. 检查 `/health``/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。 5. 检查 `/health``/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。
Dockerfile/Compose 是待实测部署配置,不能视为已验证安装程序。未提供自动 MinIO 初始化和备份恢复命令,发布前必须完成 `initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket`.env` 中的 root 与同步凭据必须不同
2026-09-08 已在独立 Docker 项目完成真实 PostgreSQL/MinIO 双 worker 测试部署,修正基础镜像中的 `sync` 系统用户名冲突。测试专用 HTTP 地址、故障检查、完整验收缺口与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。仓库通用 Compose 的生产 TLS 与备份恢复仍未通过发布验收。 2026-09-08 已在独立 Docker 项目完成真实 PostgreSQL/MinIO 双 worker 测试部署,修正基础镜像中的 `sync` 系统用户名冲突。测试专用 HTTP 地址、故障检查、完整验收缺口与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。仓库通用 Compose 的生产 TLS 与备份恢复仍未通过发布验收。
升级前同时备份 PostgreSQL 和 Bucket,停止提交以取得一致切点。Schema v1 拒绝未知数据库版本,不自动降级。当前历史永久保留,容量管理不能手动删除被历史引用的对象。 ## 备份与空实例恢复
`backup` 在 PostgreSQL 的同一只读 repeatable-read 事务中导出 schema v1 的全部表和对象清单,再流式下载清单中的不可变对象并核对长度/SHA-256。存在未完成上传或缺失历史对象时失败;目标目录必须不存在。备份目录含认证哈希和会话哈希,必须使用受限 ACL、加密磁盘及异机副本保护。
```powershell
python -m sync_server backup --directory D:/OpenNexus-backups/2026-09-09 --io-workers 8
```
`restore` 默认拒绝超过 24 小时的备份,只允许空 PostgreSQL 数据库和空/不存在 Bucket。命令先校验完整备份,再上传并回读核对全部对象,最后在单个 PostgreSQL 事务中创建 schema、导入并复核对象目录;数据库不会引用只恢复一部分的对象。恢复演练应使用新实例,成功后再启动服务并检查 `/ready`
```powershell
python -m sync_server restore --directory D:/OpenNexus-backups/2026-09-09 --io-workers 8
```
生产定时任务至少每日生成一次新目录并检查命令退出码、`created_utc`、对象数与总字节;保留策略和异机复制由部署维护者配置。升级前执行新备份并完成抽样恢复。Schema v1 拒绝未知数据库版本,不自动降级;当前历史永久保留,容量管理不能手动删除被历史引用的对象。
## 四并发大附件传输探针 ## 四并发大附件传输探针
+22 -6
View File
@@ -2,13 +2,13 @@ services:
database: database:
image: postgres:17.6 image: postgres:17.6
environment: environment:
POSTGRES_USER: notesagent POSTGRES_USER: opennexus
POSTGRES_DB: notesagent POSTGRES_DB: opennexus
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
healthcheck: healthcheck:
test: [CMD-SHELL, "pg_isready -U notesagent -d notesagent"] test: [CMD-SHELL, "pg_isready -U opennexus -d opennexus"]
interval: 5s interval: 5s
retries: 20 retries: 20
objects: objects:
@@ -19,18 +19,34 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?required} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?required}
volumes: volumes:
- objects:/data - objects:/data
initialize:
build: .
command: ["/service/.venv/bin/python", "-m", "sync_server", "initialize"]
environment:
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
SYNC_S3_ENDPOINT: http://objects:9000
SYNC_S3_BUCKET: opennexus
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?required}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required}
AWS_DEFAULT_REGION: us-east-1
depends_on:
database:
condition: service_healthy
objects:
condition: service_started
restart: "no"
sync: sync:
build: . build: .
environment: environment:
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required} SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
SYNC_S3_ENDPOINT: http://objects:9000 SYNC_S3_ENDPOINT: http://objects:9000
SYNC_S3_BUCKET: notesagent SYNC_S3_BUCKET: opennexus
AWS_ACCESS_KEY_ID: ${SYNC_ACCESS_KEY_ID:?required} AWS_ACCESS_KEY_ID: ${SYNC_ACCESS_KEY_ID:?required}
AWS_SECRET_ACCESS_KEY: ${SYNC_SECRET_ACCESS_KEY:?required} AWS_SECRET_ACCESS_KEY: ${SYNC_SECRET_ACCESS_KEY:?required}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
depends_on: depends_on:
database: initialize:
condition: service_healthy condition: service_completed_successfully
volumes: volumes:
- staging:/staging - staging:/staging
ports: ports:
+56 -3
View File
@@ -2,8 +2,10 @@
import argparse import argparse
import getpass import getpass
import json
import os import os
from pathlib import Path from pathlib import Path
import time
from .app import create_app from .app import create_app
from .database import Database from .database import Database
@@ -20,18 +22,66 @@ def application():
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["serve", "migrate", "create-user", "cleanup-uploads"]) parser.add_argument(
"command",
choices=["serve", "initialize", "migrate", "create-user", "cleanup-uploads", "backup", "restore"],
)
parser.add_argument("--workers", type=int, choices=[1, 2], default=2) parser.add_argument("--workers", type=int, choices=[1, 2], default=2)
parser.add_argument("--username") parser.add_argument("--username")
parser.add_argument("--directory", type=Path)
parser.add_argument("--io-workers", type=int, choices=range(1, 33), default=8)
parser.add_argument("--max-age-hours", type=float, default=24)
args = parser.parse_args() args = parser.parse_args()
url = os.environ["SYNC_DATABASE_URL"] url = os.environ["SYNC_DATABASE_URL"]
if not url.startswith("postgresql+psycopg://"): if not url.startswith("postgresql+psycopg://"):
raise SystemExit("生产入口只支持 PostgreSQL") raise SystemExit("生产入口只支持 PostgreSQL")
db = Database(url) db = Database(url)
db.migrate() if args.command == "initialize":
if args.command == "create-user": objects = S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"])
deadline = time.monotonic() + 60
while True:
try:
db.migrate()
created = objects.ensure_bucket()
print(json.dumps({"schema": 1, "bucket_created": created}))
break
except Exception:
if time.monotonic() >= deadline:
raise
time.sleep(1)
elif args.command == "backup":
if args.directory is None:
raise SystemExit("backup 需要 --directory")
from .operations import create_backup
objects = S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"])
print(
json.dumps(
create_backup(db, objects, args.directory, workers=args.io_workers)
)
)
elif args.command == "restore":
if args.directory is None:
raise SystemExit("restore 需要 --directory")
from .operations import restore_backup
objects = S3Objects(os.environ["SYNC_S3_ENDPOINT"], os.environ["SYNC_S3_BUCKET"])
print(
json.dumps(
restore_backup(
db,
objects,
args.directory,
workers=args.io_workers,
max_age_hours=args.max_age_hours,
)
)
)
elif args.command == "create-user":
db.migrate()
db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): ")) db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): "))
elif args.command == "serve": elif args.command == "serve":
db.migrate()
import uvicorn import uvicorn
host = os.environ.get("SYNC_HOST", "0.0.0.0") host = os.environ.get("SYNC_HOST", "0.0.0.0")
if host not in {"0.0.0.0", "127.0.0.1", "::1"}: if host not in {"0.0.0.0", "127.0.0.1", "::1"}:
@@ -45,8 +95,11 @@ def main():
uvicorn.run("sync_server.__main__:application", factory=True, workers=args.workers, uvicorn.run("sync_server.__main__:application", factory=True, workers=args.workers,
host=host, port=port, access_log=False) host=host, port=port, access_log=False)
elif args.command == "cleanup-uploads": elif args.command == "cleanup-uploads":
db.migrate()
from .maintenance import cleanup_expired_uploads from .maintenance import cleanup_expired_uploads
print(cleanup_expired_uploads(db, Path(os.environ["SYNC_STAGING_DIR"]))) print(cleanup_expired_uploads(db, Path(os.environ["SYNC_STAGING_DIR"])))
elif args.command == "migrate":
db.migrate()
if __name__ == "__main__": if __name__ == "__main__":
+412
View File
@@ -0,0 +1,412 @@
"""Consistent PostgreSQL/S3 backup and empty-instance restore operations."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
import hashlib
import json
import math
import os
from pathlib import Path
import re
import shutil
import tempfile
from typing import Any
from sqlalchemy import text
from .database import SCHEMA
MANIFEST_SCHEMA = 1
VAULT_ID = re.compile(r"[0-9a-f]{32}")
CONTENT_HASH = re.compile(r"[0-9a-f]{64}")
TABLES: dict[str, tuple[str, ...]] = {
"schema_version": ("version",),
"users": ("id", "username", "password"),
"devices": ("id", "user_id", "name", "revoked"),
"sessions": ("token", "refresh", "device_id", "expires", "refresh_expires"),
"vaults": ("id", "user_id", "name", "sequence", "quota", "used"),
"uploads": ("id", "vault_id", "device_id", "hash", "size", "offset_bytes", "expires"),
"objects": ("vault_id", "hash", "size", "created"),
"revisions": (
"vault_id",
"sequence",
"file_id",
"base_revision",
"path",
"path_key",
"operation",
"hash",
"size",
"device_id",
"operation_id",
"fingerprint",
),
"files": ("vault_id", "file_id", "sequence", "path_key", "deleted"),
"login_limits": ("key", "started", "attempts"),
"upload_receipts": ("id", "vault_id", "device_id", "hash", "completed"),
}
class OperationsError(RuntimeError):
"""Stable operator-facing failure without credentials or response bodies."""
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _object_path(root: Path, vault_id: str, content_hash: str) -> Path:
if not VAULT_ID.fullmatch(vault_id) or not CONTENT_HASH.fullmatch(content_hash):
raise OperationsError("OBJECT_ID_INVALID")
return root / "objects" / vault_id / content_hash
def _manifest_objects(conn) -> list[dict[str, Any]]:
pending = conn.execute(text("SELECT COUNT(*) FROM uploads")).scalar_one()
if pending:
raise OperationsError("BACKUP_PENDING_UPLOADS")
missing = conn.execute(
text(
"SELECT COUNT(*) FROM revisions r LEFT JOIN objects o "
"ON o.vault_id=r.vault_id AND o.hash=r.hash "
"WHERE r.hash IS NOT NULL AND o.hash IS NULL"
)
).scalar_one()
if missing:
raise OperationsError("HISTORICAL_OBJECT_MISSING")
return [
{"vault_id": row.vault_id, "hash": row.hash, "size": int(row.size)}
for row in conn.execute(
text("SELECT vault_id,hash,size FROM objects ORDER BY vault_id,hash")
)
]
def _write_database_snapshot(conn, destination: Path) -> dict[str, int]:
counts = {}
with destination.open("x", encoding="utf-8", newline="\n") as output:
for table, columns in TABLES.items():
projection = ",".join(f'"{column}"' for column in columns)
ordering = ",".join(f'"{column}"' for column in columns)
rows = conn.execute(
text(f'SELECT {projection} FROM "{table}" ORDER BY {ordering}')
)
count = 0
for row in rows:
output.write(
json.dumps(
{"table": table, "values": list(row)},
ensure_ascii=False,
separators=(",", ":"),
)
+ "\n"
)
count += 1
counts[table] = count
output.flush()
os.fsync(output.fileno())
try:
destination.chmod(0o600)
except OSError:
pass
return counts
def _download(objects, root: Path, item: dict[str, Any]) -> None:
target = _object_path(root, item["vault_id"], item["hash"])
target.parent.mkdir(parents=True, exist_ok=True)
response = objects.client.get_object(
Bucket=objects.bucket,
Key=f"{item['vault_id']}/{item['hash']}",
)
digest = hashlib.sha256()
size = 0
try:
with response["Body"] as body, target.open("xb") as destination:
for chunk in iter(lambda: body.read(1024 * 1024), b""):
destination.write(chunk)
digest.update(chunk)
size += len(chunk)
destination.flush()
os.fsync(destination.fileno())
except BaseException:
target.unlink(missing_ok=True)
raise
if size != item["size"] or digest.hexdigest() != item["hash"]:
target.unlink(missing_ok=True)
raise OperationsError("OBJECT_INTEGRITY_FAILED")
def create_backup(db, objects, destination: Path, *, workers: int = 8) -> dict[str, Any]:
destination = destination.resolve(strict=False)
if destination.exists():
raise OperationsError("BACKUP_DESTINATION_EXISTS")
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = Path(
tempfile.mkdtemp(prefix=destination.name + ".incomplete-", dir=destination.parent)
)
database = temporary / "database.jsonl"
try:
with db.engine.connect() as conn:
transaction = conn.begin()
try:
conn.exec_driver_sql(
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY"
)
version = conn.execute(text("SELECT version FROM schema_version")).scalar_one()
if version != 1:
raise OperationsError("SCHEMA_INCOMPATIBLE")
catalog = _manifest_objects(conn)
table_rows = _write_database_snapshot(conn, database)
transaction.commit()
except BaseException:
if transaction.is_active:
transaction.rollback()
raise
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(lambda item: _download(objects, temporary, item), catalog))
manifest = {
"schema": MANIFEST_SCHEMA,
"created_utc": datetime.now(timezone.utc).isoformat(),
"database_sha256": sha256_file(database),
"database_rows": table_rows,
"object_count": len(catalog),
"object_bytes": sum(item["size"] for item in catalog),
"objects": catalog,
}
manifest_path = temporary / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
os.replace(temporary, destination)
return {
"status": "BACKUP_COMPLETE",
"created_utc": manifest["created_utc"],
"object_count": manifest["object_count"],
"object_bytes": manifest["object_bytes"],
}
except BaseException:
shutil.rmtree(temporary, ignore_errors=True)
raise
def _load_manifest(source: Path, max_age_hours: float) -> dict[str, Any]:
try:
manifest = json.loads((source / "manifest.json").read_text(encoding="utf-8"))
created = datetime.fromisoformat(manifest["created_utc"])
except (OSError, ValueError, KeyError, json.JSONDecodeError) as error:
raise OperationsError("BACKUP_MANIFEST_INVALID") from error
if manifest.get("schema") != MANIFEST_SCHEMA or created.tzinfo is None:
raise OperationsError("BACKUP_MANIFEST_INVALID")
age = (datetime.now(timezone.utc) - created.astimezone(timezone.utc)).total_seconds()
if age < -300 or age > max_age_hours * 3600:
raise OperationsError("BACKUP_AGE_INVALID")
catalog = manifest.get("objects")
if not isinstance(catalog, list) or manifest.get("database_rows") is None:
raise OperationsError("BACKUP_MANIFEST_INVALID")
normalized = []
for item in catalog:
if (
not isinstance(item, dict)
or not isinstance(item.get("vault_id"), str)
or not isinstance(item.get("hash"), str)
or not isinstance(item.get("size"), int)
or isinstance(item.get("size"), bool)
or item["size"] < 0
):
raise OperationsError("BACKUP_MANIFEST_INVALID")
_object_path(source, item["vault_id"], item["hash"])
normalized.append(
{"vault_id": item["vault_id"], "hash": item["hash"], "size": item["size"]}
)
rows = manifest["database_rows"]
if (
normalized != sorted(normalized, key=lambda item: (item["vault_id"], item["hash"]))
or len({(item["vault_id"], item["hash"]) for item in normalized}) != len(normalized)
or manifest.get("object_count") != len(normalized)
or manifest.get("object_bytes") != sum(item["size"] for item in normalized)
or not CONTENT_HASH.fullmatch(str(manifest.get("database_sha256", "")))
or not isinstance(rows, dict)
or set(rows) != set(TABLES)
or any(not isinstance(rows[name], int) or rows[name] < 0 for name in TABLES)
):
raise OperationsError("BACKUP_MANIFEST_INVALID")
manifest["objects"] = normalized
manifest["age_seconds"] = max(0, int(age))
return manifest
def _load_database_snapshot(source: Path, manifest: dict[str, Any]) -> dict[str, list[list]]:
database = source / "database.jsonl"
if not database.is_file() or sha256_file(database) != manifest["database_sha256"]:
raise OperationsError("BACKUP_DATABASE_INTEGRITY_FAILED")
restored = {table: [] for table in TABLES}
try:
with database.open("r", encoding="utf-8") as stream:
for line in stream:
item = json.loads(line)
table = item.get("table")
values = item.get("values")
if table not in TABLES or not isinstance(values, list):
raise OperationsError("BACKUP_DATABASE_INVALID")
if len(values) != len(TABLES[table]):
raise OperationsError("BACKUP_DATABASE_INVALID")
restored[table].append(values)
except (OSError, json.JSONDecodeError) as error:
raise OperationsError("BACKUP_DATABASE_INVALID") from error
if any(len(restored[table]) != manifest["database_rows"][table] for table in TABLES):
raise OperationsError("BACKUP_DATABASE_INVALID")
if restored["schema_version"] != [[1]]:
raise OperationsError("BACKUP_DATABASE_INVALID")
object_columns = TABLES["objects"]
indexes = {name: object_columns.index(name) for name in ("vault_id", "hash", "size")}
catalog = sorted(
(
{
"vault_id": row[indexes["vault_id"]],
"hash": row[indexes["hash"]],
"size": row[indexes["size"]],
}
for row in restored["objects"]
),
key=lambda item: (item["vault_id"], item["hash"]),
)
if catalog != manifest["objects"]:
raise OperationsError("BACKUP_DATABASE_INVALID")
return restored
def _verify_backup_file(source: Path, item: dict[str, Any]) -> None:
path = _object_path(source, item["vault_id"], item["hash"])
try:
size = path.stat().st_size
except OSError as error:
raise OperationsError("BACKUP_OBJECT_MISSING") from error
if size != item["size"] or sha256_file(path) != item["hash"]:
raise OperationsError("BACKUP_OBJECT_INTEGRITY_FAILED")
def _upload_and_verify(objects, source: Path, item: dict[str, Any]) -> str:
key = f"{item['vault_id']}/{item['hash']}"
path = _object_path(source, item["vault_id"], item["hash"])
objects.put_file(key, path, item["hash"])
try:
response = objects.client.get_object(Bucket=objects.bucket, Key=key)
digest = hashlib.sha256()
size = 0
with response["Body"] as body:
for chunk in iter(lambda: body.read(1024 * 1024), b""):
digest.update(chunk)
size += len(chunk)
if size != item["size"] or digest.hexdigest() != item["hash"]:
raise OperationsError("RESTORED_OBJECT_INTEGRITY_FAILED")
except BaseException:
try:
objects.delete(key)
except BaseException:
pass
raise
return key
def _database_is_empty(conn) -> bool:
return (
conn.execute(
text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema='public'"
)
).scalar_one()
== 0
)
def _restore_database(
db, snapshot: dict[str, list[list]], expected_objects: list[dict[str, Any]]
) -> None:
with db.transaction() as conn:
conn.execute(text("SELECT pg_advisory_xact_lock(1330534488)"))
if not _database_is_empty(conn):
raise OperationsError("RESTORE_DATABASE_NOT_EMPTY")
for statement in SCHEMA:
conn.execute(text(statement))
for table, columns in TABLES.items():
rows = snapshot[table]
if not rows:
continue
names = ",".join(f'"{column}"' for column in columns)
values = ",".join(f":v{index}" for index in range(len(columns)))
parameters = [
{f"v{index}": value for index, value in enumerate(row)} for row in rows
]
conn.execute(text(f'INSERT INTO "{table}" ({names}) VALUES ({values})'), parameters)
version = conn.execute(text("SELECT version FROM schema_version")).scalar_one()
if version != 1 or _manifest_objects(conn) != expected_objects:
raise OperationsError("RESTORED_DATABASE_INTEGRITY_FAILED")
def restore_backup(
db,
objects,
source: Path,
*,
workers: int = 8,
max_age_hours: float = 24,
) -> dict[str, Any]:
if not math.isfinite(max_age_hours) or max_age_hours <= 0:
raise OperationsError("BACKUP_AGE_LIMIT_INVALID")
source = source.resolve(strict=True)
manifest = _load_manifest(source, max_age_hours)
snapshot = _load_database_snapshot(source, manifest)
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(lambda item: _verify_backup_file(source, item), manifest["objects"]))
with db.engine.connect() as conn:
if not _database_is_empty(conn):
raise OperationsError("RESTORE_DATABASE_NOT_EMPTY")
created_bucket = objects.ensure_bucket()
if not objects.is_empty():
raise OperationsError("RESTORE_BUCKET_NOT_EMPTY")
uploaded: list[str] = []
database_restored = False
try:
failure = None
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [
pool.submit(_upload_and_verify, objects, source, item)
for item in manifest["objects"]
]
for future in as_completed(futures):
try:
uploaded.append(future.result())
except BaseException as error:
failure = failure or error
if failure is not None:
raise failure
_restore_database(db, snapshot, manifest["objects"])
database_restored = True
except BaseException:
if not database_restored:
try:
objects.delete_many(uploaded)
except BaseException:
pass
if created_bucket:
try:
objects.delete_bucket()
except BaseException:
pass
raise
return {
"status": "RESTORE_COMPLETE",
"backup_age_seconds": manifest["age_seconds"],
"object_count": manifest["object_count"],
"object_bytes": manifest["object_bytes"],
"verified_objects": len(manifest["objects"]),
}
+31
View File
@@ -66,6 +66,37 @@ class S3Objects:
retries={"max_attempts": 0})) retries={"max_attempts": 0}))
self.bucket = bucket self.bucket = bucket
def ensure_bucket(self) -> bool:
"""Create the configured bucket when absent; never alter an existing bucket."""
from botocore.exceptions import ClientError
try:
self.client.head_bucket(Bucket=self.bucket)
return False
except ClientError as error:
code = str(error.response.get("Error", {}).get("Code", ""))
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if code not in {"404", "NoSuchBucket", "NotFound"} and status != 404:
raise
self.client.create_bucket(Bucket=self.bucket)
return True
def is_empty(self) -> bool:
response = self.client.list_objects_v2(Bucket=self.bucket, MaxKeys=1)
return not response.get("Contents")
def delete_many(self, keys: list[str]) -> None:
for start in range(0, len(keys), 1000):
batch = keys[start : start + 1000]
if batch:
self.client.delete_objects(
Bucket=self.bucket,
Delete={"Objects": [{"Key": key} for key in batch], "Quiet": True},
)
def delete_bucket(self) -> None:
self.client.delete_bucket(Bucket=self.bucket)
def put(self, key: str, data: bytes): def put(self, key: str, data: bytes):
self.client.put_object(Bucket=self.bucket, Key=key, Body=data, self.client.put_object(Bucket=self.bucket, Key=key, Body=data,
Metadata={"sha256": hashlib.sha256(data).hexdigest()}) Metadata={"sha256": hashlib.sha256(data).hexdigest()})