镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit 35b7a7d

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/launchpad-service-deploy
2 parents 7eab2ab + 18fa5c1 commit 35b7a7d

4 files changed

Lines changed: 105 additions & 14 deletions

File tree

‎ops/production/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ The inventory places future database dump staging on the mounted deployments vol
1616

1717
Checks: `python3 test-preserve-backups.py` on Linux as root, shell syntax checks, ts-cloud driver tests and its isolated Linux mount tests. Verify database readiness, app HTTPS, workers, persistent mount identities, fstab, backups, mail protocols and failed units after applying.
1818

19+
The hourly backup retry also selects newly discovered projects, missing provider
20+
receipts, unverified restores and backups older than the health check's 36-hour
21+
window. It skips only fresh, recovery-verified groups on every provider. Verify
22+
this selection with `python3 test-backup-retry.py`; the root integration exercises
23+
the real retry coordinator and confirms preservation mode never prunes snapshots.
24+
1925
ReviewOS uses a separate private, versioned `reviewos-production` bucket in `hel1`, declared in its `cloud/object-storage.ts`. The shared host's existing operator credential can be installed into the app's private credential file using `reviewos-credentials.py --source <protected-S3-env-file>`. It never emits credentials or overwrites a different key pair. Prefer a dedicated project credential when rotating keys; the current trusted application processes already run as root on this host. Keep the backup bucket and its snapshots untouched.
2026

2127
`install-preserving.py --apply` checks all operator baselines first, saves prior versions, installs the companion systemd declarations and reloads systemd. The declarations preserve app/mail backup history, put staging behind the required volume and make automated capacity maintenance read-only. The PostgreSQL declaration requires the logging volume before startup.

‎ops/production/host-backup.py‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,20 @@ def save(path, data):
3535
temporary.replace(path)
3636

3737

38+
def project_backup_due(status, repositories, now):
39+
"""Failed, newly discovered and stale groups all need a verified backup."""
40+
if status.get("error"):
41+
return True
42+
for name in repositories:
43+
recovered = status.get(name, {})
44+
if not recovered.get("restore_verified") or not 0 <= now - recovered.get("timestamp", 0) <= 36 * 3600:
45+
return True
46+
return False
47+
48+
3849
def all_projects_recovered(config, state, now):
39-
for project in config["projects"]:
40-
status = state.get("projects", {}).get(project, {})
41-
if status.get("error"):
42-
return False
43-
for name in config["repositories"]:
44-
recovered = status.get(name, {})
45-
if not recovered.get("restore_verified") or not 0 <= now - recovered.get("timestamp", 0) <= 36 * 3600:
46-
return False
47-
return True
50+
return all(not project_backup_due(state.get("projects", {}).get(project, {}), config["repositories"], now)
51+
for project in config["projects"])
4852

4953

5054
def sqlite_snapshot(source, target):
@@ -319,7 +323,7 @@ def main():
319323
parser.add_argument("--config", type=Path, default=CONFIG)
320324
parser.add_argument("--check", action="store_true", help="rotate through a seventh of all repository data")
321325
parser.add_argument("--drill", action="store_true", help="load recovered PostgreSQL/Vitess dumps in disposable instances")
322-
parser.add_argument("--retry-failed", action="store_true", help="retry failed app groups without repeating successful backups")
326+
parser.add_argument("--retry-failed", action="store_true", help="retry failed, missing or stale app groups without repeating fresh verified backups")
323327
parser.add_argument("--preserve-backups", action="store_true", help="verify fresh backups without expiring any existing snapshots")
324328
args = parser.parse_args()
325329
os.umask(0o077)
@@ -362,8 +366,9 @@ def main():
362366
save(STATE, state)
363367
print(f"{name}: repository check {partition}/7 passed", flush=True)
364368
return
365-
if args.retry_failed and not any(item.get("error") for item in state.get("projects", {}).values()):
366-
print("No failed app backups to retry", flush=True)
369+
retry_at = time.time()
370+
if args.retry_failed and all_projects_recovered(config, state, retry_at):
371+
print("All app backups are fresh and recovery-verified", flush=True)
367372
return
368373
stage = Path(config.get("staging", "/var/cache/host-backup"))
369374
stage.mkdir(mode=0o700, parents=True, exist_ok=True)
@@ -373,7 +378,7 @@ def main():
373378
failures = []
374379
for project, specification in config["projects"].items():
375380
previous = state.setdefault("projects", {}).get(project, {})
376-
if args.retry_failed and not previous.get("error"):
381+
if args.retry_failed and not project_backup_due(previous, config["repositories"], retry_at):
377382
continue
378383
try:
379384
results, error = project_backup(project, specification, archives, stage)

‎ops/production/operator-baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"host-backup.py": {
33
"target": "/usr/local/sbin/host-backup.py",
4-
"baseline": "17a7e96756aaae920386270c63f1e8c44d67fb4b0a97c70a5ed0a96fce6653ea"
4+
"baseline": "bb46505bf0c110c63d61322ceac394aa296a8cc61dbff6fcf8c978ca47274766"
55
},
66
"mail-backup.sh": {
77
"target": "/opt/mail/backup.sh",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Retry selection must cover discovered and stale groups without pruning history."""
2+
import importlib.machinery
3+
import importlib.util
4+
import json
5+
import os
6+
from pathlib import Path
7+
import sys
8+
import tempfile
9+
import time
10+
import types
11+
import unittest
12+
from unittest.mock import patch
13+
14+
ROOT = Path(__file__).parent
15+
16+
17+
def load_backup():
18+
dependency = types.ModuleType('app_backup_retention')
19+
dependency.ResticArchive = lambda _: None
20+
dependency.digest = lambda _: 'fixture'
21+
dependency.STAGING_MARKER = '.host-backup-managed'
22+
spec = importlib.util.spec_from_file_location('retry_fixture', ROOT / 'host-backup.py')
23+
module = importlib.util.module_from_spec(spec)
24+
with patch.dict(sys.modules, {'app_backup_retention': dependency}), patch.object(importlib.machinery.SourceFileLoader, 'load_module', return_value=types.SimpleNamespace()):
25+
spec.loader.exec_module(module)
26+
return module
27+
28+
29+
class RetryTests(unittest.TestCase):
30+
def test_only_fresh_verified_backups_can_be_skipped(self):
31+
backup = load_backup()
32+
now = 2_000_000
33+
fresh = {'timestamp': now - 60, 'restore_verified': True}
34+
for label, state, due in [
35+
('new discovery', {}, True),
36+
('one provider missing', {'aws': fresh}, True),
37+
('both verified', {'aws': fresh, 'hetzner': fresh}, False),
38+
('explicit failure', {'aws': fresh, 'hetzner': fresh, 'error': 'failed'}, True),
39+
('stale provider', {'aws': fresh, 'hetzner': {**fresh, 'timestamp': now - 36 * 3600 - 1}}, True),
40+
('unverified restore', {'aws': fresh, 'hetzner': {**fresh, 'restore_verified': False}}, True),
41+
('future timestamp', {'aws': fresh, 'hetzner': {**fresh, 'timestamp': now + 1}}, True),
42+
]:
43+
with self.subTest(label=label):
44+
self.assertEqual(backup.project_backup_due(state, ['aws', 'hetzner'], now), due)
45+
self.assertEqual(backup.all_projects_recovered({'projects': {'app': {}}, 'repositories': ['aws', 'hetzner']}, {'projects': {'app': state}}, now), not due)
46+
self.assertFalse(backup.all_projects_recovered({'projects': {'new-app': {}}, 'repositories': ['aws', 'hetzner']}, {'projects': {}}, now))
47+
48+
@unittest.skipUnless(os.geteuid() == 0, 'real root-owned staging integration')
49+
def test_retry_creates_missing_and_stale_backups_and_preserves_every_snapshot(self):
50+
backup = load_backup()
51+
now = int(time.time())
52+
fresh = {name: {'timestamp': now, 'restore_verified': True} for name in ['aws', 'hetzner']}
53+
stale = {name: {**value, 'timestamp': now - 37 * 3600} for name, value in fresh.items()}
54+
calls, archived = [], []
55+
backup.ResticArchive = lambda _: types.SimpleNamespace(run=lambda *args: archived.append(args))
56+
backup.command = lambda args: None
57+
def recover(project, *_):
58+
calls.append(project)
59+
return fresh, None
60+
backup.project_backup = recover
61+
with tempfile.TemporaryDirectory() as directory:
62+
root = Path(directory)
63+
config = root / 'config.json'
64+
config.write_text(json.dumps({'repositories': {'aws': '/unused', 'hetzner': '/unused'}, 'projects': {name: {} for name in ['fresh', 'new', 'stale', 'failed']}, 'staging': str(root / 'stage')}))
65+
backup.STATE = root / 'state.json'
66+
backup.STATE.write_text(json.dumps({'projects': {'fresh': fresh, 'stale': stale, 'failed': {**fresh, 'error': 'previous failure'}}}))
67+
real_open = open
68+
def locked_open(file, *args, **kwargs):
69+
return real_open(root / 'lock' if file == '/run/lock/host-backup.lock' else file, *args, **kwargs)
70+
with patch('sys.argv', ['host-backup.py', '--config', str(config), '--retry-failed', '--preserve-backups']), patch('builtins.open', side_effect=locked_open):
71+
backup.main()
72+
self.assertEqual(calls, ['new', 'stale', 'failed'])
73+
self.assertEqual(archived, [], 'retry must never forget or prune an existing snapshot')
74+
self.assertNotIn('error', json.loads(backup.STATE.read_text())['projects']['failed'])
75+
backup.main()
76+
self.assertEqual(calls, ['new', 'stale', 'failed'], 'fresh recovered groups must not be repeated')
77+
78+
79+
if __name__ == '__main__':
80+
unittest.main()

0 commit comments

Comments
 (0)