fix(gitea): 保留跨日备份并恢复中断服务
gitea-ci / reliability-source Run 3142 checked the exact source HEAD and passed operations tests

This commit is contained in:
2026-09-26 07:53:38 +08:00
parent 80480b7ce2
commit d8bb18a091
4 changed files with 60 additions and 7 deletions
+1
View File
@@ -41,4 +41,5 @@ T5 ID `52f09d35e3fd31ce2aa3d24925ef101533dcff1f0810a203a983b307686ffdb6`。
本次修复把 03:30 单次定时改为 03:30 起按小时补跑:CI 占用则下一小时重试;
外盘短暂不可用则优先复制已有本机加密快照;同一备份窗口通过后不重复停服务。
跨日未完成的旧外置复制不阻止下一日生成新的本机快照;当日已复制但尚未发布 `last-success` 的回执会幂等补齐。
这只能缩短临时故障造成的备份缺口。连续失败仍没有主动告警;密钥独立保管仍待用户确认。
+1 -1
View File
@@ -17,7 +17,7 @@ python3 ~/ops/gitea-reliability/backup.py restore-check --external
```
安装器仅维护三个当前用户 LaunchAgent:每天 03:30 开始备份窗口,其后每小时检查一次,直到当天外盘快照校验成功;另有 60 秒异常停机恢复守卫、每小时备份健康检查。
若 CI 占用,下一小时重试冷拷贝;若本机加密快照已成功而外盘复制失败,先补复制同一份快照,不再次暂停 Gitea。当天已验证后,后续小时只检查状态,不重做冷拷贝。
若 CI 占用,下一小时重试冷拷贝;若当天本机加密快照已成功而外盘复制失败,先补复制同一份快照,不再次暂停 Gitea。跨日仍未补成的旧快照不会挡住今天的新本机冷拷贝。当天已验证后,后续小时只检查状态,不重做冷拷贝。
不加载历史 Compose 中的其它 runner。后台 Python 首次访问 T5,需要 macOS 允许“可移除宗卷”。
必须实际从 launchd 运行验证,终端成功不能代替后台权限验收。
日志在运行目录 `logs/`;检查 `launchctl print gui/$(id -u)/com.ljwx.gitea.backup` 的退出码。
+11 -6
View File
@@ -334,6 +334,7 @@ done < /tmp/repositories
def scheduled(self, now=None):
"""Retry a verified local copy first; take at most one cold copy per backup day."""
self.recover_interrupted_snapshot()
window = backup_window(now or dt.datetime.now(dt.timezone.utc))
success_file = self.root / "last-success.json"
previous = json.loads(success_file.read_text()) if success_file.exists() else None
@@ -342,13 +343,17 @@ done < /tmp/repositories
record = json.loads(record_file.read_text())
if record["created_utc"] <= previous_stamp:
break
if record.get("snapshot_id") and record.get("image_snapshot_id") and not record.get("external_verified"):
self.replicate(record_file)
if record_window(record["created_utc"]) == window:
self.status()
self.log("PASS: recovered the current backup window without another service pause")
return
# A failed copy from an older window must not prevent a fresh
# local snapshot today when the external disk remains unavailable.
if record_window(record["created_utc"]) != window:
break
if record.get("snapshot_id") and record.get("image_snapshot_id"):
# Also repair a crash after marking the record verified but
# before publishing last-success and the USB receipt.
self.replicate(record_file)
self.status()
self.log("PASS: recovered the current backup window without another service pause")
return
previous = json.loads(success_file.read_text()) if success_file.exists() else None
if previous and previous.get("external_verified") and record_window(previous["created_utc"]) == window:
self.status()
+47
View File
@@ -41,6 +41,36 @@ class BackupSafetyTests(unittest.TestCase):
status.assert_called_once()
backup.assert_not_called()
def test_older_failed_external_copy_does_not_block_todays_local_backup(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
obj = Backup({"state_dir": directory, "restic": "unused", "password_file": str(root / "key"), "local_repository": str(root / "repo")})
now = dt.datetime.now(dt.timezone.utc)
prior = (now - dt.timedelta(days=2)).strftime("%Y%m%dT%H%M%SZ")
stale = (now - dt.timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
(root / "last-success.json").write_text(json.dumps({"created_utc": prior, "external_verified": True}))
(root / f"backup-{stale}.json").write_text(json.dumps({"created_utc": stale, "snapshot_id": "data", "image_snapshot_id": "image", "external_verified": False}))
with patch.object(obj, "replicate", side_effect=RuntimeError("USB unavailable")) as replicate, patch.object(obj, "backup") as backup:
obj.scheduled(now)
replicate.assert_not_called()
backup.assert_called_once()
def test_current_verified_record_without_published_receipt_avoids_new_outage(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
obj = Backup({"state_dir": directory, "restic": "unused", "password_file": str(root / "key"), "local_repository": str(root / "repo")})
now = dt.datetime.now(dt.timezone.utc)
prior = (now - dt.timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
current = now.strftime("%Y%m%dT%H%M%SZ")
(root / "last-success.json").write_text(json.dumps({"created_utc": prior, "external_verified": True}))
pending = root / f"backup-{current}.json"
pending.write_text(json.dumps({"created_utc": current, "snapshot_id": "data", "image_snapshot_id": "image", "external_verified": True}))
with patch.object(obj, "replicate") as replicate, patch.object(obj, "status") as status, patch.object(obj, "backup") as backup:
obj.scheduled(now)
replicate.assert_called_once_with(pending)
status.assert_called_once()
backup.assert_not_called()
def test_scheduled_backup_retries_after_busy_ci(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@@ -52,6 +82,23 @@ class BackupSafetyTests(unittest.TestCase):
obj.scheduled(now + dt.timedelta(hours=1))
self.assertEqual(backup.call_count, 2)
def test_scheduled_retry_recovers_interrupted_service_before_external_copy(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
obj = Backup({"state_dir": directory, "restic": "unused", "password_file": str(root / "key"), "local_repository": str(root / "repo")})
now = dt.datetime.now(dt.timezone.utc)
prior = (now - dt.timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
current = now.strftime("%Y%m%dT%H%M%SZ")
(root / "last-success.json").write_text(json.dumps({"created_utc": prior, "external_verified": True}))
pending = root / f"backup-{current}.json"
pending.write_text(json.dumps({"created_utc": current, "snapshot_id": "data", "image_snapshot_id": "image", "external_verified": False}))
(root / "maintenance.json").write_text(json.dumps({"container": "gitea"}))
with patch.object(obj, "start_and_wait") as start, patch.object(obj, "replicate") as replicate, patch.object(obj, "status"):
obj.scheduled(now)
start.assert_called_once()
replicate.assert_called_once_with(pending)
self.assertFalse((root / "maintenance.json").exists())
def test_actual_compose_volume_is_used(self):
self.assertEqual(data_volume({"Mounts": [{"Destination": "/data", "Type": "volume", "Name": "gitea_gitea-data"}]}), "gitea_gitea-data")