deployd/interpreter/deployer
Arija A. fca825f872
Improve deployer exports
Signed-off-by: Arija A. <ari@ari.lt>
2025-07-25 00:32:07 +03:00

707 lines
16 KiB
Bash
Executable file

#!/bin/sh
LOGPID=''
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export PATH
# Directory structure:
#
# /var/cache/deployd
# ... <did>/
# upstream/
# sources/
# build/
# build.live/ (temporary, while building)
# .lock (for locking)
# backup/
# sources/
# build/
# backup.bak/ (temporary, while backuping)
# deploy.log
#
# Flow:
# prepare() (get sources, set up the environment)
# build() (build the sources, minify them, etc)
# test() (test the built sources)
# cleanup() (clean up the current directory of any garbage)
# teardown() (tear down any previous resources (such as daemons))
# deploy() (set up any resources (such as daemons))
# sysadmin() (sysadmin tasks)
set -e
error() {
echo "$(date -u +'%Y-%m-%d %H:%M:%S') | Error: $*" >&2
}
info() {
echo "$(date -u +'%Y-%m-%d %H:%M:%S') | Info: $*" >&2
}
warn() {
echo "$(date -u +'%Y-%m-%d %H:%M:%S') | Warning: $*" >&2
}
abs_path() {
path="$1"
if [ -d "$path" ]; then
(cd "$path" 2>/dev/null && pwd) || return 1
else
dir="${path%/*}"
file="${path##*/}"
if [ "$dir" = "$path" ]; then
printf '%s/%s\n' "$(pwd)" "$path"
else
(cd "$dir" 2>/dev/null && printf '%s/%s\n' "$(pwd)" "$file") || return 1
fi
fi
}
parse_deploy_header_block() {
inside_block=0
DEPLOY_NAME=""
DEPLOY_EXTENSIONS=""
DEPLOY_USER=""
DEPLOY_GROUP=""
DEPLOY_TARGET=""
DEPLOY_COPY=""
if ! grep -q '\bprepare()' -- "$1"; then
error 'Deployer script does not include a prepare() function'
return 1;
fi
while IFS= read -r line; do
case "$line" in
'# -----'*)
if [ "$inside_block" -eq 0 ]; then
inside_block=1
else
break
fi
;;
'# '*)
if [ "$inside_block" -eq 1 ]; then
case "$line" in
\#\ Name:\ *)
DEPLOY_NAME="${line#\# Name: }"
;;
\#\ Extensions:\ *)
DEPLOY_EXTENSIONS="${line#\# Extensions: }"
;;
\#\ User:\ *)
DEPLOY_USER="${line#\# User: }"
;;
\#\ Group:\ *)
DEPLOY_GROUP="${line#\# Group: }"
;;
\#\ Target:\ *)
DEPLOY_TARGET="${line#\# Target: }"
;;
\#\ Copy:\ *)
DEPLOY_COPY="${line#\# Copy: }"
case "$DEPLOY_COPY" in
yes | Yes | YES | 1 | true | True | TRUE)
DEPLOY_COPY=1
;;
*)
DEPLOY_COPY=''
;;
esac
;;
esac
fi
;;
esac
done <"$1"
if [ "$inside_block" -eq 0 ]; then
error "No (valid) metadata block found in the deployer, please use the following template:"
cat <<EOF
# --------------------------------------
# Name: my epic deploy
# Extensions: ??? (optional)
# User: ???
# Group: ???
# Target: ??? (optional)
# Copy: yes
# --------------------------------------
EOF
return 1
fi
for var in DEPLOY_NAME DEPLOY_USER DEPLOY_GROUP; do
if [ -z "$var" ]; then
error "$var is not set"
return 1
fi
done
export DEPLOY_NAME DEPLOY_EXTENSIONS DEPLOY_USER DEPLOY_GROUP DEPLOY_TARGET DEPLOY_COPY
}
validate_directory_path() {
dir_path="$1"
var_name="$2"
# Check non-empty
if [ -z "$dir_path" ]; then
error "$var_name is empty"
return 1
fi
# Disallow root dir
if [ "$dir_path" = "/" ]; then
error "$var_name cannot be root directory (/)"
return 1
fi
# Disallow relative paths (must be absolute)
case "$dir_path" in
/*) ;; # absolute path, OK
*)
error "$var_name must be an absolute path"
return 1
;;
esac
if [ "$3" != '-L' ]; then
# Check if directory is symlink
if [ -L "$dir_path" ]; then
error "$var_name path is a symlink; refusing for safety"
return 1
fi
fi
# Remove leading slash, split by '/'
path_components="$(printf '%s' "$dir_path" | sed 's|^/||' | tr '/' '\n')"
# Allowed characters regex: a-zA-Z0-9_- only
for comp in $path_components; do
if ! printf '%s\n' "$comp" | grep -qE '^[a-zA-Z0-9_.-]+$'; then
error "$var_name contains invalid characters in path component '$comp'. Allowed: a-z, A-Z, 0-9, _, ., -"
return 1
fi
done
return 0
}
validate_user_and_group() {
user="$1"
group="$2"
if ! printf '%s\n' "$user" | grep -qE '^[a-zA-Z0-9_.-]+$'; then
error "User '$user' is invalid"
return 1
fi
if ! id -u "$user" >/dev/null 2>&1; then
error "User '$user' does not exist"
return 1
fi
if ! printf '%s\n' "$group" | grep -qE '^[a-zA-Z0-9_.-]+$'; then
error "Group '$group' is invalid"
return 1
fi
if ! getent group "$group" >/dev/null 2>&1; then
error "Group '$group' does not exist"
return 1
fi
return 0
}
print_usage() {
"$1" "Usage: $(basename -- "$2") <teardown|deploy|rollback|cleanup|restart|sysadmin|logs|help>"
"$1" " $(basename -- "$2") --global-cleanup [scripts directory] - clean up residuals"
"$1" " $(basename -- "$2") --unlock <teardown|deploy|rollback|cleanup|restart|sysadmin|logs|help> - force-unlock the deploy (such as in case of a deadlock)"
}
run_deploy() {
deployer="$1"
didpath="$2"
backup="$didpath/backup"
upstream="$didpath/upstream"
logfile="$didpath/deploy.log"
validate_directory_path "$deployer" deployer || return 1
validate_directory_path "$didpath" didpath || return 1
subcommand="$3"
case "$subcommand" in
'') ;;
teardown | deploy) ;;
rollback) ;;
cleanup) ;;
restart) ;;
sysadmin) ;;
*)
error "Unknown subcommand: $subcommand"
print_usage error "$deployer"
return 1
;;
esac
parse_deploy_header_block "$deployer"
validate_directory_path "$DEPLOY_TARGET" DEPLOY_TARGET -L || return 1
validate_user_and_group "$DEPLOY_USER" "$DEPLOY_GROUP" || return 1
setup="#!/bin/sh
set -e
PATH=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"
export PATH
# Just a random Safe and Sophie Germain prime number, because I love primes
DEPLOYER_ID=\"$(echo "${deployer}${DEPLOY_NAME}10397618657851825223" | sha256sum | cut -d ' ' -f 1)\"
export DEPLOYER_ID
build() { return 0; }
test() { return 0; }
teardown() { return 0; }
deploy() { return 0; }
cleanup() { return 0; }
restart() { return 0; }
sysadmin() { return 0; }
error() { echo \"[deploy] \$(date -u +'%Y-%m-%d %H:%M:%S') | Error: \$*\" >&2; }
info() { echo \"[deploy] \$(date -u +'%Y-%m-%d %H:%M:%S') | Info: \$*\" >&2; }
warn() { echo \"[deploy] \$(date -u +'%Y-%m-%d %H:%M:%S') | Warning: \$*\" >&2; }
prevsrc() {
mkdir -p -- '$upstream/sources'
if [ -d '$backup' ]; then
cp -a -- '$backup/sources/.' '$upstream/sources/.'
fi
}
{
$(cat "$deployer")
}
"
for ext in $DEPLOY_EXTENSIONS; do
ext_path="/usr/share/deployer/extensions/$ext"
if [ -f "$ext_path" ] && [ -r "$ext_path" ]; then
setup="$setup
# Extension: $ext
{
$(cat "$ext_path")
}
"
else
error "Extension $ext was not found"
return 1
fi
done
deploy="$setup
__deployd_run_step() {
info \"Running \$1()\"
if ! \"\$1\"; then
error \"\$1() failed!\"
exit 1
fi
}
__deployer_on_goodbye() {
info 'Goodbye!'
rm -rf -- '$upstream/sources' '$upstream/build.live'
exit 1
}
info 'Cleaning up upstream...'
rm -rf -- '$upstream/sources' '$upstream/build.live'
trap __deployer_on_goodbye INT TERM
if [ -d '$backup' ]; then
if [ ! -d '$upstream' ]; then
info 'Restoring from backup...'
cp -a -- '$backup' '$upstream'
fi
else
if [ -e '$DEPLOY_TARGET' ]; then
error 'Deploy target exists first-run'
exit 1
fi
mkdir -p -- '$upstream'
fi
cd -- '$upstream'
info \"Build name: \$DEPLOY_NAME\"
"
if [ "$subcommand" ]; then
deploy="$deploy
case '$subcommand' in
teardown | deploy)
cd -- '$upstream/build'
__deployd_run_step '$subcommand'
;;
rollback)
if [ ! -d '$backup' ]; then
error 'No backup to rollback to'
exit 1
fi
rm -rf -- '$upstream'
if ! cp -a -- '$backup' '$upstream'; then
error 'Restoring from backup failed.'
rm -rf -- '$upstream'
fi
;;
cleanup)
info 'Removing backups...'
rm -rf -- '$backup'
info 'Removing upstream live builds...'
rm -rf -- '$upstream/build.live'
if [ '$4' = '--unlink' ]; then
if [ '$DEPLOY_TARGET' ]; then
if [ '$DEPLOY_COPY' ]; then
info 'Removing upstream...'
rm -rf -- '$DEPLOY_TARGET'
else
if [ -L '$DEPLOY_TARGET' ]; then
info 'Unlinking upstream...'
unlink '$DEPLOY_TARGET'
elif [ -e '$DEPLOY_TARGET' ]; then
error \"$DEPLOY_TARGET exists and is not a symlink\"
exit 1
fi
fi
fi
fi
;;
restart)
cd -- '$upstream/build'
info 'Restarting the deploy...'
__deployd_run_step teardown
__deployd_run_step deploy
;;
sysadmin)
info 'Your sysadmin tasks will soon be ran...'
;;
*)
error 'Unknown subcommand passed to the deployer'
exit 1
;;
esac
"
else
deploy="$deploy
info 'Creating the sources directory'
mkdir -p sources build.live
# Step 1: prepare
cd -- '$upstream/sources'
__deployd_run_step prepare
info 'Copying sources to build'
cd -- '$upstream'
cp -a -- '$upstream/sources/.' '$upstream/build.live/.'
cd -- '$upstream/build.live'
# Step 2: build
cd -- '$upstream/build.live'
__deployd_run_step build
# Step 3: test
cd -- '$upstream/build.live'
__deployd_run_step test
# Step 4: cleanup
cd -- '$upstream/build.live'
__deployd_run_step cleanup
# Step 5: teardown
cd -- '$upstream/build.live'
__deployd_run_step teardown
# Step 5.5: Link
rm -rf -- '$upstream/build'
mv -- '$upstream/build.live' '$upstream/build'
if [ '$DEPLOY_TARGET' ]; then
if [ '$DEPLOY_COPY' ]; then
rm -rf -- '$DEPLOY_TARGET'
cp -a -- '$upstream/build' '$DEPLOY_TARGET'
else
if [ -L '$DEPLOY_TARGET' ]; then
ln -sfn '$upstream/build' '$DEPLOY_TARGET'
elif [ -e '$DEPLOY_TARGET' ]; then
error \"$DEPLOY_TARGET exists and is not a symlink\"
exit 1
else
ln -s '$upstream/build' '$DEPLOY_TARGET'
fi
fi
# Step 6: deploy
cd -- '$DEPLOY_TARGET'
__deployd_run_step deploy
else
# Step 6: deploy
cd -- '$upstream/build'
__deployd_run_step deploy
fi
info 'Build deployed'
"
fi
if ! chown "${DEPLOY_USER}:${DEPLOY_GROUP}" -R "$didpath"; then
error 'Failed to chown working directory'
return 1
fi
if ! deployd-su "$DEPLOY_USER" "$DEPLOY_GROUP" "$deploy"; then
error 'Failed to deployd-su.'
return 1
fi
if ! /bin/sh -c "$setup
info 'Entering root for sysadmin tasks...'
__deployd_run_step sysadmin
"; then
error "Sysadmin command failed"
return 1
fi
if [ ! "$subcommand" ]; then
info 'Backing up the upstream'
if [ -d "$backup" ]; then
cp -a "$backup" "${backup}.tmp"
fi
rm -rf -- "$backup"
if ! cp -a -- "$upstream" "$backup"; then
rm -rf -- "$backup"
if [ -d "${backup}.tmp" ]; then
mv -- "${backup}.tmp" "$backup"
fi
return 1
fi
rm -rf -- "${backup}.tmp"
fi
if ! chown "${DEPLOY_USER}:${DEPLOY_GROUP}" -R "$didpath"; then
error 'Failed to chown working directory'
return 1
fi
}
logs_cleanup() {
if [ "$LOGPID" ]; then
if ! kill "$LOGPID" 2>/dev/null; then
info "$1"
fi
for _ in 1 2 3 4 5; do
if ! kill -0 "$LOGPID" 2>/dev/null; then
break
fi
sleep 0.2
done
if kill -0 "$LOGPID" 2>/dev/null; then
kill -9 "$LOGPID" || true
fi
fi
if [ -z "$1" ]; then
info 'Closed logs.'
exit 1
else
info "Closed logs. $1"
fi
}
acquire_lock() {
lockdir="$1"
count=0
while ! mkdir "$lockdir" 2>/dev/null; do
count="$((count + 1))"
if [ "$count" -ge 60 ]; then
error "Failed to acquire lock $lockdir after 60 seconds"
return 1
fi
info "Waiting for $lockdir..."
sleep 1
done
return 0
}
release_lock() {
lockdir="$1"
rmdir "$lockdir" 2>/dev/null
}
main() {
if [ "$(id -u)" != 0 ]; then
error 'Deployer must be ran as root'
exit 1
fi
basedir='/var/cache/deployd'
if [ "$1" = '--global-cleanup' ]; then
if [ ! -d "$2" ]; then
error 'Please specify a directory to --global-cleanup which has all the deployer scripts'
exit 1
fi
dids=''
for script in "$2"/*.deployer; do
deployer="$(abs_path "$script")"
did="$(echo "$deployer" | sha256sum | cut -d ' ' -f 1)"
didpath="$basedir/$did"
if [ ! -d "$didpath" ]; then
warn "Deploy ID path for script $script does not exist"
continue
fi
dids="${dids}:${did};"
done
if [ -z "$dids" ]; then
error 'No deployers found'
exit 1
fi
for did in "$basedir"/*/; do
lockdir="$did/.lock"
acquire_lock "$lockdir"
if echo "$dids" | grep -q ":$(basename "$did");"; then
info "$did OK"
else
info "Removing pointless $did (no script)"
rm -rf -- "$did"
fi
release_lock "$lockdir"
done
info 'Cleanup done'
exit $?
fi
if [ ! -f "$1" ]; then
error "deployer: '$1': No such file or directory"
exit 1
fi
deployer="$(abs_path "$1")"
shift
did="$(echo "$deployer" | sha256sum | cut -d ' ' -f 1)"
didpath="$basedir/$did"
backup="$didpath/backup"
upstream="$didpath/upstream"
lockdir="$didpath/.lock"
logfile="$didpath/deploy.log"
mkdir -p -- "$didpath"
if [ "$1" = '--unlock' ]; then
warn 'Unlocked the lockdir forcefully'
release_lock "$lockdir" || true
shift
fi
case "$1" in
logs)
echo "==== logs ($logfile) ===="
if [ -f "$logfile" ] && [ -r "$logfile" ]; then
cat -- "$logfile"
fi
return 0
;;
help)
print_usage info "$deployer"
return 0
;;
esac
acquire_lock "$lockdir"
trap 'logs_cleanup;release_lock "$lockdir"' INT TERM
mkdir -p -- "$didpath"
if ! cd "$didpath"; then
error "Failed to change directory to $didpath"
exit 1
fi
true >"$logfile"
stdbuf -o0 -e0 tail -f "$logfile" &
LOGPID="$!"
(
set -e
info "Beginning deploy at $(date -u)"
if ! run_deploy "$deployer" "$didpath" "$@"; then
logs_cleanup 'Failed to run deploy!'
if [ -d "$backup" ]; then
rm -rf -- "$upstream"
cp -a "$backup" "$upstream"
fi
release_lock "$lockdir"
exit 1
fi
info "Deploy ended at $(date -u)"
) >"$logfile" 2>&1
release_lock "$lockdir"
logs_cleanup 'Your deploy is ready!'
}
main "$@"