614 lines
No EOL
13 KiB
Bash
614 lines
No EOL
13 KiB
Bash
#!/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 "$@" |