Add self-contained automated server upgrade package
This commit is contained in:
1 parent
4d58fc7b66
commit
8e6c9a058f
14 files changed
+908
-26
No files matched your search
+31
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user