235 行
8.6 KiB
Bash
可执行文件
235 行
8.6 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
|
|
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
|
|
COMPOSE_FILE="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}"
|
|
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-}"
|
|
POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}"
|
|
STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}"
|
|
BUILD_SHA="${BUILD_SHA:-}"
|
|
VITE_BUILD_SHA="${VITE_BUILD_SHA:-}"
|
|
AUTOMATIC_BACKUP_DIR="${AUTOMATIC_BACKUP_DIR:-}"
|
|
AUTOMATIC_BACKUP_STATE_DIR="${AUTOMATIC_BACKUP_STATE_DIR:-}"
|
|
OFFHOST_TARGET="${OFFHOST_TARGET:-}"
|
|
OFFHOST_SSH_KEY="${OFFHOST_SSH_KEY:-}"
|
|
OFFHOST_KNOWN_HOSTS="${OFFHOST_KNOWN_HOSTS:-}"
|
|
ALERT_WEBHOOK_FILE="${ALERT_WEBHOOK_FILE:-}"
|
|
ALERT_HELPER="${ALERT_HELPER:-${REMOTE_DIR}/scripts/send-wecom-group-alert.py}"
|
|
BACKUP_RUN_AT="${BACKUP_RUN_AT:-02:30}"
|
|
WATCHDOG_RUN_AT="${WATCHDOG_RUN_AT:-07:10}"
|
|
SERVICE_PREFIX="${SERVICE_PREFIX:-hyb-oa-automatic-backup}"
|
|
MODE="${1:-install}"
|
|
|
|
RUNNER="${REMOTE_DIR}/scripts/run-automatic-backup.sh"
|
|
SYSTEMD_DIR="/etc/systemd/system"
|
|
|
|
fail() {
|
|
echo "[automatic-backup-installer][ERROR] $1" >&2
|
|
exit 1
|
|
}
|
|
|
|
need_root() {
|
|
[[ "$(id -u)" == "0" ]] || fail "root privileges are required"
|
|
}
|
|
|
|
validate_absolute_path() {
|
|
local name="$1"
|
|
local value="$2"
|
|
if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
|
|
fail "$name must be an explicit safe absolute path"
|
|
fi
|
|
if [[ "$value" == "/" || "$value" == */ || "$value" == *"//"* \
|
|
|| "/$value/" == *"/../"* || "/$value/" == *"/./"* ]]; then
|
|
fail "$name contains an unsafe path segment"
|
|
fi
|
|
}
|
|
|
|
validate_managed_directory_path() {
|
|
local name="$1"
|
|
local value="$2"
|
|
validate_absolute_path "$name" "$value"
|
|
[[ "$value" =~ ^/[^/]+/[^/]+ ]] \
|
|
|| fail "$name must be a dedicated path below a top-level directory"
|
|
case "$value" in
|
|
/bin/*|/boot/*|/dev/*|/etc/*|/lib/*|/lib64/*|/proc/*|/run/*|/sbin/*|/sys/*|/usr/*)
|
|
fail "$name must not be inside a system-managed directory"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
paths_overlap() {
|
|
local first="$1"
|
|
local second="$2"
|
|
[[ "$first" == "$second" || "$first" == "$second"/* || "$second" == "$first"/* ]]
|
|
}
|
|
|
|
validate_inputs() {
|
|
local name value
|
|
for name in REMOTE_DIR REMOTE_ENV_FILE POSTGRES_DATA_DIR STORAGE_HOST_DIR \
|
|
AUTOMATIC_BACKUP_DIR AUTOMATIC_BACKUP_STATE_DIR OFFHOST_SSH_KEY OFFHOST_KNOWN_HOSTS \
|
|
ALERT_WEBHOOK_FILE ALERT_HELPER; do
|
|
value="${!name:-}"
|
|
validate_absolute_path "$name" "$value"
|
|
done
|
|
validate_managed_directory_path AUTOMATIC_BACKUP_DIR "$AUTOMATIC_BACKUP_DIR"
|
|
validate_managed_directory_path AUTOMATIC_BACKUP_STATE_DIR "$AUTOMATIC_BACKUP_STATE_DIR"
|
|
[[ -d "$REMOTE_DIR" && -f "$REMOTE_ENV_FILE" && -x "$RUNNER" ]] \
|
|
|| fail "deployment root, env file, or backup runner is missing"
|
|
[[ -f "$POSTGRES_DATA_DIR/PG_VERSION" && -d "$STORAGE_HOST_DIR" ]] \
|
|
|| fail "active data directories are invalid"
|
|
[[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "Compose project name is invalid"
|
|
[[ "$COMPOSE_FILE" =~ ^[A-Za-z0-9._/-]+$ && "$COMPOSE_FILE" != /* \
|
|
&& "/$COMPOSE_FILE/" != *"/../"* && "/$COMPOSE_FILE/" != *"/./"* ]] \
|
|
|| fail "Compose file must be a safe relative path"
|
|
[[ "$BUILD_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail "full build SHA is invalid"
|
|
BUILD_SHA="${BUILD_SHA,,}"
|
|
VITE_BUILD_SHA="${VITE_BUILD_SHA,,}"
|
|
[[ "$VITE_BUILD_SHA" =~ ^[0-9a-f]{12}$ && "${BUILD_SHA:0:12}" == "$VITE_BUILD_SHA" ]] \
|
|
|| fail "frontend build SHA must match the full build prefix"
|
|
[[ "$OFFHOST_TARGET" =~ ^[a-z_][a-z0-9_-]*@[A-Za-z0-9._-]+$ ]] \
|
|
|| fail "off-host target must use a safe user@alias value"
|
|
[[ -f "$OFFHOST_SSH_KEY" && ! -L "$OFFHOST_SSH_KEY" \
|
|
&& "$(stat -c %a "$OFFHOST_SSH_KEY")" == "600" ]] \
|
|
|| fail "off-host private key must exist with mode 0600"
|
|
[[ -s "$OFFHOST_KNOWN_HOSTS" ]] || fail "pinned known-hosts file is missing"
|
|
[[ -f "$ALERT_WEBHOOK_FILE" && ! -L "$ALERT_WEBHOOK_FILE" \
|
|
&& "$(stat -c %a "$ALERT_WEBHOOK_FILE")" == "600" \
|
|
&& "$(stat -c %u "$ALERT_WEBHOOK_FILE")" == "0" ]] \
|
|
|| fail "alert webhook file must be root-owned with mode 0600"
|
|
[[ -f "$ALERT_HELPER" && ! -L "$ALERT_HELPER" ]] || fail "alert helper is missing"
|
|
[[ "$BACKUP_RUN_AT" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || fail "backup time is invalid"
|
|
[[ "$WATCHDOG_RUN_AT" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || fail "watchdog time is invalid"
|
|
[[ "$SERVICE_PREFIX" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "service prefix is invalid"
|
|
local -a isolated_paths=(
|
|
"$POSTGRES_DATA_DIR" "$STORAGE_HOST_DIR" "$AUTOMATIC_BACKUP_DIR" "$AUTOMATIC_BACKUP_STATE_DIR"
|
|
)
|
|
local first_index second_index
|
|
for ((first_index = 0; first_index < ${#isolated_paths[@]}; first_index += 1)); do
|
|
for ((second_index = first_index + 1; second_index < ${#isolated_paths[@]}; second_index += 1)); do
|
|
if paths_overlap "${isolated_paths[$first_index]}" "${isolated_paths[$second_index]}"; then
|
|
fail "data, backup, and state directories must be independent"
|
|
fi
|
|
done
|
|
done
|
|
}
|
|
|
|
environment_lines() {
|
|
cat <<ENVIRONMENT
|
|
Environment=REMOTE_DIR=${REMOTE_DIR}
|
|
Environment=REMOTE_ENV_FILE=${REMOTE_ENV_FILE}
|
|
Environment=COMPOSE_FILE=${COMPOSE_FILE}
|
|
Environment=COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
|
|
Environment=POSTGRES_DATA_DIR=${POSTGRES_DATA_DIR}
|
|
Environment=STORAGE_HOST_DIR=${STORAGE_HOST_DIR}
|
|
Environment=BUILD_SHA=${BUILD_SHA}
|
|
Environment=VITE_BUILD_SHA=${VITE_BUILD_SHA}
|
|
Environment=AUTOMATIC_BACKUP_DIR=${AUTOMATIC_BACKUP_DIR}
|
|
Environment=AUTOMATIC_BACKUP_STATE_DIR=${AUTOMATIC_BACKUP_STATE_DIR}
|
|
Environment=OFFHOST_TARGET=${OFFHOST_TARGET}
|
|
Environment=OFFHOST_SSH_KEY=${OFFHOST_SSH_KEY}
|
|
Environment=OFFHOST_KNOWN_HOSTS=${OFFHOST_KNOWN_HOSTS}
|
|
Environment=ALERT_WEBHOOK_FILE=${ALERT_WEBHOOK_FILE}
|
|
Environment=ALERT_HELPER=${ALERT_HELPER}
|
|
ENVIRONMENT
|
|
}
|
|
|
|
write_service() {
|
|
local suffix="$1"
|
|
local mode="$2"
|
|
local description="$3"
|
|
local service_file="$SYSTEMD_DIR/${SERVICE_PREFIX}-${suffix}.service"
|
|
cat >"$service_file" <<SERVICE
|
|
[Unit]
|
|
Description=${description}
|
|
After=docker.service network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
WorkingDirectory=${REMOTE_DIR}
|
|
UMask=0077
|
|
$(environment_lines)
|
|
ExecStart=/bin/bash ${RUNNER} ${mode}
|
|
SERVICE
|
|
}
|
|
|
|
write_timer() {
|
|
local suffix="$1"
|
|
local calendar="$2"
|
|
local description="$3"
|
|
local timer_file="$SYSTEMD_DIR/${SERVICE_PREFIX}-${suffix}.timer"
|
|
cat >"$timer_file" <<TIMER
|
|
[Unit]
|
|
Description=${description}
|
|
|
|
[Timer]
|
|
OnCalendar=${calendar}
|
|
Persistent=true
|
|
Unit=${SERVICE_PREFIX}-${suffix}.service
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
TIMER
|
|
}
|
|
|
|
install_units() {
|
|
need_root
|
|
validate_inputs
|
|
mkdir -p "$AUTOMATIC_BACKUP_DIR" "$AUTOMATIC_BACKUP_STATE_DIR"
|
|
[[ -d "$AUTOMATIC_BACKUP_DIR" && ! -L "$AUTOMATIC_BACKUP_DIR" \
|
|
&& -d "$AUTOMATIC_BACKUP_STATE_DIR" && ! -L "$AUTOMATIC_BACKUP_STATE_DIR" ]] \
|
|
|| fail "backup and state directories must not be symbolic links"
|
|
chmod 0700 "$AUTOMATIC_BACKUP_DIR" "$AUTOMATIC_BACKUP_STATE_DIR"
|
|
|
|
write_service daily run "hyb-oa automatic paired off-host backup"
|
|
write_service watchdog check-stale "hyb-oa automatic backup freshness watchdog"
|
|
write_service drill-reminder remind-drill "hyb-oa quarterly shadow restore reminder"
|
|
write_timer daily "*-*-* ${BACKUP_RUN_AT}:00" "Run hyb-oa automatic off-host backup daily"
|
|
write_timer watchdog "*-*-* ${WATCHDOG_RUN_AT}:00" "Check hyb-oa off-host backup freshness daily"
|
|
write_timer drill-reminder "*-01,04,07,10-01 09:00:00" "Remind quarterly hyb-oa shadow restore drill"
|
|
systemctl daemon-reload
|
|
echo "[automatic-backup-installer][PASS] units installed disabled"
|
|
}
|
|
|
|
enable_units() {
|
|
install_units
|
|
systemctl enable --now \
|
|
"${SERVICE_PREFIX}-daily.timer" \
|
|
"${SERVICE_PREFIX}-watchdog.timer" \
|
|
"${SERVICE_PREFIX}-drill-reminder.timer"
|
|
echo "[automatic-backup-installer][PASS] timers enabled"
|
|
}
|
|
|
|
uninstall_units() {
|
|
need_root
|
|
[[ "$SERVICE_PREFIX" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "service prefix is invalid"
|
|
local suffix
|
|
for suffix in daily watchdog drill-reminder; do
|
|
systemctl disable --now "${SERVICE_PREFIX}-${suffix}.timer" >/dev/null 2>&1 || true
|
|
systemctl stop "${SERVICE_PREFIX}-${suffix}.service" >/dev/null 2>&1 || true
|
|
rm -f -- "$SYSTEMD_DIR/${SERVICE_PREFIX}-${suffix}.timer" \
|
|
"$SYSTEMD_DIR/${SERVICE_PREFIX}-${suffix}.service"
|
|
done
|
|
systemctl daemon-reload
|
|
echo "[automatic-backup-installer][PASS] units removed; backups and credentials retained"
|
|
}
|
|
|
|
case "$MODE" in
|
|
install)
|
|
install_units
|
|
;;
|
|
enable)
|
|
enable_units
|
|
;;
|
|
uninstall|--uninstall)
|
|
uninstall_units
|
|
;;
|
|
--help|-h)
|
|
echo "Usage: <explicit deployment/data/backup/SSH env> bash install-automatic-backup-timer.sh [install|enable|uninstall]"
|
|
;;
|
|
*)
|
|
fail "unsupported mode"
|
|
;;
|
|
esac
|