fix(备份): 修复快照位点与轮转期间的增量遗漏
统一跨平台备份入口,校验快照位点、完整性及增量链后隔离恢复。 末个 binlog 按读取位点归档,避免轮转后并发事务被续接跳过。 修复 Windows 安装器的编码、默认路径和花括号路径参数。
This commit is contained in:
parent
b552f1e567
commit
ae12403c0b
144
backend/tests/unit/test_payroll_backup.py
Normal file
144
backend/tests/unit/test_payroll_backup.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""备份失败不得伪装成功,损坏文件及断链不得进入恢复。"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"payroll_backup", Path(__file__).resolve().parents[3] / "scripts/payroll_backup.py"
|
||||||
|
)
|
||||||
|
backup = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(backup)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chain(tmp_path):
|
||||||
|
data = tmp_path / "full.sql.gz"
|
||||||
|
data.write_bytes(b"fixture")
|
||||||
|
baseline = {"kind": "FULL", "database": "payroll", "server_uuid": "server-a",
|
||||||
|
"point": {"file": "binlog.000001", "position": 100},
|
||||||
|
"entries": [{"file": data.name, "sha256": backup.checksum(data)}]}
|
||||||
|
backup.json_write(tmp_path / "full.json", baseline)
|
||||||
|
state = {"full": "full.json", "point": baseline["point"], "increments": []}
|
||||||
|
backup.json_write(tmp_path / "state.json", state)
|
||||||
|
return SimpleNamespace(directory=tmp_path, container="test", database="payroll"), baseline, state
|
||||||
|
|
||||||
|
|
||||||
|
def test_备份篡改与路径越界必须拒绝(chain):
|
||||||
|
args, _, _ = chain
|
||||||
|
(args.directory / "full.sql.gz").write_bytes(b"corrupted")
|
||||||
|
with pytest.raises(backup.BackupError, match="SHA-256"):
|
||||||
|
backup.checked_chain(args)
|
||||||
|
with pytest.raises(backup.BackupError, match="文件名"):
|
||||||
|
backup.load_manifest(args.directory, "../external.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_跨全量与位点断链拒绝恢复(chain):
|
||||||
|
args, _, state = chain
|
||||||
|
change = {"full": "other.json", "start": state["point"], "end": state["point"], "entries": []}
|
||||||
|
backup.json_write(args.directory / "increment.json", change)
|
||||||
|
state["increments"] = ["increment.json"]
|
||||||
|
backup.json_write(args.directory / "state.json", state)
|
||||||
|
with pytest.raises(backup.BackupError, match="不连续"):
|
||||||
|
backup.checked_chain(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_无更新不轮转日志也不生成增量(chain, monkeypatch):
|
||||||
|
args, baseline, state = chain
|
||||||
|
calls = []
|
||||||
|
def sql(*values):
|
||||||
|
calls.append(values)
|
||||||
|
return baseline["server_uuid"]
|
||||||
|
monkeypatch.setattr(backup, "mysql", sql)
|
||||||
|
monkeypatch.setattr(backup, "position", lambda _: state["point"])
|
||||||
|
assert backup.incremental(args)["status"] == "UNCHANGED"
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert not list(args.directory.glob("increment*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_服务器更换后不续接旧链(chain, monkeypatch):
|
||||||
|
args, _, _ = chain
|
||||||
|
monkeypatch.setattr(backup, "mysql", lambda *a: "server-b")
|
||||||
|
with pytest.raises(backup.BackupError, match="身份已变化"):
|
||||||
|
backup.incremental(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_备份检查拒绝同服务器上的其他数据库(chain, monkeypatch):
|
||||||
|
args, _, _ = chain
|
||||||
|
args.database = "other_payroll"
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(backup, "mysql", lambda *a: calls.append(a))
|
||||||
|
with pytest.raises(backup.BackupError, match="检查目标不符"):
|
||||||
|
backup.check(args)
|
||||||
|
assert not calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_轮转后的并发事务归档到末位点且续接不跳过(chain, monkeypatch):
|
||||||
|
args, baseline, state = chain
|
||||||
|
start = state["point"]
|
||||||
|
end = {"file": "binlog.000002", "position": 400}
|
||||||
|
monkeypatch.setattr(backup, "mysql", lambda *a: baseline["server_uuid"])
|
||||||
|
monkeypatch.setattr(backup, "position", lambda _: end)
|
||||||
|
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000001", "binlog.000002"])
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def archive(command, path):
|
||||||
|
commands.append(command)
|
||||||
|
path.write_bytes(b"archived")
|
||||||
|
return {"file": path.name, "sha256": backup.checksum(path)}
|
||||||
|
|
||||||
|
monkeypatch.setattr(backup, "stream_gzip", archive)
|
||||||
|
first = backup.incremental(args)
|
||||||
|
manifest = backup.load_manifest(args.directory, first["manifest"])
|
||||||
|
assert manifest["start"] == start
|
||||||
|
assert manifest["end"] == end
|
||||||
|
assert [entry["binlog"] for entry in manifest["entries"]] == [
|
||||||
|
"binlog.000001", "binlog.000002"
|
||||||
|
]
|
||||||
|
assert commands[-1][-4:] == ["head", "-c", "400", "/var/lib/mysql/binlog.000002"]
|
||||||
|
|
||||||
|
# 下一轮仍从上一轮已归档的末位点续接,跨轮不会遗漏或重复计算事务。
|
||||||
|
next_end = {"file": "binlog.000003", "position": 157}
|
||||||
|
monkeypatch.setattr(backup, "position", lambda _: next_end)
|
||||||
|
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000002", "binlog.000003"])
|
||||||
|
second = backup.incremental(args)
|
||||||
|
next_manifest = backup.load_manifest(args.directory, second["manifest"])
|
||||||
|
assert next_manifest["start"] == end
|
||||||
|
assert [entry["binlog"] for entry in next_manifest["entries"]] == [
|
||||||
|
"binlog.000002", "binlog.000003"
|
||||||
|
]
|
||||||
|
assert backup.checked_chain(args)[1] == [manifest, next_manifest]
|
||||||
|
|
||||||
|
|
||||||
|
def test_损坏清单不得启动任何恢复容器(chain, monkeypatch):
|
||||||
|
args, _, _ = chain
|
||||||
|
(args.directory / "full.sql.gz").write_bytes(b"broken")
|
||||||
|
commands = []
|
||||||
|
monkeypatch.setattr(backup, "run", lambda command, **kw: commands.append(command))
|
||||||
|
with pytest.raises(backup.BackupError):
|
||||||
|
backup.restore(args)
|
||||||
|
assert commands == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_清单原子落盘不留下半成品(tmp_path):
|
||||||
|
target = tmp_path / "manifest.json"
|
||||||
|
backup.json_write(target, {"status": "SUCCEEDED"})
|
||||||
|
assert json.loads(target.read_text())["status"] == "SUCCEEDED"
|
||||||
|
assert not list(tmp_path.glob("*.partial"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_MySQL探活使用TCP避开镜像初始化临时服务(monkeypatch):
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def run(command, **kwargs):
|
||||||
|
commands.append(command)
|
||||||
|
assert kwargs["data"] == b"SELECT 1"
|
||||||
|
return b"1\n"
|
||||||
|
|
||||||
|
monkeypatch.setattr(backup, "run", run)
|
||||||
|
assert backup.mysql("isolated-test", "SELECT 1") == "1"
|
||||||
|
command = commands[0]
|
||||||
|
assert "--host=127.0.0.1 --protocol=TCP" in command[command.index("-c") + 1]
|
||||||
7
docker/Dockerfile.backup
Normal file
7
docker/Dockerfile.backup
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# 官方最小 MySQL 镜像缺少 mysqlbinlog;仅在独立恢复演练镜像内补齐同版本工具。
|
||||||
|
ARG MYSQL_IMAGE=mysql:8.0.46
|
||||||
|
FROM ${MYSQL_IMAGE}
|
||||||
|
ADD https://repo.mysql.com/yum/mysql-8.0-community/el/9/x86_64/mysql-community-client-8.0.46-1.el9.x86_64.rpm /tmp/mysql-client.rpm
|
||||||
|
RUN echo "decc2c06fac430b7987cfae4e4039cb49bdce56dfadfd0a28738bf0757f04f17 /tmp/mysql-client.rpm" | sha256sum -c - \
|
||||||
|
&& rpm -i --nodeps --replacefiles /tmp/mysql-client.rpm \
|
||||||
|
&& mysqlbinlog --version
|
||||||
@ -1,64 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 衍水工资系统 —— MySQL 全量备份(⑨ 备份恢复与运维检查)
|
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
|
||||||
#
|
|
||||||
# 思路:在跑中的容器内执行 mysqldump,免暴露 3306 端口给宿主机工具,
|
|
||||||
# 也避免宿主机装 MySQL 客户端。凭据从 docker/.env 读取(不入脚本、不入版本库)。
|
|
||||||
#
|
|
||||||
# 用法:在项目根目录执行 bash scripts/backup_full.sh
|
|
||||||
# 产物:db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz,永久保留,不自动清理。
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" full "$@"
|
||||||
|
|
||||||
if [[ -f docker/.env ]]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source docker/.env
|
|
||||||
else
|
|
||||||
echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
|
|
||||||
: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}"
|
|
||||||
|
|
||||||
BACKUP_DIR="$ROOT/db/backups"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
|
||||||
OUT="$BACKUP_DIR/payroll_${STAMP}.sql.gz"
|
|
||||||
|
|
||||||
# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致
|
|
||||||
CONTAINER="ys-mysql-dev"
|
|
||||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "开始备份 $MYSQL_DATABASE → $(basename "$OUT")"
|
|
||||||
docker exec "$CONTAINER" sh -c \
|
|
||||||
"mysqldump -uroot -p\"\$MYSQL_ROOT_PASSWORD\" --single-transaction --routines --triggers \"$MYSQL_DATABASE\"" \
|
|
||||||
| gzip > "$OUT"
|
|
||||||
|
|
||||||
if [[ ! -s "$OUT" ]]; then
|
|
||||||
echo "ERROR: 备份文件为空(数据库名或凭据可能不对)" >&2
|
|
||||||
rm -f "$OUT"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
SIZE="$(du -h "$OUT" | cut -f1)"
|
|
||||||
echo "备份完成:$(basename "$OUT") ($SIZE)"
|
|
||||||
|
|
||||||
# 记录 binlog 位点:增量备份(backup_incremental.sh)与恢复重放的对齐锚点——
|
|
||||||
# 从「此位点之后」的 binlog 开始重放即为完整增量链
|
|
||||||
docker exec "$CONTAINER" sh -c \
|
|
||||||
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW MASTER STATUS"' > "${OUT}.pos" 2>/dev/null
|
|
||||||
if [[ -s "${OUT}.pos" ]]; then
|
|
||||||
echo "binlog 位点:$(awk '{print $1, "pos="$2}' "${OUT}.pos") → $(basename "$OUT").pos"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "永久留存:未自动清理任何备份"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "最新备份:"
|
|
||||||
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -3
|
|
||||||
|
|||||||
@ -1,89 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 衍水工资系统 —— MySQL 增量备份(binlog 归档)
|
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
|
||||||
#
|
|
||||||
# 口径(澄清 #19 业务答复 2026-10-19):每日全量 + 有更新时增量补充备份。
|
|
||||||
# 增量 = 归档 MySQL binlog:自上次归档之后产生的新 binlog 文件逐个拷出压缩;
|
|
||||||
# 没有新变更时不产出文件(「有更新时」才补充)。
|
|
||||||
#
|
|
||||||
# 恢复顺序:最新全量(backup_full.sh 产物)→ 依次重放其后的 binlog 增量包
|
|
||||||
# (用 mysqlbinlog 重放,见 docs/备份恢复手册.md 增量章节)。
|
|
||||||
#
|
|
||||||
# 实现说明:mysql:8.0 官方镜像不含 mysqlbinlog 客户端,故不走
|
|
||||||
# --read-from-remote-server,直接从数据目录 tar 拷出原始 binlog 文件;
|
|
||||||
# 只归档「已写完」的文件(SHOW BINARY LOGS 列表最后一个为当前写入中,
|
|
||||||
# 留到下次收取,配合 30 天 binlog 过期窗口余量充足)。
|
|
||||||
#
|
|
||||||
# 用法:在项目根目录执行 bash scripts/backup_incremental.sh
|
|
||||||
# 产物:db/backups/binlog_NNNNNN.gz(NNNNNN 为 binlog 序号),永久保留,不自动清理。
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" incremental "$@"
|
||||||
|
|
||||||
if [[ -f docker/.env ]]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source docker/.env
|
|
||||||
else
|
|
||||||
echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
|
|
||||||
|
|
||||||
BACKUP_DIR="$ROOT/db/backups"
|
|
||||||
STATE_FILE="$BACKUP_DIR/.incremental_state"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致
|
|
||||||
CONTAINER="ys-mysql-dev"
|
|
||||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 列出 binlog:文件名 序号 大小(mysql -N 无表头,直接出数据行)
|
|
||||||
mapfile -t BINLOGS < <(
|
|
||||||
docker exec "$CONTAINER" sh -c \
|
|
||||||
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW BINARY LOGS"' 2>/dev/null \
|
|
||||||
| awk '{print $1}'
|
|
||||||
)
|
|
||||||
if [[ ${#BINLOGS[@]} -eq 0 ]]; then
|
|
||||||
echo "ERROR: SHOW BINARY LOGS 无输出(binlog 未开启或凭据不对)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ${#BINLOGS[@]} -eq 1 ]]; then
|
|
||||||
echo "仅有写入中的 binlog(${BINLOGS[0]}),无已写完文件可归档"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
COMPLETED=("${BINLOGS[@]:0:${#BINLOGS[@]}-1}") # 去掉写入中的最后一个
|
|
||||||
|
|
||||||
LAST="$(cat "$STATE_FILE" 2>/dev/null || echo "")"
|
|
||||||
NEW=()
|
|
||||||
for f in "${COMPLETED[@]}"; do
|
|
||||||
# 与状态文件比较序号:无状态(首次)全收;有状态只收更大的
|
|
||||||
if [[ -z "$LAST" ]] || (( ${f##*.} > ${LAST##*.} )); then
|
|
||||||
NEW+=("$f")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ${#NEW[@]} -eq 0 ]]; then
|
|
||||||
echo "无新增变更(上次归档至 ${LAST},无需补充增量)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
|
||||||
echo "归档 ${#NEW[@]} 个 binlog(${NEW[0]} ~ ${NEW[-1]})"
|
|
||||||
for f in "${NEW[@]}"; do
|
|
||||||
OUT="$BACKUP_DIR/${f}.gz"
|
|
||||||
docker exec "$CONTAINER" sh -c "tar cf - -C /var/lib/mysql '$f'" | gzip > "$OUT"
|
|
||||||
if [[ ! -s "$OUT" ]]; then
|
|
||||||
echo "ERROR: $OUT 为空" >&2
|
|
||||||
rm -f "$OUT"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " $(basename "$OUT") ($(du -h "$OUT" | cut -f1))"
|
|
||||||
done
|
|
||||||
echo "$STAMP ${NEW[-1]}" > "$STATE_FILE"
|
|
||||||
|
|
||||||
# 归档文件永久留存;服务器原始 binlog 过期与归档留存是不同配置,部署前验证归档链完整。
|
|
||||||
echo "增量归档完成,最新至 ${NEW[-1]}"
|
|
||||||
|
|||||||
@ -1,106 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 运维健康检查 —— 容器/磁盘/备份新鲜度/登录异常(⑨ 输出一份可读报告)
|
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
|
||||||
#
|
set -euo pipefail
|
||||||
# 用法:在项目根目录执行 bash scripts/health_check.sh
|
|
||||||
# 每项 PASS/WARN/FAIL,最后给出结论。WARN/FAIL 时退出码非零提示运维介入。
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" check "$@"
|
||||||
|
|
||||||
if [[ -f docker/.env ]]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source docker/.env
|
|
||||||
fi
|
|
||||||
|
|
||||||
ISSUES=0
|
|
||||||
report() { printf "%-10s %s\n" "$1" "$2"; }
|
|
||||||
warn() { report "WARN" "$1"; ISSUES=$((ISSUES+1)); }
|
|
||||||
fail() { report "FAIL" "$1"; ISSUES=$((ISSUES+1)); }
|
|
||||||
pass() { report "PASS" "$1"; }
|
|
||||||
|
|
||||||
echo "======== 衍水工资系统 健康检查 $(date '+%Y-%m-%d %H:%M:%S') ========"
|
|
||||||
|
|
||||||
# 1. 容器健康
|
|
||||||
for C in ys-mysql-dev ys-redis-dev; do
|
|
||||||
if ! docker inspect "$C" >/dev/null 2>&1; then
|
|
||||||
fail "容器 $C 不存在"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
STATUS="$(docker inspect -f '{{.State.Health.Status}}' "$C" 2>/dev/null || echo none)"
|
|
||||||
RUNNING="$(docker inspect -f '{{.State.Running}}' "$C" 2>/dev/null)"
|
|
||||||
if [[ "$RUNNING" == "true" && "$STATUS" == "healthy" ]]; then
|
|
||||||
pass "容器 $C 运行中且健康"
|
|
||||||
elif [[ "$RUNNING" == "true" ]]; then
|
|
||||||
warn "容器 $C 运行中但健康检查=$STATUS"
|
|
||||||
else
|
|
||||||
fail "容器 $C 未运行"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 2. 磁盘水位
|
|
||||||
DISK_PCT="$(df -h "$ROOT" | awk 'NR==2 {gsub(/%/,"",$5); print $5}')"
|
|
||||||
if [[ -z "$DISK_PCT" ]]; then
|
|
||||||
warn "磁盘信息读取失败"
|
|
||||||
elif (( DISK_PCT < 80 )); then
|
|
||||||
pass "磁盘使用率 ${DISK_PCT}%(阈值 80%)"
|
|
||||||
elif (( DISK_PCT < 90 )); then
|
|
||||||
warn "磁盘使用率 ${DISK_PCT}%(建议清理)"
|
|
||||||
else
|
|
||||||
fail "磁盘使用率 ${DISK_PCT}%(高危,立即清理)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. 备份新鲜度
|
|
||||||
BACKUP_DIR="$ROOT/db/backups"
|
|
||||||
LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)"
|
|
||||||
if [[ -z "$LATEST" ]]; then
|
|
||||||
warn "未找到任何备份(应每日备份)"
|
|
||||||
else
|
|
||||||
AGE_H=$(( ($(date +%s) - $(stat -c %Y "$LATEST")) / 3600 ))
|
|
||||||
if (( AGE_H < 24 )); then
|
|
||||||
pass "最新备份 $(basename "$LATEST"),${AGE_H} 小时前"
|
|
||||||
elif (( AGE_H < 48 )); then
|
|
||||||
warn "最新备份已 ${AGE_H} 小时前(超过 24 小时)"
|
|
||||||
else
|
|
||||||
fail "最新备份已 ${AGE_H} 小时前(备份任务可能已停)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行;
|
|
||||||
# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD)
|
|
||||||
if [[ -n "${REDIS_PASSWORD:-}" ]]; then
|
|
||||||
if docker exec ys-redis-dev sh -c 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping' 2>/dev/null | grep -q PONG; then
|
|
||||||
pass "Redis 响应 PONG"
|
|
||||||
else
|
|
||||||
warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "docker/.env 缺 REDIS_PASSWORD,跳过 Redis 探活"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. 登录日志异常(24h 失败次数)
|
|
||||||
if [[ -n "${MYSQL_ROOT_PASSWORD:-}" ]] && docker inspect ys-mysql-dev >/dev/null 2>&1; then
|
|
||||||
FAILS=$(docker exec ys-mysql-dev sh -c \
|
|
||||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" ${MYSQL_DATABASE:-payroll} -N -B -e \"
|
|
||||||
SELECT COUNT(*) FROM login_logs
|
|
||||||
WHERE success = 0 AND create_time > NOW() - INTERVAL 24 HOUR;\" 2>/dev/null" || echo "?")
|
|
||||||
if [[ "$FAILS" == "?" ]]; then
|
|
||||||
warn "登录日志查询失败"
|
|
||||||
elif (( FAILS < 10 )); then
|
|
||||||
pass "24h 登录失败 ${FAILS} 次(阈值 10)"
|
|
||||||
else
|
|
||||||
warn "24h 登录失败 ${FAILS} 次(可能有暴力破解,建议核查)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "跳过登录日志检查(无凭据或容器未运行)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=============================================================="
|
|
||||||
if (( ISSUES == 0 )); then
|
|
||||||
echo "结论:全部通过,系统健康。"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "结论:发现 $ISSUES 项需关注(见上)。"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|||||||
489
scripts/payroll_backup.py
Normal file
489
scripts/payroll_backup.py
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
"""跨平台 MySQL 备份:一致位点、binlog 完整链、独立容器恢复、只读检查。"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from itertools import pairwise
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
IDENT = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
||||||
|
LOG = re.compile(r"^binlog\.\d{6,}$")
|
||||||
|
|
||||||
|
|
||||||
|
class BackupError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, *, data=None, env=None):
|
||||||
|
result = subprocess.run(args, input=data, capture_output=True, env=env, check=False)
|
||||||
|
if result.returncode:
|
||||||
|
# 原始数据库错误可能含业务内容;只报告操作及退出状态。
|
||||||
|
raise BackupError(f"{args[0]} 操作失败,退出码 {result.returncode}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def mysql(container, sql, database=None):
|
||||||
|
args = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
container,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
# 官方镜像初始化期间会启动禁用 TCP 的临时服务,不能把它当作就绪实例。
|
||||||
|
'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; '
|
||||||
|
'exec mysql -uroot --host=127.0.0.1 --protocol=TCP -N -B "$@"',
|
||||||
|
"mysql",
|
||||||
|
]
|
||||||
|
if database:
|
||||||
|
args.append(database)
|
||||||
|
return run(args, data=sql.encode()).decode("utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def json_write(path, value):
|
||||||
|
temporary = path.with_suffix(path.suffix + ".partial")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def checksum(path):
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def stream_to(args, source):
|
||||||
|
"""按块恢复大库和日志,避免将全部数据加载进内存。"""
|
||||||
|
with subprocess.Popen(
|
||||||
|
args,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
) as process:
|
||||||
|
shutil.copyfileobj(source, process.stdin, 1024 * 1024)
|
||||||
|
process.stdin.close()
|
||||||
|
if process.wait() != 0:
|
||||||
|
raise BackupError("恢复数据流执行失败")
|
||||||
|
|
||||||
|
|
||||||
|
def logs(container):
|
||||||
|
rows = [r.split("\t")[0] for r in mysql(container, "SHOW BINARY LOGS").splitlines()]
|
||||||
|
if not rows or any(not LOG.fullmatch(r) for r in rows):
|
||||||
|
raise BackupError("binlog 未开启或文件名不受支持")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def position(container):
|
||||||
|
parts = mysql(container, "SHOW MASTER STATUS").split("\t")
|
||||||
|
if len(parts) < 2 or not LOG.fullmatch(parts[0]):
|
||||||
|
raise BackupError("无法获得 binlog 位点")
|
||||||
|
return {"file": parts[0], "position": int(parts[1])}
|
||||||
|
|
||||||
|
|
||||||
|
def stream_gzip(args, out):
|
||||||
|
temporary = out.with_suffix(out.suffix + ".partial")
|
||||||
|
with subprocess.Popen(
|
||||||
|
args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
|
||||||
|
) as process:
|
||||||
|
with gzip.open(temporary, "wb") as dest:
|
||||||
|
shutil.copyfileobj(process.stdout, dest, 1024 * 1024)
|
||||||
|
if process.wait() != 0:
|
||||||
|
raise BackupError("备份流失败,未发布成功清单,临时文件保留供核对")
|
||||||
|
temporary.replace(out)
|
||||||
|
return {"file": out.name, "sha256": checksum(out)}
|
||||||
|
|
||||||
|
|
||||||
|
def full(args):
|
||||||
|
stamp = (
|
||||||
|
datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
+ "_"
|
||||||
|
+ secrets.token_hex(3)
|
||||||
|
)
|
||||||
|
out = args.directory / f"full_{stamp}.sql.gz"
|
||||||
|
server = mysql(args.container, "SELECT @@server_uuid")
|
||||||
|
binary = logs(args.container) # 关闭 binlog 时不生成无法续接的全量。
|
||||||
|
command = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
args.container,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
(
|
||||||
|
'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysqldump -uroot '
|
||||||
|
"--single-transaction --source-data=2 --set-gtid-purged=OFF "
|
||||||
|
'--routines --events --triggers --hex-blob --no-tablespaces "$1"'
|
||||||
|
),
|
||||||
|
"backup",
|
||||||
|
args.database,
|
||||||
|
]
|
||||||
|
entry = stream_gzip(command, out)
|
||||||
|
point, completed, tables = None, False, []
|
||||||
|
with gzip.open(out, "rt", encoding="utf-8") as stream:
|
||||||
|
for line in stream:
|
||||||
|
match = re.search(
|
||||||
|
r"(?:SOURCE|MASTER)_LOG_FILE='([^']+)', (?:SOURCE|MASTER)_LOG_POS=(\d+)",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
point = {"file": match[1], "position": int(match[2])}
|
||||||
|
table = re.match(r"CREATE TABLE `([^`]+)`", line)
|
||||||
|
if table:
|
||||||
|
tables.append(table[1])
|
||||||
|
completed = completed or line.startswith("-- Dump completed on ")
|
||||||
|
if not completed or not point or not tables or not LOG.fullmatch(point["file"]):
|
||||||
|
raise BackupError("全量缺少完成标识、业务表或一致快照位点,不能作为恢复基线")
|
||||||
|
manifest = {
|
||||||
|
"kind": "FULL",
|
||||||
|
"database": args.database,
|
||||||
|
"server_uuid": server,
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
"image": args.image,
|
||||||
|
"point": point,
|
||||||
|
"entries": [entry],
|
||||||
|
"tables": sorted(tables),
|
||||||
|
"logs_seen": binary,
|
||||||
|
}
|
||||||
|
name = f"full_{stamp}.json"
|
||||||
|
json_write(args.directory / name, manifest)
|
||||||
|
json_write(
|
||||||
|
args.directory / "state.json",
|
||||||
|
{
|
||||||
|
"full": name,
|
||||||
|
"point": point,
|
||||||
|
"increments": [],
|
||||||
|
"checked_at": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"status": "SUCCEEDED", "manifest": name, "point": point}
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(directory, name):
|
||||||
|
if Path(name).name != name:
|
||||||
|
raise BackupError("备份清单必须是备份目录内的文件名")
|
||||||
|
value = json.loads((directory / name).read_text(encoding="utf-8"))
|
||||||
|
for entry in value["entries"]:
|
||||||
|
filename = entry["file"]
|
||||||
|
if (
|
||||||
|
Path(filename).name != filename
|
||||||
|
or checksum(directory / filename) != entry["sha256"]
|
||||||
|
):
|
||||||
|
raise BackupError("备份文件缺失或 SHA-256 不符")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def incremental(args):
|
||||||
|
state_path = args.directory / "state.json"
|
||||||
|
if not state_path.exists():
|
||||||
|
raise BackupError("请先完成全量备份")
|
||||||
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
baseline = load_manifest(args.directory, state["full"])
|
||||||
|
if baseline["database"] != args.database or baseline["server_uuid"] != mysql(
|
||||||
|
args.container, "SELECT @@server_uuid"
|
||||||
|
):
|
||||||
|
raise BackupError("数据库或服务器身份已变化,必须先建立新的全量基线")
|
||||||
|
current = position(args.container)
|
||||||
|
if state["point"] == current:
|
||||||
|
state["checked_at"] = datetime.now(UTC).isoformat()
|
||||||
|
json_write(state_path, state)
|
||||||
|
return {"status": "UNCHANGED", "point": current}
|
||||||
|
available = logs(args.container)
|
||||||
|
if state["point"]["file"] not in available:
|
||||||
|
raise BackupError("增量链起点已被清理,必须重新全量备份")
|
||||||
|
mysql(args.container, "FLUSH BINARY LOGS")
|
||||||
|
end = position(args.container)
|
||||||
|
available = logs(args.container)
|
||||||
|
names = available[
|
||||||
|
available.index(state["point"]["file"]) : available.index(end["file"]) + 1
|
||||||
|
]
|
||||||
|
numbers = [int(n.split(".")[1]) for n in names]
|
||||||
|
if any(b != a + 1 for a, b in pairwise(numbers)):
|
||||||
|
raise BackupError("binlog 序号存在缺口,拒绝发布增量成功")
|
||||||
|
stamp = secrets.token_hex(8)
|
||||||
|
entries = []
|
||||||
|
for name in names:
|
||||||
|
# 轮转后读取位点前仍可能有事务写入新文件;末文件也要归档到该位点,
|
||||||
|
# 否则下一次从 end 续接时会永久跳过这些事务。固定字节边界避免重复重放。
|
||||||
|
command = ["docker", "exec", args.container]
|
||||||
|
if name == end["file"]:
|
||||||
|
command.extend(["head", "-c", str(end["position"]), f"/var/lib/mysql/{name}"])
|
||||||
|
else:
|
||||||
|
command.extend(["cat", f"/var/lib/mysql/{name}"])
|
||||||
|
entry = stream_gzip(
|
||||||
|
command,
|
||||||
|
args.directory / f"{stamp}_{name}.gz",
|
||||||
|
)
|
||||||
|
entry["binlog"] = name
|
||||||
|
entries.append(entry)
|
||||||
|
manifest_name = f"increment_{stamp}.json"
|
||||||
|
manifest = {
|
||||||
|
"kind": "INCREMENTAL",
|
||||||
|
"full": state["full"],
|
||||||
|
"start": state["point"],
|
||||||
|
"end": end,
|
||||||
|
"entries": entries,
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
json_write(args.directory / manifest_name, manifest)
|
||||||
|
state["increments"].append(manifest_name)
|
||||||
|
state["point"] = end
|
||||||
|
state["checked_at"] = datetime.now(UTC).isoformat()
|
||||||
|
json_write(state_path, state)
|
||||||
|
return {"status": "SUCCEEDED", "manifest": manifest_name, "files": len(entries)}
|
||||||
|
|
||||||
|
|
||||||
|
def checked_chain(args):
|
||||||
|
state = json.loads((args.directory / "state.json").read_text(encoding="utf-8"))
|
||||||
|
baseline = load_manifest(args.directory, state["full"])
|
||||||
|
point, increments = baseline["point"], []
|
||||||
|
for name in state["increments"]:
|
||||||
|
change = load_manifest(args.directory, name)
|
||||||
|
if change["full"] != state["full"] or change["start"] != point:
|
||||||
|
raise BackupError("增量清单不连续或跨全量基线")
|
||||||
|
increments.append(change)
|
||||||
|
point = change["end"]
|
||||||
|
if point != state["point"]:
|
||||||
|
raise BackupError("增量末位点与状态不一致")
|
||||||
|
return baseline, increments
|
||||||
|
|
||||||
|
|
||||||
|
def restore(args):
|
||||||
|
"""只创建随机命名、独立可写层的临时 MySQL;不接触已有数据卷。"""
|
||||||
|
baseline, increments = checked_chain(args)
|
||||||
|
target = "payroll-drill-" + secrets.token_hex(8)
|
||||||
|
env = os.environ | {"MYSQL_ROOT_PASSWORD": secrets.token_urlsafe(32)}
|
||||||
|
volume = target + "-data"
|
||||||
|
run(["docker", "volume", "create", "--label", "payroll.restore-drill=true", volume])
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
target,
|
||||||
|
"--label",
|
||||||
|
"payroll.restore-drill=true",
|
||||||
|
"--mount",
|
||||||
|
f"type=volume,source={volume},target=/var/lib/mysql",
|
||||||
|
"--env",
|
||||||
|
"MYSQL_ROOT_PASSWORD",
|
||||||
|
args.image,
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for _ in range(300):
|
||||||
|
try:
|
||||||
|
mysql(target, "SELECT 1")
|
||||||
|
break
|
||||||
|
except BackupError:
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
raise BackupError("隔离恢复容器未就绪")
|
||||||
|
database = baseline["database"]
|
||||||
|
if not IDENT.fullmatch(database):
|
||||||
|
raise BackupError("备份数据库名称无效")
|
||||||
|
mysql(target, f"CREATE DATABASE `{database}` CHARACTER SET utf8mb4")
|
||||||
|
command = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
target,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysql -uroot "$1"',
|
||||||
|
"restore",
|
||||||
|
database,
|
||||||
|
]
|
||||||
|
with subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
) as process:
|
||||||
|
with gzip.open(
|
||||||
|
args.directory / baseline["entries"][0]["file"], "rb"
|
||||||
|
) as source:
|
||||||
|
shutil.copyfileobj(source, process.stdin, 1024 * 1024)
|
||||||
|
process.stdin.close()
|
||||||
|
if process.wait() != 0:
|
||||||
|
raise BackupError("隔离全量恢复失败")
|
||||||
|
for change in increments:
|
||||||
|
paths = []
|
||||||
|
for entry in change["entries"]:
|
||||||
|
name = entry["binlog"]
|
||||||
|
if not LOG.fullmatch(name):
|
||||||
|
raise BackupError("增量文件名无效")
|
||||||
|
with gzip.open(args.directory / entry["file"], "rb") as source:
|
||||||
|
stream_to(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
target,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
'cat > "/tmp/$1"',
|
||||||
|
"archive",
|
||||||
|
name,
|
||||||
|
],
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
paths.append(f"/tmp/{name}")
|
||||||
|
if paths:
|
||||||
|
# mysqlbinlog 必须在同一次调用中按顺序消费多个文件,保留临时表上下文。
|
||||||
|
decoder_command = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
target,
|
||||||
|
"mysqlbinlog",
|
||||||
|
"--disable-log-bin",
|
||||||
|
f"--database={database}",
|
||||||
|
f"--start-position={change['start']['position']}",
|
||||||
|
*paths,
|
||||||
|
]
|
||||||
|
with subprocess.Popen(
|
||||||
|
decoder_command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
|
||||||
|
) as decoder:
|
||||||
|
with subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdin=decoder.stdout,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
) as consumer:
|
||||||
|
decoder.stdout.close()
|
||||||
|
consumed = consumer.wait()
|
||||||
|
if decoder.wait() or consumed:
|
||||||
|
raise BackupError("增量重放失败,不能将恢复标为成功")
|
||||||
|
tables = mysql(target, "SHOW TABLES", database).splitlines()
|
||||||
|
if not increments and sorted(tables) != baseline["tables"]:
|
||||||
|
raise BackupError("恢复后表清单不符")
|
||||||
|
counts = {
|
||||||
|
table: int(mysql(target, f"SELECT COUNT(*) FROM `{table}`", database))
|
||||||
|
for table in tables
|
||||||
|
if IDENT.fullmatch(table)
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"container": target,
|
||||||
|
"database": database,
|
||||||
|
"increments": len(increments),
|
||||||
|
"table_counts": counts,
|
||||||
|
"scope": "独立容器;生产库未覆盖",
|
||||||
|
}
|
||||||
|
json_write(args.directory / f"{target}.json", result)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
# 仅停止本次随机创建且带标签的容器,保留恢复内容供复核,不批量删除历史演练。
|
||||||
|
run(["docker", "stop", target])
|
||||||
|
|
||||||
|
|
||||||
|
def check(args):
|
||||||
|
baseline, increments = checked_chain(args)
|
||||||
|
if baseline["database"] != args.database:
|
||||||
|
raise BackupError("备份数据库与检查目标不符")
|
||||||
|
age = (
|
||||||
|
datetime.now(UTC) - datetime.fromisoformat(baseline["created_at"])
|
||||||
|
).total_seconds()
|
||||||
|
if age > 26 * 3600:
|
||||||
|
raise BackupError("最新全量备份超过 26 小时")
|
||||||
|
uuid = mysql(args.container, "SELECT @@server_uuid")
|
||||||
|
if uuid != baseline["server_uuid"]:
|
||||||
|
raise BackupError("运行服务器与备份身份不符")
|
||||||
|
if mysql(args.container, "SELECT @@innodb_force_recovery") != "0":
|
||||||
|
raise BackupError("数据库仍处于强制恢复模式")
|
||||||
|
state = json.loads((args.directory / "state.json").read_text(encoding="utf-8"))
|
||||||
|
checked_at = state.get("checked_at", baseline["created_at"])
|
||||||
|
if (
|
||||||
|
datetime.now(UTC) - datetime.fromisoformat(checked_at)
|
||||||
|
).total_seconds() > 30 * 60:
|
||||||
|
raise BackupError("增量采集检查超过 30 分钟未运行")
|
||||||
|
redis = getattr(args, "redis_container", None)
|
||||||
|
if redis:
|
||||||
|
pong = run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
redis,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
'REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli --no-auth-warning PING',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if pong.strip() != b"PONG":
|
||||||
|
raise BackupError("Redis 探活失败")
|
||||||
|
stat = shutil.disk_usage(args.directory)
|
||||||
|
if stat.free / stat.total < 0.1:
|
||||||
|
raise BackupError("备份盘可用空间低于 10%")
|
||||||
|
return {
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"full_age_hours": round(age / 3600, 2),
|
||||||
|
"increment_count": len(increments),
|
||||||
|
"free_bytes": stat.free,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"action", choices=["full", "incremental", "check", "restore-drill"]
|
||||||
|
)
|
||||||
|
parser.add_argument("--container", default="ys-mysql-dev")
|
||||||
|
parser.add_argument("--database", default="payroll")
|
||||||
|
parser.add_argument("--directory", type=Path, default=ROOT / "db/backups")
|
||||||
|
parser.add_argument("--image", default="payroll-mysql-backup:8.0.46")
|
||||||
|
parser.add_argument("--redis-container", default="ys-redis-dev")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not IDENT.fullmatch(args.database) or not re.fullmatch(
|
||||||
|
r"[A-Za-z0-9_.-]+", args.container
|
||||||
|
):
|
||||||
|
parser.error("容器名或数据库名无效")
|
||||||
|
args.directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
lock = args.directory / ".backup.lock"
|
||||||
|
try:
|
||||||
|
with lock.open("x", encoding="utf-8") as stream:
|
||||||
|
stream.write(str(os.getpid()))
|
||||||
|
except FileExistsError:
|
||||||
|
print("备份目录已有活动锁,请核实上次进程状态", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
result = {
|
||||||
|
"full": full,
|
||||||
|
"incremental": incremental,
|
||||||
|
"check": check,
|
||||||
|
"restore-drill": restore,
|
||||||
|
}[args.action](args)
|
||||||
|
json_write(
|
||||||
|
args.directory / f"last-{args.action}-run.json",
|
||||||
|
{"finished_at": datetime.now(UTC).isoformat(), **result},
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
except (BackupError, OSError, ValueError, KeyError) as error:
|
||||||
|
json_write(
|
||||||
|
args.directory / "last-failure.json",
|
||||||
|
{
|
||||||
|
"finished_at": datetime.now(UTC).isoformat(),
|
||||||
|
"action": args.action,
|
||||||
|
"status": "FAILED",
|
||||||
|
"reason": str(error),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(f"操作失败:{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
lock.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -1,116 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 恢复演练 —— 把最近一份备份恢复到「临时库」并核对行数(⑨:备份不演练 = 没有备份)
|
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
|
||||||
#
|
|
||||||
# 演练绝不直接覆盖生产库;临时库名 payroll_drill_<时间戳>,核对完由
|
|
||||||
# 调用方决定是否删除(本脚本默认不 DROP,安全优先)。
|
|
||||||
#
|
|
||||||
# 用法:在项目根目录执行 bash scripts/restore_drill.sh
|
|
||||||
# 产物:临时库 + 终端行数对比报告;--drop-exec 参数在核对通过后删除临时库。
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" restore-drill "$@"
|
||||||
|
|
||||||
if [[ -f docker/.env ]]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source docker/.env
|
|
||||||
else
|
|
||||||
echo "ERROR: docker/.env 不存在" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
|
|
||||||
: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}"
|
|
||||||
|
|
||||||
CONTAINER="ys-mysql-dev"
|
|
||||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: 容器 $CONTAINER 未运行" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP_DIR="$ROOT/db/backups"
|
|
||||||
LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)"
|
|
||||||
if [[ -z "$LATEST" ]]; then
|
|
||||||
echo "ERROR: 未找到备份文件(先 bash scripts/backup_full.sh)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
DRILL_DB="payroll_drill_$(date +%Y%m%d_%H%M%S)"
|
|
||||||
|
|
||||||
# 0. --drop-exec:演练前先清掉**全部**历史遗留 drill 库(含上一轮「保留供
|
|
||||||
# 核对」的那份——否则每演练一轮净增一份整库拷贝,磁盘被无人认领的
|
|
||||||
# drill 库吃满),演练完成后本轮临时库也随之删除
|
|
||||||
if [[ "${1:-}" == "--drop-exec" ]]; then
|
|
||||||
OLD_DBS=$(docker exec "$CONTAINER" sh -c \
|
|
||||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SHOW DATABASES LIKE 'payroll_drill_%';\"" 2>/dev/null || true)
|
|
||||||
for OLD in $OLD_DBS; do
|
|
||||||
docker exec "$CONTAINER" sh -c \
|
|
||||||
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$OLD\""
|
|
||||||
echo "已清理历史演练库 $OLD"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "演练恢复:$(basename "$LATEST") → 临时库 $DRILL_DB"
|
|
||||||
|
|
||||||
# 1. 创建临时库(mysqladmin create 无反引号转义问题)
|
|
||||||
docker exec "$CONTAINER" sh -c \
|
|
||||||
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" create \"$DRILL_DB\""
|
|
||||||
|
|
||||||
# 2. 解压导入
|
|
||||||
gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \
|
|
||||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\""
|
|
||||||
|
|
||||||
# 3. 行数核对:**全部业务表**(新增表必须同步补入本清单——备份漏表时
|
|
||||||
# 演练要能发现;alembic_version 不列,行数恒 1 无核对价值)
|
|
||||||
TABLES=(
|
|
||||||
adjustment_orders approval_orders approval_steps business_items business_roles
|
|
||||||
business_secondary_allocations business_status_logs businesses customer_rating_grades
|
|
||||||
customers login_logs monthly_settlements operation_logs ownership_change_logs
|
|
||||||
param_change_batches param_definitions param_values performance_ledgers permissions
|
|
||||||
personnel personnel_plans personnel_regions products receipts role_permissions roles
|
|
||||||
settlement_items shipment_items shipments system_settings user_roles users
|
|
||||||
)
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "======== 行数对比(源库 vs 演练库)========"
|
|
||||||
printf "%-24s %12s %12s %6s\n" "表名" "源库" "演练库" "结果"
|
|
||||||
ALL_OK=1
|
|
||||||
for T in "${TABLES[@]}"; do
|
|
||||||
SRC=$(docker exec "$CONTAINER" sh -c \
|
|
||||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $MYSQL_DATABASE.$T;\" 2>/dev/null" || echo "?")
|
|
||||||
DST=$(docker exec "$CONTAINER" sh -c \
|
|
||||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $DRILL_DB.$T;\" 2>/dev/null" || echo "?")
|
|
||||||
if [[ "$SRC" == "?" || "$DST" == "?" ]]; then
|
|
||||||
# 查询失败(表缺失/权限/客户端故障)不可与「数据一致」混淆:?
|
|
||||||
# 与 ? 相等会误报可恢复——备份真的漏表时演练必须红
|
|
||||||
MARK="✗"
|
|
||||||
ALL_OK=0
|
|
||||||
elif [[ "$SRC" == "$DST" ]]; then
|
|
||||||
MARK="✓"
|
|
||||||
else
|
|
||||||
MARK="✗"
|
|
||||||
ALL_OK=0
|
|
||||||
fi
|
|
||||||
printf "%-24s %12s %12s %6s\n" "$T" "$SRC" "$DST" "$MARK"
|
|
||||||
done
|
|
||||||
echo "=========================================="
|
|
||||||
|
|
||||||
if [[ "$ALL_OK" == "1" ]]; then
|
|
||||||
echo ""
|
|
||||||
echo "结果:全部表行数一致,备份可恢复。"
|
|
||||||
if [[ "${1:-}" == "--drop-exec" ]]; then
|
|
||||||
docker exec "$CONTAINER" sh -c \
|
|
||||||
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$DRILL_DB\""
|
|
||||||
echo "本轮临时库 $DRILL_DB 已删除(历史遗留亦在演练前清理)。"
|
|
||||||
else
|
|
||||||
echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec,"
|
|
||||||
echo "会先清掉包括它在内的全部演练库再演练,本轮完成后同样即删。"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "结果:存在不一致,请检查备份文件与数据库结构。" >&2
|
|
||||||
echo "临时库 $DRILL_DB 保留供排查。" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|||||||
51
scripts/windows/Install-PayrollBackupTasks.ps1
Normal file
51
scripts/windows/Install-PayrollBackupTasks.ps1
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$PythonPath,
|
||||||
|
[Parameter(Mandatory = $true)][string]$BackupDirectory,
|
||||||
|
[string]$ProjectRoot,
|
||||||
|
[string]$Container = 'ys-mysql-dev',
|
||||||
|
[string]$Database = 'payroll',
|
||||||
|
[switch]$Apply
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if (-not $ProjectRoot) {
|
||||||
|
$ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||||
|
}
|
||||||
|
$project = [IO.Path]::GetFullPath($ProjectRoot)
|
||||||
|
$python = [IO.Path]::GetFullPath($PythonPath)
|
||||||
|
$backups = [IO.Path]::GetFullPath($BackupDirectory)
|
||||||
|
$script = Join-Path $project 'scripts\payroll_backup.py'
|
||||||
|
if (-not (Test-Path -LiteralPath $python -PathType Leaf) -or -not (Test-Path -LiteralPath $script -PathType Leaf)) {
|
||||||
|
throw 'Python 或备份程序不存在。'
|
||||||
|
}
|
||||||
|
if ($Container -notmatch '^[A-Za-z0-9_.-]+$' -or $Database -notmatch '^[A-Za-z][A-Za-z0-9_]{0,63}$') { throw '容器或数据库标识无效。' }
|
||||||
|
if (@($python, $script, $backups) | Where-Object { $_.Contains('"') }) { throw '路径不能含双引号。' }
|
||||||
|
$taskPath = '\PayrollManagementSystem-Backup\'
|
||||||
|
$common = ' --directory "' + $backups + '" --container ' + $Container + ' --database ' + $Database
|
||||||
|
$definitions = @(
|
||||||
|
@{ Name = 'DailyFull'; Action = 'full'; Time = '02:30' },
|
||||||
|
@{ Name = 'ChangedIncremental'; Action = 'incremental'; Time = '00:10' },
|
||||||
|
@{ Name = 'BackupCheck'; Action = 'check'; Time = '07:30' }
|
||||||
|
)
|
||||||
|
foreach ($definition in $definitions) {
|
||||||
|
$arguments = '"' + $script + '" ' + $definition.Action + $common
|
||||||
|
[pscustomobject]@{ TaskPath = $taskPath; Name = $definition.Name; Python = $python; Arguments = $arguments; Enabled = [bool]$Apply }
|
||||||
|
if (-not $Apply) { continue }
|
||||||
|
$existing = Get-ScheduledTask -TaskPath $taskPath -TaskName $definition.Name -ErrorAction SilentlyContinue
|
||||||
|
if ($existing -and (@($existing.Actions).Count -ne 1 -or $existing.Actions[0].Execute -ne $python -or $existing.Actions[0].Arguments -ne $arguments)) {
|
||||||
|
throw '同名任务已有不同动作,拒绝覆盖。'
|
||||||
|
}
|
||||||
|
if ($PSCmdlet.ShouldProcess($definition.Name, '注册当前用户本地开发备份任务')) {
|
||||||
|
$scheduler = New-Object -ComObject 'Schedule.Service'
|
||||||
|
$scheduler.Connect()
|
||||||
|
try { $null = $scheduler.GetFolder($taskPath) }
|
||||||
|
catch { $null = $scheduler.GetFolder('\').CreateFolder('PayrollManagementSystem-Backup') }
|
||||||
|
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $project
|
||||||
|
$trigger = if ($definition.Action -eq 'incremental') {
|
||||||
|
New-ScheduledTaskTrigger -Once -At ((Get-Date).Date.AddMinutes(10)) -RepetitionInterval (New-TimeSpan -Minutes 15)
|
||||||
|
} else { New-ScheduledTaskTrigger -Daily -At $definition.Time }
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -RestartCount 2 -RestartInterval (New-TimeSpan -Minutes 5)
|
||||||
|
Register-ScheduledTask -TaskPath $taskPath -TaskName $definition.Name -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user