mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 11:16:50 +00:00
417 lines
16 KiB
Bash
417 lines
16 KiB
Bash
#!/usr/bin/env bash
|
|
# ProxMenux change journal — recording side.
|
|
#
|
|
# What a sysadmin holds against a tool like this one is not that it
|
|
# changes things: it is that afterwards nobody can say what it changed.
|
|
# Reading the script does not answer it either — a function of four
|
|
# hundred lines may alter two values, and the reader has no way to know
|
|
# which two.
|
|
#
|
|
# So the rule here is that a change is recorded because it could not be
|
|
# made any other way. These helpers are the writing path: they capture
|
|
# what was there, make the change, and record both. A function that uses
|
|
# them is auditable without its author having remembered anything, and a
|
|
# function that writes directly is a bug we can find by grepping.
|
|
#
|
|
# Nothing here needs sqlite, python or network access. Each entry is one
|
|
# small JSON file written whole into a spool directory, which the Monitor
|
|
# reads and consolidates. One file per entry means no two concurrent
|
|
# scripts can interleave a line, and an interrupted write leaves a file
|
|
# the reader skips rather than a corrupted log.
|
|
#
|
|
# Usage:
|
|
# source /usr/local/share/proxmenux/scripts/pmx_journal.sh
|
|
# pmx_journal_context "optimize_logrotate" "1.1"
|
|
# pmx_write_file /etc/logrotate.conf <<EOF
|
|
# ...
|
|
# EOF
|
|
# pmx_enable_service log2ram
|
|
#
|
|
# Everything degrades quietly: if the journal cannot be written, the
|
|
# change still happens. Recording must never be the reason an operation
|
|
# fails on somebody's host.
|
|
|
|
PMX_JOURNAL_ROOT="${PMX_JOURNAL_ROOT:-/usr/local/share/proxmenux/changes}"
|
|
PMX_JOURNAL_SPOOL="$PMX_JOURNAL_ROOT/spool"
|
|
PMX_JOURNAL_OBJECTS="$PMX_JOURNAL_ROOT/objects"
|
|
|
|
# Set by pmx_journal_context; every entry carries them.
|
|
PMX_JOURNAL_FUNCTION="${PMX_JOURNAL_FUNCTION:-}"
|
|
PMX_JOURNAL_VERSION="${PMX_JOURNAL_VERSION:-}"
|
|
PMX_JOURNAL_SOURCE="${PMX_JOURNAL_SOURCE:-${SCRIPT_SOURCE:-}}"
|
|
|
|
# Which function is making the changes that follow. Called once at the
|
|
# top of a function, so the entries it produces are attributable to it
|
|
# rather than to whichever script happened to source this file.
|
|
pmx_journal_context() {
|
|
PMX_JOURNAL_FUNCTION="${1:-unknown}"
|
|
PMX_JOURNAL_VERSION="${2:-}"
|
|
PMX_JOURNAL_SOURCE="${3:-${SCRIPT_SOURCE:-$(basename "${BASH_SOURCE[-1]:-unknown}")}}"
|
|
}
|
|
|
|
_pmx_journal_ready() {
|
|
mkdir -p "$PMX_JOURNAL_SPOOL" "$PMX_JOURNAL_OBJECTS" 2>/dev/null || return 1
|
|
chmod 700 "$PMX_JOURNAL_ROOT" 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
# JSON string escaping in pure bash: no jq dependency on the recording
|
|
# side, because the recording side runs before anything is installed.
|
|
_pmx_json_escape() {
|
|
local text="$1"
|
|
text="${text//\\/\\\\}"
|
|
text="${text//\"/\\\"}"
|
|
text="${text//$'\n'/\\n}"
|
|
text="${text//$'\r'/\\r}"
|
|
text="${text//$'\t'/\\t}"
|
|
printf '%s' "$text"
|
|
}
|
|
|
|
# The largest file whose contents are worth keeping. Configuration is
|
|
# measured in kilobytes; a binary is measured in megabytes and shows no
|
|
# useful difference, so past this the journal records that the file was
|
|
# there and what it hashed to, and stops short of copying it. A host that
|
|
# fills its disk with captured binaries is a worse outcome than a change
|
|
# whose contents cannot be shown.
|
|
PMX_JOURNAL_MAX_OBJECT="${PMX_JOURNAL_MAX_OBJECT:-1048576}"
|
|
|
|
# Set by _pmx_store_object. Reported through globals rather than printed
|
|
# because a command substitution runs in a subshell: anything the helper
|
|
# set there would be lost on the way back, and the caller would record
|
|
# every capture as unrecoverable.
|
|
PMX_LAST_DIGEST=""
|
|
PMX_LAST_OBJECT_STORED=false
|
|
|
|
# Stores a file's contents and returns its digest, so an entry references
|
|
# the bytes rather than embedding them. Content is kept once however many
|
|
# times it is captured.
|
|
_pmx_store_object() {
|
|
local path="$1"
|
|
PMX_LAST_DIGEST=""
|
|
PMX_LAST_OBJECT_STORED=false
|
|
[ -f "$path" ] || return 1
|
|
local digest
|
|
digest="$(sha256sum "$path" 2>/dev/null | cut -d' ' -f1)" || return 1
|
|
[ -n "$digest" ] || return 1
|
|
PMX_LAST_DIGEST="$digest"
|
|
|
|
local size
|
|
size="$(stat -c %s "$path" 2>/dev/null || echo 0)"
|
|
if [ "$size" -gt "$PMX_JOURNAL_MAX_OBJECT" ] 2>/dev/null; then
|
|
# The digest still identifies what was there; the bytes are not
|
|
# kept, and the entry will say the change cannot be undone from
|
|
# the journal alone.
|
|
return 0
|
|
fi
|
|
|
|
local target="$PMX_JOURNAL_OBJECTS/${digest:0:2}/$digest"
|
|
if [ ! -f "$target" ]; then
|
|
mkdir -p "$(dirname "$target")" 2>/dev/null || return 1
|
|
cp "$path" "$target.tmp.$$" 2>/dev/null || return 1
|
|
chmod 600 "$target.tmp.$$" 2>/dev/null || true
|
|
mv "$target.tmp.$$" "$target" 2>/dev/null || return 1
|
|
fi
|
|
PMX_LAST_OBJECT_STORED=true
|
|
}
|
|
|
|
# Writes one entry. Callers pass key=value pairs; values are escaped
|
|
# here so no caller has to think about JSON.
|
|
_pmx_journal_record() {
|
|
_pmx_journal_ready || return 0
|
|
local entry="" key value first=1
|
|
for pair in "$@"; do
|
|
key="${pair%%=*}"
|
|
value="${pair#*=}"
|
|
[ "$first" = 1 ] && first=0 || entry+=","
|
|
# A key ending in _raw carries a number or a literal such as
|
|
# true/false/null and is written unquoted.
|
|
if [ "${key%_raw}" != "$key" ]; then
|
|
entry+="\"${key%_raw}\":${value}"
|
|
else
|
|
entry+="\"$key\":\"$(_pmx_json_escape "$value")\""
|
|
fi
|
|
done
|
|
local file
|
|
file="$PMX_JOURNAL_SPOOL/$(date +%s)-$$-${RANDOM}.json"
|
|
printf '{%s}\n' "$entry" > "$file.tmp" 2>/dev/null || return 0
|
|
chmod 600 "$file.tmp" 2>/dev/null || true
|
|
mv "$file.tmp" "$file" 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
_pmx_journal_common() {
|
|
printf '%s\n' \
|
|
"recorded_at_raw=$(date +%s)" \
|
|
"function=${PMX_JOURNAL_FUNCTION:-unknown}" \
|
|
"function_version=${PMX_JOURNAL_VERSION:-}" \
|
|
"source=${PMX_JOURNAL_SOURCE:-unknown}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Configuration: files this host had, and what they became
|
|
# ---------------------------------------------------------------------
|
|
|
|
# Replaces a file with what arrives on stdin, capturing what was there.
|
|
#
|
|
# pmx_write_file /etc/logrotate.conf <<EOF
|
|
# ...
|
|
# EOF
|
|
pmx_write_file() {
|
|
local path="$1"
|
|
local temp before="" after="" existed="false"
|
|
temp="$(mktemp)" || { cat > "$path"; return $?; }
|
|
cat > "$temp"
|
|
|
|
local kept="true"
|
|
if [ -f "$path" ]; then
|
|
existed="true"
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
fi
|
|
# The change itself. Permissions of an existing file are preserved by
|
|
# writing through it rather than replacing the inode.
|
|
if ! cat "$temp" > "$path" 2>/dev/null; then
|
|
rm -f "$temp"
|
|
return 1
|
|
fi
|
|
_pmx_store_object "$path"; after="$PMX_LAST_DIGEST"
|
|
rm -f "$temp"
|
|
# Writing the same bytes back is not a change. Recording it would
|
|
# fill the journal with entries a reader has to open to discover
|
|
# nothing happened — which is exactly what re-running an idempotent
|
|
# post-install does.
|
|
[ "$before" = "$after" ] && return 0
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=write_file" "target=$path" \
|
|
"before=${before:-}" "after=${after:-}" \
|
|
"existed_raw=$existed" \
|
|
"capture=$([ "$existed" = true ] && echo present || echo created)" \
|
|
"revert=$([ "$existed" = true ] && echo restore || echo remove)" \
|
|
"exactness=$([ "$existed" != true ] || [ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Applies a sed expression in place, capturing the file first.
|
|
#
|
|
# pmx_edit_file /etc/default/grub 's/^X=.*/X=1/'
|
|
pmx_edit_file() {
|
|
local path="$1"; shift
|
|
[ -f "$path" ] || return 1
|
|
local before after kept
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
sed -i "$@" "$path" || return 1
|
|
_pmx_store_object "$path"; after="$PMX_LAST_DIGEST"
|
|
# An expression that matched nothing is not a change, and recording
|
|
# it would fill the journal with entries a reader has to dismiss.
|
|
[ "$before" = "$after" ] && return 0
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=edit_file" "target=$path" \
|
|
"before=${before:-}" "after=${after:-}" \
|
|
"expression=$*" "capture=present" "revert=restore" \
|
|
"exactness=$([ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Removes a file, keeping its contents so the removal can be undone.
|
|
pmx_remove_file() {
|
|
local path="$1"
|
|
[ -e "$path" ] || return 0
|
|
local before kept
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
rm -f "$path" || return 1
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=remove_file" "target=$path" \
|
|
"before=${before:-}" "after=" "capture=present" \
|
|
"revert=restore" \
|
|
"exactness=$([ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Adds to a file, keeping what was there.
|
|
#
|
|
# Appending looks like it needs no capture — the previous content is
|
|
# still in the file — but the journal shows a change as the difference
|
|
# between two states, and a reader asking what a function did to a file
|
|
# should not have to reconstruct the first state by subtracting.
|
|
#
|
|
# printf 'ulimit -n 1048576\n' | pmx_append_file /root/.profile
|
|
pmx_append_file() {
|
|
local path="$1"
|
|
local temp before="" after="" existed="false"
|
|
temp="$(mktemp)" || { cat >> "$path"; return $?; }
|
|
cat > "$temp"
|
|
|
|
local kept="true"
|
|
if [ -f "$path" ]; then
|
|
existed="true"
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
fi
|
|
if ! cat "$temp" >> "$path" 2>/dev/null; then
|
|
rm -f "$temp"
|
|
return 1
|
|
fi
|
|
_pmx_store_object "$path"; after="$PMX_LAST_DIGEST"
|
|
rm -f "$temp"
|
|
[ "$before" = "$after" ] && return 0
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=append_file" "target=$path" \
|
|
"before=${before:-}" "after=${after:-}" \
|
|
"capture=$([ "$existed" = true ] && echo present || echo created)" \
|
|
"revert=$([ "$existed" = true ] && echo restore || echo remove)" \
|
|
"exactness=$([ "$existed" != true ] || [ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Applies a setting through the command that owns it, capturing the
|
|
# state that command reports before and after.
|
|
#
|
|
# Some settings have no file to write: the timezone, whether the clock is
|
|
# disciplined, a bootloader entry. The tool that owns them is the only
|
|
# thing that can read them back, so it is asked twice — before and after
|
|
# — and the journal records the two answers.
|
|
#
|
|
# pmx_apply_setting "timezone" "timedatectl show -p Timezone --value" \
|
|
# timedatectl set-timezone "$timezone"
|
|
pmx_apply_setting() {
|
|
local name="$1" reader="$2"; shift 2
|
|
local before after
|
|
before="$(eval "$reader" 2>/dev/null | head -c 400)"
|
|
"$@" >/dev/null 2>&1
|
|
local status=$?
|
|
after="$(eval "$reader" 2>/dev/null | head -c 400)"
|
|
# A setting already at the wanted value is not a change.
|
|
[ "$before" = "$after" ] && return $status
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=apply_setting" "target=$name" \
|
|
"before_state=$before" "after_state=$after" "command=$*" \
|
|
"capture=present" "revert=reapply" \
|
|
"result=$([ $status -eq 0 ] && echo ok || echo failed)" \
|
|
"exactness=exact"
|
|
return $status
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Installation: what was not on this host and now is
|
|
# ---------------------------------------------------------------------
|
|
|
|
# Installs packages, recording which ones actually arrived.
|
|
#
|
|
# What is recorded is the difference the operation made, not what was
|
|
# asked for: a package already present is not a change, and the
|
|
# dependencies apt pulled in are, even though nobody named them.
|
|
pmx_install_pkg() {
|
|
local -a requested=("$@")
|
|
[ ${#requested[@]} -gt 0 ] || return 0
|
|
|
|
local before_list after_list added
|
|
before_list="$(dpkg-query -W -f='${binary:Package}\n' 2>/dev/null | sort -u)"
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y "${requested[@]}" >/dev/null 2>&1
|
|
local status=$?
|
|
after_list="$(dpkg-query -W -f='${binary:Package}\n' 2>/dev/null | sort -u)"
|
|
added="$(comm -13 <(printf '%s\n' "$before_list") <(printf '%s\n' "$after_list") | tr '\n' ' ')"
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=installation" "operation=install_package" \
|
|
"target=${requested[*]}" "installed=${added% }" \
|
|
"result=$([ $status -eq 0 ] && echo ok || echo failed)" \
|
|
"capture=present" "revert=purge" \
|
|
"exactness=$([ -n "${added// /}" ] && echo partial || echo none)"
|
|
return $status
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Services: what was running, and what runs now
|
|
# ---------------------------------------------------------------------
|
|
|
|
_pmx_service_state() {
|
|
local unit="$1"
|
|
printf '%s/%s' \
|
|
"$(systemctl is-enabled "$unit" 2>/dev/null | head -1 || echo unknown)" \
|
|
"$(systemctl is-active "$unit" 2>/dev/null | head -1 || echo unknown)"
|
|
}
|
|
|
|
pmx_enable_service() {
|
|
local unit="$1"
|
|
local before after
|
|
before="$(_pmx_service_state "$unit")"
|
|
systemctl enable --now "$unit" >/dev/null 2>&1
|
|
local status=$?
|
|
after="$(_pmx_service_state "$unit")"
|
|
[ "$before" = "$after" ] && return $status
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=enable_service" "target=$unit" \
|
|
"before_state=$before" "after_state=$after" \
|
|
"capture=present" "revert=disable" "exactness=exact"
|
|
return $status
|
|
}
|
|
|
|
pmx_disable_service() {
|
|
local unit="$1"
|
|
local before after
|
|
before="$(_pmx_service_state "$unit")"
|
|
systemctl disable --now "$unit" >/dev/null 2>&1
|
|
local status=$?
|
|
after="$(_pmx_service_state "$unit")"
|
|
[ "$before" = "$after" ] && return $status
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=disable_service" "target=$unit" \
|
|
"before_state=$before" "after_state=$after" \
|
|
"capture=present" "revert=enable" "exactness=exact"
|
|
return $status
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Execution: what ProxMenux ran on the user's behalf
|
|
# ---------------------------------------------------------------------
|
|
|
|
# For work ProxMenux launches but does not decide: a system upgrade, a
|
|
# rebuild. Recording it as a change of ours would claim authorship of
|
|
# whatever apt decided; recording nothing would leave a host that changed
|
|
# under the reader's feet with no trace of why.
|
|
pmx_record_execution() {
|
|
local description="$1"; shift
|
|
local command="$*"
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=execution" "operation=run_command" \
|
|
"target=$description" "command=$command" \
|
|
"capture=none" "revert=none" "exactness=none"
|
|
}
|
|
|
|
# Records that a function was applied without being able to say what it
|
|
# changed — the state before it ran is not knowable. Used by the
|
|
# registration path so a host carries an honest account of what was
|
|
# applied before the journal existed.
|
|
pmx_record_applied() {
|
|
local tool="$1" version="$2" state="${3:-applied}"
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=registration" "operation=$state" "target=$tool" \
|
|
"function_version=$version" "capture=unknown" \
|
|
"revert=none" "exactness=none"
|
|
}
|