Regalamiunsorriso/sync/promote-sync.sh

614 lines
13 KiB
Bash
Raw Normal View History

2026-04-07 18:02:17 +02:00
#!/bin/sh
set -eu
IFS='
'
SCRIPT_NAME=$(basename "$0")
STAGE_ROOT=${SYNC_STAGE_ROOT:-/home/marco/regalamiunsorriso}
LIVE_ROOT=${SYNC_LIVE_ROOT:-/home/sites/regalamiunsorriso}
STAGE_USER=${SYNC_STAGE_USER:-marco}
INCOMING_ROOT=${SYNC_INCOMING_ROOT:-$STAGE_ROOT/incoming}
LOG_FILE=${SYNC_LOG_FILE:-$STAGE_ROOT/logs/promote-sync.log}
PERMS_DB=${SYNC_PERMS_DB:-$STAGE_ROOT/metadata/perms.jsonl}
MODE=
PROMOTE_EXECUTE=0
INIT_EXECUTE=0
ONLY_SCOPE=
RESOLVED_LIVE_ROOT=
UPDATED_COUNT=0
SKIPPED_COUNT=0
FAILED_COUNT=0
usage() {
cat <<EOF
Usage:
$SCRIPT_NAME --dry-run [--only <scope>] [--perms-db <path>] [--log <path>]
$SCRIPT_NAME --apply [--only <scope>] [--perms-db <path>] [--log <path>]
$SCRIPT_NAME --init-sync [--only <scope>] [--perms-db <path>] [--log <path>]
$SCRIPT_NAME --init-sync --apply-init [--only <scope>] [--perms-db <path>] [--log <path>]
Modes:
--dry-run Preview staged file promotion into the live tree.
--apply Promote staged files into the live tree.
--init-sync Preview initial copy from live tree into staging.
--apply-init Execute the initial copy when used with --init-sync.
Options:
--only <scope> Limit processing to one scope: www, rus, WEB-INF/classes.
--perms-db <path> Metadata database location. Default: $PERMS_DB
--log <path> Log file location. Default: $LOG_FILE
--stage-root <path> Override staging root. Default: $STAGE_ROOT
--live-root <path> Override live root. Default: $LIVE_ROOT
--stage-user <user> Override staging owner. Default: $STAGE_USER
--help Show this help.
Environment overrides:
SYNC_STAGE_ROOT, SYNC_LIVE_ROOT, SYNC_STAGE_USER, SYNC_INCOMING_ROOT,
SYNC_LOG_FILE, SYNC_PERMS_DB
EOF
}
timestamp_utc() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
ensure_log_dir() {
mkdir -p "$(dirname -- "$LOG_FILE")"
}
log_line() {
ensure_log_dir
printf '%s\n' "$1" | tee -a "$LOG_FILE"
}
stat_owner() {
if stat -f '%Su' "$1" >/dev/null 2>&1; then
stat -f '%Su' "$1"
else
stat -c '%U' "$1"
fi
}
stat_group() {
if stat -f '%Sg' "$1" >/dev/null 2>&1; then
stat -f '%Sg' "$1"
else
stat -c '%G' "$1"
fi
}
stat_mode() {
_mode=
if stat -f '%Lp' "$1" >/dev/null 2>&1; then
_mode=$(stat -f '%Lp' "$1")
else
_mode=$(stat -c '%a' "$1")
fi
printf '%04d\n' "$_mode"
}
resolve_existing_path() {
if command -v realpath >/dev/null 2>&1; then
realpath "$1"
return
fi
_dir_path=$(dirname -- "$1")
_base_name=$(basename -- "$1")
(
cd "$_dir_path"
printf '%s/%s\n' "$(pwd -P)" "$_base_name"
)
}
is_under_root() {
case "$1" in
"$2"|"$2"/*)
return 0
;;
*)
return 1
;;
esac
}
require_root() {
if [ "$(id -u)" -ne 0 ]; then
fail "this mode must run under sudo or as root"
fi
}
is_allowed_scope() {
case "$1" in
www|rus|WEB-INF/classes)
return 0
;;
*)
return 1
;;
esac
}
run_for_selected_scopes() {
_callback=$1
if [ -n "$ONLY_SCOPE" ]; then
"$_callback" "$ONLY_SCOPE"
return
fi
"$_callback" "www"
"$_callback" "rus"
"$_callback" "WEB-INF/classes"
}
is_excluded_relpath() {
case "$1" in
www/_img|www/_img/*|www/_news|www/_news/*|www/_tmp|www/_tmp/*|www/mypics|www/mypics/*|www/mypics-archivio|www/mypics-archivio/*|www/mypics2|www/mypics2/*)
return 0
;;
*)
return 1
;;
esac
}
is_safe_relpath() {
case "$1" in
''|/*|../*|*/../*|*/..|*//*)
return 1
;;
esac
case "$1" in
*/../*|*/..)
return 1
;;
*)
return 0
;;
esac
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
sha256_file() {
if command -v sha256 >/dev/null 2>&1; then
sha256 -q "$1"
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
printf ''
fi
}
db_backup_existing() {
_metadata_dir=$(dirname -- "$PERMS_DB")
mkdir -p "$_metadata_dir"
if [ -f "$PERMS_DB" ]; then
_backup_path="$PERMS_DB.$(date -u +%Y%m%dT%H%M%SZ).bak"
cp "$PERMS_DB" "$_backup_path"
log_line "$(timestamp_utc) INFO perms-db-backup path=$_backup_path"
fi
}
db_append_record() {
printf '{"relpath":"%s","owner":"%s","group":"%s","mode":"%s","type":"%s","sha256":"%s"}\n' \
"$(json_escape "$1")" \
"$(json_escape "$2")" \
"$(json_escape "$3")" \
"$(json_escape "$4")" \
"$(json_escape "$5")" \
"$(json_escape "${6:-}")" >> "$PERMS_DB"
}
db_lookup_line() {
awk -v target="\"relpath\":\"$1\"" '
index($0, target) {
print
found = 1
exit
}
END {
if (!found) {
exit 1
}
}
' "$PERMS_DB"
}
db_extract_field() {
printf '%s\n' "$1" | awk -F'"' -v lookup_key="$2" '
{
for (i = 2; i < NF; i += 4) {
if ($i == lookup_key) {
print $(i + 2)
exit
}
}
}
'
}
user_exists() {
id "$1" >/dev/null 2>&1
}
group_exists() {
if command -v getent >/dev/null 2>&1; then
getent group "$1" >/dev/null 2>&1
return
fi
if command -v pw >/dev/null 2>&1; then
pw groupshow "$1" >/dev/null 2>&1
return
fi
return 0
}
record_live_metadata() {
_live_path=$1
_relpath=$2
_entry_type=$3
_owner=$(stat_owner "$_live_path")
_group=$(stat_group "$_live_path")
_mode=$(stat_mode "$_live_path")
_checksum=
if [ "$_entry_type" = "file" ]; then
_checksum=$(sha256_file "$_live_path")
fi
db_append_record "$_relpath" "$_owner" "$_group" "$_mode" "$_entry_type" "$_checksum"
}
log_skip() {
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
log_line "$(timestamp_utc) SKIP $1 reason=$2"
}
log_failure() {
FAILED_COUNT=$((FAILED_COUNT + 1))
log_line "$(timestamp_utc) FAIL $1 reason=$2"
}
log_update() {
UPDATED_COUNT=$((UPDATED_COUNT + 1))
log_line "$(timestamp_utc) $1 $2 -> $3 OK"
}
validate_path_policy() {
if ! is_safe_relpath "$1"; then
return 1
fi
if is_excluded_relpath "$1"; then
return 1
fi
case "$1" in
RUS|RUS/*)
return 1
;;
*)
return 0
;;
esac
}
ensure_parent_directory_exists() {
[ -d "$(dirname -- "$1")" ]
}
copy_live_entry_to_stage() {
_live_path=$1
_relpath=$2
_stage_path=$3
_entry_type=$4
if [ "$_entry_type" = "dir" ]; then
mkdir -p "$_stage_path"
chown "$STAGE_USER" "$_stage_path"
chmod 0755 "$_stage_path"
log_update "INIT-DIR" "$_relpath" "$_stage_path"
return
fi
mkdir -p "$(dirname -- "$_stage_path")"
cp "$_live_path" "$_stage_path"
chown "$STAGE_USER" "$_stage_path"
chmod 0644 "$_stage_path"
log_update "INIT" "$_relpath" "$_stage_path"
}
run_init_scope() {
_scope=$1
_live_scope_root="$LIVE_ROOT/$_scope"
_stage_scope_root="$INCOMING_ROOT/$_scope"
if [ ! -e "$_live_scope_root" ]; then
log_skip "$_scope" "live-scope-missing"
return
fi
_entry_list=$(mktemp "${TMPDIR:-/tmp}/promote-sync.init.XXXXXX")
find "$_live_scope_root" \( -type d -o -type f -o -type l \) > "$_entry_list"
while IFS= read -r _entry || [ -n "$_entry" ]; do
_relpath=${_entry#"$LIVE_ROOT/"}
if ! validate_path_policy "$_relpath"; then
log_skip "$_relpath" "excluded-or-unsafe"
continue
fi
if [ -L "$_entry" ]; then
log_skip "$_relpath" "symlink-excluded"
continue
fi
if ! is_under_root "$(resolve_existing_path "$_entry")" "$RESOLVED_LIVE_ROOT"; then
log_failure "$_relpath" "outside-live-root"
continue
fi
_stage_path="$INCOMING_ROOT/$_relpath"
if [ -d "$_entry" ]; then
if [ "$INIT_EXECUTE" -eq 1 ]; then
record_live_metadata "$_entry" "$_relpath" "dir"
copy_live_entry_to_stage "$_entry" "$_relpath" "$_stage_path" "dir"
else
log_line "$(timestamp_utc) INIT-DRY-RUN $_relpath -> $_stage_path"
fi
continue
fi
if [ "$INIT_EXECUTE" -eq 1 ]; then
record_live_metadata "$_entry" "$_relpath" "file"
copy_live_entry_to_stage "$_entry" "$_relpath" "$_stage_path" "file"
else
log_line "$(timestamp_utc) INIT-DRY-RUN $_relpath -> $_stage_path"
fi
done < "$_entry_list"
rm -f "$_entry_list"
if [ "$INIT_EXECUTE" -eq 1 ]; then
mkdir -p "$_stage_scope_root"
chown "$STAGE_USER" "$_stage_scope_root"
chmod 0755 "$_stage_scope_root"
fi
}
run_init_sync() {
if [ "$INIT_EXECUTE" -eq 1 ]; then
require_root
fi
RESOLVED_LIVE_ROOT=$(resolve_existing_path "$LIVE_ROOT")
if [ "$INIT_EXECUTE" -eq 1 ]; then
mkdir -p "$INCOMING_ROOT"
db_backup_existing
: > "$PERMS_DB"
fi
run_for_selected_scopes run_init_scope
}
apply_metadata_to_temp() {
if ! user_exists "$2"; then
fail "owner '$2' from permissions database does not exist"
fi
if ! group_exists "$3"; then
fail "group '$3' from permissions database does not exist"
fi
chown "$2:$3" "$1"
chmod "$4" "$1"
}
promote_staged_file() {
_stage_file=$1
_relpath=$2
_dest_file=$3
_resolved_dest=$(resolve_existing_path "$_dest_file")
if ! is_under_root "$_resolved_dest" "$RESOLVED_LIVE_ROOT"; then
log_failure "$_relpath" "outside-live-root"
return
fi
if [ ! -f "$PERMS_DB" ]; then
log_failure "$_relpath" "perms-db-missing"
return
fi
if ! _db_line=$(db_lookup_line "$_relpath"); then
log_skip "$_relpath" "destination-missing-perms"
return
fi
_owner=$(db_extract_field "$_db_line" "owner")
_group=$(db_extract_field "$_db_line" "group")
_mode=$(db_extract_field "$_db_line" "mode")
if [ "$PROMOTE_EXECUTE" -eq 0 ]; then
log_line "$(timestamp_utc) DRY-RUN $_relpath -> $_dest_file owner=$_owner group=$_group mode=$_mode"
return
fi
_tmp_file=$(mktemp "$(dirname -- "$_dest_file")/.promote-sync.XXXXXX")
cp "$_stage_file" "$_tmp_file"
apply_metadata_to_temp "$_tmp_file" "$_owner" "$_group" "$_mode"
mv -f "$_tmp_file" "$_dest_file"
log_update "APPLY" "$_relpath" "$_dest_file"
}
run_promote_scope() {
_scope=$1
_stage_scope_root="$INCOMING_ROOT/$_scope"
if [ ! -d "$_stage_scope_root" ]; then
log_skip "$_scope" "staging-scope-missing"
return
fi
_entry_list=$(mktemp "${TMPDIR:-/tmp}/promote-sync.promote.XXXXXX")
find "$_stage_scope_root" \( -type f -o -type l \) > "$_entry_list"
while IFS= read -r _entry || [ -n "$_entry" ]; do
_relpath=${_entry#"$INCOMING_ROOT/"}
if ! validate_path_policy "$_relpath"; then
log_skip "$_relpath" "excluded-or-unsafe"
continue
fi
if [ -L "$_entry" ]; then
log_skip "$_relpath" "symlink-excluded"
continue
fi
_dest_file="$LIVE_ROOT/$_relpath"
if [ ! -e "$_dest_file" ]; then
log_skip "$_relpath" "destination-missing"
continue
fi
if ! ensure_parent_directory_exists "$_dest_file"; then
log_skip "$_relpath" "destination-parent-missing"
continue
fi
promote_staged_file "$_entry" "$_relpath" "$_dest_file"
done < "$_entry_list"
rm -f "$_entry_list"
}
run_promote() {
if [ "$PROMOTE_EXECUTE" -eq 1 ]; then
require_root
fi
RESOLVED_LIVE_ROOT=$(resolve_existing_path "$LIVE_ROOT")
run_for_selected_scopes run_promote_scope
}
print_summary() {
log_line "$(timestamp_utc) SUMMARY updated=$UPDATED_COUNT skipped=$SKIPPED_COUNT failed=$FAILED_COUNT mode=${MODE:-unknown}"
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
[ -z "$MODE" ] || fail "choose only one primary mode"
MODE=promote
PROMOTE_EXECUTE=0
;;
--apply)
[ -z "$MODE" ] || fail "choose only one primary mode"
MODE=promote
PROMOTE_EXECUTE=1
;;
--init-sync)
[ -z "$MODE" ] || fail "choose only one primary mode"
MODE=init
;;
--apply-init)
INIT_EXECUTE=1
;;
--only)
shift
[ "$#" -gt 0 ] || fail "--only requires a scope"
ONLY_SCOPE=$1
is_allowed_scope "$ONLY_SCOPE" || fail "unsupported scope '$ONLY_SCOPE'"
;;
--perms-db)
shift
[ "$#" -gt 0 ] || fail "--perms-db requires a path"
PERMS_DB=$1
;;
--log)
shift
[ "$#" -gt 0 ] || fail "--log requires a path"
LOG_FILE=$1
;;
--stage-root)
shift
[ "$#" -gt 0 ] || fail "--stage-root requires a path"
STAGE_ROOT=$1
INCOMING_ROOT=$STAGE_ROOT/incoming
;;
--live-root)
shift
[ "$#" -gt 0 ] || fail "--live-root requires a path"
LIVE_ROOT=$1
;;
--stage-user)
shift
[ "$#" -gt 0 ] || fail "--stage-user requires a user"
STAGE_USER=$1
;;
--help|-h)
usage
exit 0
;;
*)
fail "unknown argument: $1"
;;
esac
shift
done
}
main() {
parse_args "$@"
[ -n "$MODE" ] || fail "choose one of --dry-run, --apply, or --init-sync"
if [ "$INIT_EXECUTE" -eq 1 ] && [ "$MODE" != "init" ]; then
fail "--apply-init requires --init-sync"
fi
if [ "$MODE" = "init" ] && [ "$INIT_EXECUTE" -eq 0 ]; then
log_line "$(timestamp_utc) INFO init-sync-preview only; add --apply-init to write staging files and permissions db"
fi
case "$MODE" in
init)
run_init_sync
;;
promote)
run_promote
;;
*)
fail "unsupported mode '$MODE'"
;;
esac
print_summary
}
main "$@"