193 lines
7.2 KiB
Bash
Executable File
193 lines
7.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# The host side of the panel's update button.
|
|
#
|
|
# The panel cannot run an update: it is www-data inside a container, and the
|
|
# update restarts that container. So it writes a request into the checkout and
|
|
# this agent — running on the host as the service account, on a timer — picks it
|
|
# up, runs deploy/update.sh, and writes back what happened.
|
|
#
|
|
# It also answers the question the panel cannot answer on its own: is there
|
|
# anything to update? That needs a `git fetch`, which needs credentials the
|
|
# application deliberately does not have.
|
|
#
|
|
# Installed by deploy/install-agent.sh (as root, once). Runs every few minutes.
|
|
#
|
|
set -Eeuo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$ROOT"
|
|
|
|
# release_mode / release_source, so this agent and update.sh agree on what this
|
|
# installation is following rather than each deciding for itself.
|
|
# shellcheck source=lib/release.sh
|
|
. "$ROOT/deploy/lib/release.sh"
|
|
|
|
STATE_DIR="$ROOT/storage/app/deploy"
|
|
REQUEST="$STATE_DIR/update-request.json"
|
|
STATUS="$STATE_DIR/update-status.json"
|
|
RUNLOG="$STATE_DIR/update-last-run.log"
|
|
# The outcome of the last RUN, kept apart from the periodic check. Written into
|
|
# one file, the next idle tick five minutes later would overwrite a failure with
|
|
# "idle" — and an operator would usually never see that the update failed.
|
|
LASTRUN="$STATE_DIR/update-last-run.json"
|
|
LOCK="$STATE_DIR/.agent.lock"
|
|
|
|
# A request older than this is abandoned. The panel already refuses to show one
|
|
# as pending; the agent has to agree, or a request written while the agent was
|
|
# stopped fires whenever the agent next starts — which could be days later.
|
|
REQUEST_EXPIRES_MINUTES=30
|
|
|
|
mkdir -p "$STATE_DIR"
|
|
|
|
# One agent at a time. Two overlapping runs of update.sh fight over the
|
|
# checkout, and the loser leaves it half-updated.
|
|
exec 9>"$LOCK"
|
|
flock -n 9 || exit 0
|
|
|
|
MODE="$(release_mode)"
|
|
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)"
|
|
TARGET_RELEASE=''
|
|
|
|
json_escape() {
|
|
printf '%s' "${1-}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr -d '\n\r'
|
|
}
|
|
|
|
write_status() {
|
|
local state="$1" error="${2-}"
|
|
cat > "$STATUS.tmp" <<EOF
|
|
{
|
|
"state": "$(json_escape "$state")",
|
|
"mode": "$(json_escape "$MODE")",
|
|
"checked_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
"finished_at": "$(json_escape "${FINISHED_AT:-}")",
|
|
"local_commit": "$(json_escape "${LOCAL_COMMIT:-}")",
|
|
"remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")",
|
|
"target_release": "$(json_escape "${TARGET_RELEASE:-}")",
|
|
"behind": ${BEHIND:-null},
|
|
"branch": "$(json_escape "$BRANCH")",
|
|
"error": "$(json_escape "$error")"
|
|
}
|
|
EOF
|
|
# Replaced atomically: the panel reads this on every settings render, and
|
|
# half a JSON document is exactly the kind of thing that breaks the page at
|
|
# the moment someone needs it.
|
|
mv -f "$STATUS.tmp" "$STATUS"
|
|
}
|
|
|
|
# The outcome of an actual run. Survives every later idle check.
|
|
write_run() {
|
|
local state="$1" error="${2-}"
|
|
cat > "$LASTRUN.tmp" <<EOF
|
|
{
|
|
"state": "$(json_escape "$state")",
|
|
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
"commit": "$(json_escape "$(git rev-parse HEAD 2>/dev/null || echo '')")",
|
|
"error": "$(json_escape "$error")",
|
|
"exit_code": ${EXIT_CODE:-null}
|
|
}
|
|
EOF
|
|
mv -f "$LASTRUN.tmp" "$LASTRUN"
|
|
}
|
|
|
|
LOCAL_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')"
|
|
REMOTE_COMMIT=''
|
|
BEHIND=null
|
|
FETCH_ERROR=''
|
|
|
|
# ── Is there anything newer? ──────────────────────────────────────────────
|
|
# Two different questions depending on what this installation follows. A pinned
|
|
# server does not care what landed on main — it moves between TAGS, and asking
|
|
# the branch would advertise updates that pressing the button cannot apply.
|
|
if [[ "$MODE" == "release" ]]; then
|
|
CURRENT_TAG="$(release_source)"; CURRENT_TAG="${CURRENT_TAG#refs/tags/}"
|
|
|
|
if git fetch --quiet --tags --force origin 2>/dev/null; then
|
|
# Newest by version order, not by tag date: v1.10.0 is newer than v1.9.0
|
|
# and sorts earlier alphabetically.
|
|
NEWEST_TAG="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null | head -1)"
|
|
|
|
if [[ -n "$NEWEST_TAG" && "$NEWEST_TAG" != "$CURRENT_TAG" ]]; then
|
|
# Count only tags actually ahead of the deployed one.
|
|
BEHIND="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null \
|
|
| awk -v cur="$CURRENT_TAG" '$0 == cur {exit} {n++} END {print n+0}')"
|
|
[[ "${BEHIND:-0}" -gt 0 ]] && TARGET_RELEASE="$NEWEST_TAG"
|
|
REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')"
|
|
else
|
|
BEHIND=0
|
|
fi
|
|
else
|
|
# A code, not a sentence: the panel is translated and this script is
|
|
# not, so a German string here would surface in the English interface.
|
|
FETCH_ERROR='repo_unreachable'
|
|
fi
|
|
else
|
|
if [[ "$BRANCH" == "HEAD" ]]; then
|
|
# Detached, but not pinned to a release either — nothing sane to follow.
|
|
FETCH_ERROR='detached_no_release'
|
|
elif git fetch --quiet origin "$BRANCH" 2>/dev/null; then
|
|
REMOTE_COMMIT="$(git rev-parse "origin/$BRANCH" 2>/dev/null || echo '')"
|
|
if [[ -n "$LOCAL_COMMIT" && -n "$REMOTE_COMMIT" ]]; then
|
|
BEHIND="$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0)"
|
|
fi
|
|
else
|
|
# Reported, not swallowed: "cannot reach the repository" and "already up
|
|
# to date" look identical from the panel otherwise, and only one is fine.
|
|
# A code, not a sentence: the panel is translated and this script is
|
|
# not, so a German string here would surface in the English interface.
|
|
FETCH_ERROR='repo_unreachable'
|
|
fi
|
|
fi
|
|
|
|
# ── Is an update asked for, and is it still current? ──────────────────────
|
|
if [[ ! -f "$REQUEST" ]]; then
|
|
write_status idle "$FETCH_ERROR"
|
|
exit 0
|
|
fi
|
|
|
|
REQUEST_AGE_MINUTES="$(( ( $(date +%s) - $(stat -c %Y "$REQUEST" 2>/dev/null || date +%s) ) / 60 ))"
|
|
|
|
if (( REQUEST_AGE_MINUTES > REQUEST_EXPIRES_MINUTES )); then
|
|
# Written while this agent was stopped. Running it now is not what was
|
|
# asked for — it was asked for then.
|
|
rm -f "$REQUEST"
|
|
write_status idle "$FETCH_ERROR"
|
|
exit 0
|
|
fi
|
|
|
|
# Consumed BEFORE the run, not after: update.sh restarts the container stack and
|
|
# may well kill this shell with it. A request left in place would be picked up
|
|
# again on the next tick and update in a loop.
|
|
rm -f "$REQUEST"
|
|
|
|
write_status running "$FETCH_ERROR"
|
|
|
|
set +e
|
|
if [[ "$MODE" == "release" && -n "$TARGET_RELEASE" ]]; then
|
|
RELEASE="$TARGET_RELEASE" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1
|
|
else
|
|
# BRANCH passed explicitly: update.sh defaults to main, so an installation
|
|
# following anything else would be told about ITS branch and then handed
|
|
# main's commits.
|
|
BRANCH="$BRANCH" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1
|
|
fi
|
|
RESULT=$?
|
|
set -e
|
|
|
|
FINISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
LOCAL_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')"
|
|
|
|
EXIT_CODE="$RESULT"
|
|
|
|
if [[ $RESULT -eq 0 ]]; then
|
|
BEHIND=0
|
|
TARGET_RELEASE=''
|
|
write_run succeeded ''
|
|
write_status idle ''
|
|
else
|
|
write_run failed 'update_failed'
|
|
write_status idle "$FETCH_ERROR"
|
|
fi
|
|
|
|
exit 0
|