233 lines
10 KiB
Bash
233 lines
10 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# Shared release helpers for install.sh and update.sh.
|
|
#
|
|
# Two deployment modes, and the difference matters:
|
|
#
|
|
# release — pinned to an immutable tag, checked out detached. What a
|
|
# production server should be on: it moves only when someone
|
|
# decides it moves.
|
|
# branch — following the head of a line, usually main. The edge. CI tags a
|
|
# commit AFTER it is pushed, so origin/main is briefly, and after a
|
|
# failure permanently, code nobody has verified.
|
|
#
|
|
# The mode is persisted, because update.sh cannot infer it: a detached checkout
|
|
# and a branch checkout need entirely different git commands, and guessing wrong
|
|
# either fails loudly or silently drags a pinned server onto the edge.
|
|
|
|
MODE_FILE="storage/app/deploy-mode"
|
|
MANIFEST_FILE="storage/app/deployment.json"
|
|
|
|
# The release number the checkout claims to be.
|
|
release_version() { tr -d ' \n\r' < VERSION 2>/dev/null || echo '0.0.0'; }
|
|
|
|
# release_mode — "release" or "branch"; defaults to branch for installs that
|
|
# predate this file.
|
|
release_mode() { cat "$MODE_FILE" 2>/dev/null || echo 'branch'; }
|
|
|
|
release_source() { cat "${MODE_FILE}.source" 2>/dev/null || echo ''; }
|
|
|
|
# The commit the manifest claims is deployed. Read with sed rather than a JSON
|
|
# parser so this has no dependency the installer does not already guarantee.
|
|
release_manifest_commit() {
|
|
sed -n 's/.*"commit"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1
|
|
}
|
|
|
|
# The version the manifest says actually DEPLOYED, which is not the same as the
|
|
# VERSION file: an update moves the checkout before it migrates, so a run that
|
|
# failed in between leaves a newer number on disk than is serving. Deciding
|
|
# "is there something newer" against the file would then offer nothing, because
|
|
# the checkout already claims to be the version it never finished installing.
|
|
#
|
|
# `|| true` at the end, same idiom as release_version()'s `|| echo '0.0.0'`:
|
|
# guarantee a zero exit status regardless of what happened upstream. Without
|
|
# it, a missing manifest — an ordinary state before the first install — makes
|
|
# `sed` exit 2, `pipefail` carries that out of the pipeline, and the caller's
|
|
# `DEPLOYED_VERSION="$(release_manifest_version)"` is a bare assignment: under
|
|
# `set -e` that ends the agent on every tick, before it ever writes a status.
|
|
# Empty output either way, so the caller's existing fallback to
|
|
# release_version() still fires exactly as before.
|
|
release_manifest_version() {
|
|
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1 || true
|
|
}
|
|
|
|
# release_version_gt A B — true when A is strictly a higher version than B.
|
|
#
|
|
# By version, not by ancestry: a tag can be cut from anywhere, and what an
|
|
# operator is being offered is a version number. `sort -V` does the comparing,
|
|
# so v1.10.0 correctly beats v1.9.0 where a string comparison would not.
|
|
release_version_gt() {
|
|
[[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]
|
|
}
|
|
|
|
# release_newest_tag [CEILING] — the highest v* tag by version order, or nothing.
|
|
#
|
|
# Mit CEILING: der hoechste Tag, der NICHT darueber liegt. Das ist die ganze
|
|
# Mechanik des Festnagelns — der Agent fragt nach dem neuesten Tag, den er
|
|
# nehmen DARF, und `behind`, der Knopf und das Wartungsfenster fallen alle aus
|
|
# dieser einen Antwort.
|
|
#
|
|
# Deliberately not `git tag … | head -1`. head exits after the first line, git
|
|
# gets SIGPIPE, and under `set -o pipefail` that non-zero status leaves the
|
|
# command substitution and ends the caller — the same way the tag count did,
|
|
# and the same trap install-agent.sh already carries a warning about. A read
|
|
# loop over a process substitution is not a pipeline and cannot do it.
|
|
release_newest_tag() {
|
|
local ceiling="${1-}" tag
|
|
|
|
while read -r tag; do
|
|
[[ -n "$tag" ]] || continue
|
|
if [[ -n "$ceiling" ]] && release_version_gt "${tag#v}" "${ceiling#v}"; then
|
|
continue
|
|
fi
|
|
printf '%s' "$tag"
|
|
return 0
|
|
done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)
|
|
|
|
return 0
|
|
}
|
|
|
|
# release_tags_ahead VERSION [CEILING] — how many v* tags are higher than
|
|
# VERSION, counting only up to CEILING.
|
|
#
|
|
# Lives here rather than inline in the agent because it has to be testable. The
|
|
# inline version killed the update agent on every tick: the loop body was
|
|
# `release_version_gt … && echo`, so the LAST iteration — almost always a tag
|
|
# that is not newer — left the loop with a non-zero status, `pipefail` carried
|
|
# it out of the pipeline, the command substitution inherited it, and `set -e`
|
|
# ended the agent before it could write a status.
|
|
#
|
|
# `if` rather than `&&` is the whole fix: an if whose condition is false still
|
|
# returns 0. Anything added here must keep that property — auch die
|
|
# Decken-Pruefung unten ist deshalb ein verschachteltes `if` und kein `&&`.
|
|
release_tags_ahead() {
|
|
local current="$1" ceiling="${2-}" tag count=0
|
|
|
|
while read -r tag; do
|
|
[[ -n "$tag" ]] || continue
|
|
if release_version_gt "${tag#v}" "$current"; then
|
|
if [[ -z "$ceiling" ]] || ! release_version_gt "${tag#v}" "${ceiling#v}"; then
|
|
count=$((count + 1))
|
|
fi
|
|
fi
|
|
done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)
|
|
|
|
printf '%s' "$count"
|
|
}
|
|
|
|
# release_tag_exists TAG — true when TAG is a real tag in this checkout.
|
|
#
|
|
# Form und Existenz sind zwei Fragen. Eine Decke, die bloss AUSSIEHT wie eine
|
|
# Version, ist keine — ein Tag, den jemand von Hand in die Deckendatei
|
|
# geschrieben hat und der nie existiert hat, oder einer, den DIESER Wirt
|
|
# schlicht noch nie geholt hat.
|
|
#
|
|
# Was das NICHT abdeckt: einen Tag, der hier schon lag und auf der
|
|
# Gegenstelle geloescht wurde. Der Agent holt mit
|
|
# `git fetch --quiet --tags --force origin` (update-agent.sh), und das
|
|
# ENTFERNT keine lokalen Tags, die drueben verschwunden sind — dafuer
|
|
# braeuchte es `--prune --prune-tags`. Das ist hier absichtlich NICHT
|
|
# gesetzt: es waere eine Entscheidung darueber, was jede der zehn
|
|
# Server-Konsolen mit ihren eigenen lokalen Tags tut, sobald irgendwo einer
|
|
# zurueckgezogen wird — und die steht dem Besitzer zu, nicht diesem Helfer.
|
|
# `git rev-parse --verify` beantwortet also auch dann noch mit "ja", wenn
|
|
# der Tag auf der Gegenstelle laengst weg ist, solange dieser Wirt ihn
|
|
# irgendwann einmal geholt hatte.
|
|
release_tag_exists() {
|
|
[[ -n "${1-}" ]] || return 1
|
|
git rev-parse -q --verify "refs/tags/${1}^{commit}" >/dev/null 2>&1
|
|
}
|
|
|
|
# release_tags_from VERSION [LIMIT] — die Tags, auf die festgenagelt werden
|
|
# darf: VERSION selbst und alles darueber, neueste zuerst, einer je Zeile.
|
|
#
|
|
# VERSION ist EINGESCHLOSSEN, und das ist der haeufigste Fall: „hier
|
|
# einfrieren, nichts Neues nehmen" ist das, was neun von zehn Servern wollen,
|
|
# waehrend der zehnte die neue Fassung bekommt.
|
|
#
|
|
# LIMIT, weil die Statusdatei sonst mit der Tag-Historie mitwaechst.
|
|
release_tags_from() {
|
|
local current="$1" limit="${2:-20}" tag count=0 out=''
|
|
|
|
while read -r tag; do
|
|
[[ -n "$tag" ]] || continue
|
|
if [[ "${tag#v}" == "$current" ]] || release_version_gt "${tag#v}" "$current"; then
|
|
out+="${tag}"$'\n'
|
|
count=$((count + 1))
|
|
if (( count >= limit )); then
|
|
break
|
|
fi
|
|
fi
|
|
done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)
|
|
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
release_remember() { # release_remember MODE SOURCE
|
|
mkdir -p "$(dirname "$MODE_FILE")"
|
|
printf '%s' "$1" > "$MODE_FILE"
|
|
printf '%s' "$2" > "${MODE_FILE}.source"
|
|
}
|
|
|
|
# release_would_go_backwards BASE TARGET — true when TARGET is an ancestor of
|
|
# BASE, i.e. this is a move back in history.
|
|
#
|
|
# Ancestry, not version strings: SemVer order and git history are different
|
|
# things, and a tag can be cut from anywhere. Ancestry is what actually says
|
|
# whether the code is going backwards.
|
|
release_would_go_backwards() {
|
|
local base="$1" target="$2"
|
|
[[ "$base" == "$target" ]] && return 1
|
|
git merge-base --is-ancestor "$target" "$base" 2>/dev/null
|
|
}
|
|
|
|
# release_write_manifest COMMIT SOURCE MODE
|
|
#
|
|
# Written only once every step has succeeded, and written atomically: a
|
|
# half-written manifest read by the console would report a version that never
|
|
# ran. The console reads this rather than live git, because git says what the
|
|
# files are, not whether the deployment finished.
|
|
release_write_manifest() {
|
|
local commit="$1" source="$2" mode="$3" tmp
|
|
mkdir -p "$(dirname "$MANIFEST_FILE")"
|
|
tmp="$(mktemp "${MANIFEST_FILE}.XXXXXX")"
|
|
|
|
printf '{\n "version": "%s",\n "commit": "%s",\n "source": "%s",\n "mode": "%s",\n "deployed_at": "%s"\n}\n' \
|
|
"$(json_escape "$(release_version)")" \
|
|
"$(json_escape "$commit")" \
|
|
"$(json_escape "$source")" \
|
|
"$(json_escape "$mode")" \
|
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"
|
|
|
|
chmod 644 "$tmp"
|
|
mv -f "$tmp" "$MANIFEST_FILE"
|
|
}
|
|
|
|
# json_escape STRING — a branch name is chosen by whoever made the branch, and
|
|
# a quote or a backslash in one would produce a document the console reads as
|
|
# absent: it would lose the deployment metadata after a deployment that
|
|
# otherwise went perfectly. No jq dependency, because this has to work on a
|
|
# server installed before jq was ever on the package list.
|
|
json_escape() {
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"
|
|
s="${s//\"/\\\"}"
|
|
# Control characters cannot occur in a git ref and cannot be represented
|
|
# raw; dropping them beats emitting a broken document.
|
|
printf '%s' "$s" | tr -d '\000-\037'
|
|
}
|
|
|
|
# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers
|
|
# diese Fassung braucht.
|
|
#
|
|
# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als
|
|
# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen
|
|
# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem
|
|
# Wirt, dessen Helfer zu alt ist.
|
|
#
|
|
# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt,
|
|
# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal
|
|
# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter
|
|
# der root sitzt.
|
|
release_host_step_needs() { printf '%s' 3; }
|