Add self-contained automated server upgrade package

This commit is contained in:
admin committed 2026-09-10 14:23:31 +02:00
1 parent 4d58fc7b66
commit 8e6c9a058f
14 files changed
+908 -26

No files matched your search

+31 -1
View File
@@ -13,6 +13,7 @@ from pathlib import Path, PurePosixPath
import re
import subprocess
import sys
import time
import tarfile
FORMAT = 2
@@ -201,9 +202,38 @@ def write_status(folder, operation, run_id, code):
os.replace(str(path) + ".tmp", path)
def wait_nextcloud(directory, timeout):
timeout = int(timeout)
if timeout < 1 or timeout > 3600:
raise ValueError("WRITER_DRAIN_TIMEOUT must be 1..3600 seconds")
expected = {os.path.realpath(directory + "/cron.php"), os.path.realpath(directory + "/occ")}
deadline = time.monotonic() + timeout
while True:
found = []
for process in Path("/proc").iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
continue
try:
args = (process / "cmdline").read_bytes().split(b"\0")
cwd = os.readlink(process / "cwd")
# Exact script arguments only: do not match this helper's NC_DIR
# argument, unrelated files, or arbitrary shell command strings.
if any(arg and os.path.realpath(os.path.join(cwd, os.fsdecode(arg))) in expected for arg in args):
found.append(process.name)
except (FileNotFoundError, ProcessLookupError):
continue
if not found:
return
if time.monotonic() >= deadline:
raise ValueError("Nextcloud writers still active (PIDs: " + ", ".join(found) + ")")
time.sleep(1)
def main():
op, *args = sys.argv[1:]
if op == "status":
if op == "wait-nextcloud":
wait_nextcloud(*args)
elif op == "status":
write_status(*args)
elif op == "crypt":
crypt(*args)
+10 -9
View File
@@ -98,13 +98,9 @@ guard_workspaces() {
init_runtime() {
need python3; need flock
guard_workspaces
# Both backup and restore, and the installer, take the same lock.
[[ $LOCKFILE == /* && ! -L $LOCKFILE ]] || die "Unsafe lock path"
if [[ -e $LOCKFILE ]]; then
[[ -f $LOCKFILE && ! -s $LOCKFILE && $(stat -c %u "$LOCKFILE") == 0 ]] || die "Unsafe existing lock file"
fi
exec 9>"$LOCKFILE"
flock -n 9 || die "Another backup/restore/update holds $LOCKFILE"
# shellcheck source=lib/locking.sh
source "$LIB_DIR/locking.sh"
acquire_backup_lock
for p in "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$RESTORE_ROOT" "$LOG_DIR"; do
[[ $p == /* && $p != / && $p != *$'\n'* && $p != *$'\t'* ]] || die "Unsafe working path: $p"
[[ ! -L $p ]] || die "Symlink working directory refused: $p"
@@ -221,8 +217,8 @@ check_remote() {
done
# Pipe directly into the parser: compatibility with older rclone without
# config redacted. Secret values never go to the log or a temporary file.
"$RCLONE_BIN" --config "$RCLONE_CONFIG" config show "$name" | helper crypt "$name"
RCLONE_ARGS=(--config "$RCLONE_CONFIG" --no-check-certificate=false --crypt-filename-encryption standard
"$RCLONE_BIN" --ask-password=false --config "$RCLONE_CONFIG" config show "$name" | helper crypt "$name"
RCLONE_ARGS=(--ask-password=false --config "$RCLONE_CONFIG" --no-check-certificate=false --crypt-filename-encryption standard
--crypt-directory-name-encryption=true --crypt-no-data-encryption=false
--retries "$RCLONE_RETRIES" --low-level-retries "$RCLONE_LOW_LEVEL_RETRIES"
--retries-sleep "$RCLONE_RETRIES_SLEEP" --timeout "$RCLONE_TIMEOUT" --contimeout "$RCLONE_CONTIMEOUT"
@@ -289,6 +285,11 @@ quiesce() {
if [[ $MAIL_BACKUP_MODE == stop ]]; then stop_service postfix; stop_service dovecot
else echo "WARNING: live mail copy is not a consistent snapshot"; fi
fi
if [[ $ENABLE_NEXTCLOUD == true ]]; then
# Cron daemons may leave an already launched PHP child running after stop.
# Wait for direct cron.php / occ processes to finish; never kill them blindly.
helper wait-nextcloud "$NC_DIR" "${WRITER_DRAIN_TIMEOUT:=300}"
fi
}
# Survives SIGKILL/power loss, which Bash traps cannot handle. This is an
# instruction record for the operator, not an automatically executed recovery.
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# The upgrade coordinator holds FD 9 for the complete install/check/backup cycle.
# Child tools may reuse that SAME open file description, never bypass the lock.
acquire_backup_lock() {
[[ $LOCKFILE == /* && ! -L $LOCKFILE ]] || { echo 'Unsafe lock path' >&2; return 1; }
if [[ -e $LOCKFILE ]]; then
[[ -f $LOCKFILE && ! -s $LOCKFILE && $(stat -c %u "$LOCKFILE") == 0 ]] || { echo 'Unsafe existing lock file' >&2; return 1; }
fi
if [[ ${APP_BACKUP_INHERIT_LOCK:-0} == 1 ]]; then
[[ -e /proc/self/fd/9 && $(stat -Lc '%d:%i' /proc/self/fd/9) == "$(stat -Lc '%d:%i' "$LOCKFILE")" ]] || {
echo 'Inherited lock descriptor does not match configured lock' >&2; return 1;
}
else
exec 9>"$LOCKFILE"
fi
flock -n 9 || { echo "Another backup/restore/update holds $LOCKFILE" >&2; return 1; }
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Read server metadata for migration without exposing config values or secrets."""
import configparser
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
def cron_units(nc_dir):
"""Discover directly configured occ/cron.php writers, not arbitrary wrappers."""
proc = subprocess.run(['systemctl','list-unit-files','--type=service','--no-legend','--no-pager'], check=True, text=True, capture_output=True)
services = []
for line in proc.stdout.splitlines():
unit = line.split()[0]
# Templates cannot be stopped without a concrete instance name.
if '@.' in unit:
continue
command = subprocess.check_output(['systemctl','show',unit,'--property=ExecStart','--value'],text=True)
if nc_dir in command and ('cron.php' in command or '/occ' in command):
services.append(unit)
timers = []
for service in services:
triggered = subprocess.check_output(['systemctl','show',service,'--property=TriggeredBy','--value'],text=True)
timers.extend(x for x in triggered.split() if x.endswith(('.timer','.socket')))
for unit in dict.fromkeys(timers + services):
if not re.fullmatch(r'[A-Za-z0-9_.@:-]+\.(service|timer|socket)', unit):
raise ValueError('Unexpected systemd unit name')
print(unit)
def check_crontabs(nc_dir, nc_user):
"""Refuse to guess scheduling semantics for cron scripts; report locations only."""
found = []
paths = [Path('/etc/crontab')]
for directory in ('/etc/cron.d','/etc/cron.hourly','/etc/cron.daily','/etc/cron.weekly','/etc/cron.monthly','/var/spool/cron'):
root = Path(directory)
if root.is_dir():
paths.extend(p for p in root.rglob('*') if p.is_file())
for path in paths:
if not path.is_file():
continue
for line in path.read_text(errors='replace').splitlines():
if not line.lstrip().startswith('#') and (nc_dir in line or 'cron.php' in line):
found.append(str(path))
break
if found:
print('Nextcloud cron entries: '+', '.join(sorted(set(found))))
sys.exit(2)
def gitea(path):
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.read_string('[DEFAULT]\n' + Path(path).read_text())
result = {}
# Only explicitly configured absolute filesystem paths are accepted here.
for key, section, option in [('GITEA_DATA_DIR','DEFAULT','APP_DATA_PATH'),('GITEA_REPOS_DIR','repository','ROOT')]:
value = parser[section].get(option,'') if section in parser else ''
if value and os.path.isabs(value) and '%' not in value:
result[key] = value
if parser.get('database','DB_TYPE',fallback='') == 'mysql':
result['GITEA_DB_NAME'] = parser.get('database','NAME',fallback='')
for key,value in result.items():
print(key+'='+shlex.quote(value))
def environment_config():
raw = subprocess.check_output(['systemctl','show','app-backup.service','--property=Environment','--value'],text=True)
for item in shlex.split(raw):
if item.startswith('RCLONE_CONFIG='):
print(item.split('=',1)[1])
return
if __name__ == '__main__':
try:
op,*args=sys.argv[1:]
if op == 'cron-units': cron_units(*args)
elif op == 'check-crontabs': check_crontabs(*args)
elif op == 'gitea': gitea(*args)
elif op == 'rclone-config': environment_config()
else: raise ValueError('Unknown discovery operation')
except (ValueError, OSError, configparser.Error, subprocess.CalledProcessError):
# Parser errors can include secret config lines, so do not echo exceptions.
print('ERROR: Discovery incomplete. Check Nextcloud cron scheduling or explicit Gitea/rclone settings; no secret values logged.',file=sys.stderr)
sys.exit(1)