This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 09:42:10 +02:00
1 parent c24bc4279b
commit 3b3f0db377
84 files changed
+2686 -392

No files matched your search

+60 -53
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from deploy_core import * # re-exported internal deployment primitives
ACME_RUNTIME_NAMES = frozenset({'acme-python', 'acme-python.installing', 'acme-python.virtualenv-bootstrap'})
RUNTIME_STATE_NAMES = frozenset({'.geoip-update.lock'})
CERTBOT_PACKAGES = ('certbot==5.8.0', 'certbot-dns-cloudflare==5.8.0', 'certbot-dns-ovh==5.8.0', 'certbot-dns-rfc2136==5.8.0')
def _under_acme_runtime(source: Path, path: Path) -> bool:
@@ -30,7 +31,9 @@ def copy_data(source: Path, target: Path) -> None:
checked_tree(source)
root = source.resolve(strict=True)
def ignore(directory: str, names: list[str]) -> set[str]:
return set(names) & ACME_RUNTIME_NAMES if Path(directory).resolve(strict=True) == root else set()
if Path(directory).resolve(strict=True) != root:
return set()
return set(names) & (ACME_RUNTIME_NAMES | RUNTIME_STATE_NAMES)
shutil.copytree(source, target, symlinks=True, ignore=ignore)
os.chmod(target, 0o700)
@@ -353,64 +356,68 @@ def stop_for_managed_update(data: Path) -> None:
def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: bool = False, reset_config: bool = False) -> dict:
prefix = Path(record['prefix']); data = Path(record['data_dir']); account = pwd.getpwnam(record['user'])
# Updates deliberately do not rewrite shell environment. Check the installer-
# managed PATH profile early and report the exact remediation target instead.
check_shell_path(prefix)
# Do this before staging/network work and, critically, before stopping the
# currently healthy service. It also repairs stale root:root/0700 metadata
# left by manual maintenance while preserving private 0700 semantics.
ensure_service_data_access(data, account)
release, version = stage(binary, prefix, record['user'], sha)
geoip_lock_fd: int | None = None
geoip_stage: Path | None = None
geoip_names: list[str] = []
if refresh_geoip:
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
stop_for_managed_update(data)
try:
backup = backup_stopped(record, prefix)
except BaseException:
if active: run(['systemctl', 'start', SERVICE])
if geoip_stage is not None:
shutil.rmtree(geoip_stage, ignore_errors=True)
raise
replacement = dict(record, release=release, version=version, sha256=sha)
try:
if reset_config:
reset_saved_config(data)
apply_geoip_update(data, geoip_stage, geoip_names, account.pw_uid, account.pw_gid)
prepare_managed_acme_runtime(data, account)
switch(prefix, release)
write_private(UNIT_PATH, service_unit(replacement).encode(), 0o644)
run(['systemctl', 'daemon-reload'])
if start:
run(['systemctl', 'start', SERVICE])
if not healthy(replacement, version):
raise RuntimeError('New release failed the local health/version check.')
write_private(prefix / 'install.json', json.dumps(replacement, indent=2).encode())
except BaseException as error:
details = service_failure_details()
if details:
print('New release diagnostics:\n' + details, file=sys.stderr)
# Updates deliberately do not rewrite shell environment. Check the installer-
# managed PATH profile early and report the exact remediation target instead.
check_shell_path(prefix)
# Repair stale root:root/0700 metadata before staging/network work and before
# stopping the currently healthy service.
ensure_service_data_access(data, account)
release, version = stage(binary, prefix, record['user'], sha)
geoip_names: list[str] = []
if refresh_geoip:
# Keep the same lock for checksum/download staging and publication. The
# runtime worker uses this file too, so manual upgrades cannot race it.
geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid)
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
stop_for_managed_update(data)
failed = data.with_name(data.name + '.failed-' + uuid.uuid4().hex[:12])
data.rename(failed) # keep the failed generation for diagnosis; do not delete it
copy_data(backup / 'data', data)
restore_managed_acme_runtime(failed, data)
chown_tree(data, account.pw_uid, account.pw_gid)
switch(prefix, record['release'])
restore_service_unit(backup)
write_private(prefix / 'install.json', json.dumps(record, indent=2).encode())
if active:
run(['systemctl', 'start', SERVICE])
if not healthy(record, record['version']):
raise RuntimeError(f'Update failed: {error}. Rollback restored code/data/unit but previous service health also failed. Backup: {backup}; failed state: {failed}') from error
raise RuntimeError(f'Update failed: {error}. Previous code, private data and service unit were restored. Installation is still present; do not run install.sh. Fix the cause and rerun update.sh. Backup: {backup}; failed state: {failed}') from error
try:
backup = backup_stopped(record, prefix)
except BaseException:
if active:
run(['systemctl', 'start', SERVICE])
raise
replacement = dict(record, release=release, version=version, sha256=sha)
try:
if reset_config:
reset_saved_config(data)
apply_geoip_update(data, geoip_stage, geoip_names, account.pw_uid, account.pw_gid)
prepare_managed_acme_runtime(data, account)
switch(prefix, release)
write_private(UNIT_PATH, service_unit(replacement).encode(), 0o644)
run(['systemctl', 'daemon-reload'])
if start:
run(['systemctl', 'start', SERVICE])
if not healthy(replacement, version):
raise RuntimeError('New release failed the local health/version check.')
write_private(prefix / 'install.json', json.dumps(replacement, indent=2).encode())
except BaseException as error:
details = service_failure_details()
if details:
print('New release diagnostics:\n' + details, file=sys.stderr)
stop_for_managed_update(data)
failed = data.with_name(data.name + '.failed-' + uuid.uuid4().hex[:12])
data.rename(failed) # keep the failed generation for diagnosis; do not delete it
copy_data(backup / 'data', data)
restore_managed_acme_runtime(failed, data)
chown_tree(data, account.pw_uid, account.pw_gid)
switch(prefix, record['release'])
restore_service_unit(backup)
write_private(prefix / 'install.json', json.dumps(record, indent=2).encode())
if active:
run(['systemctl', 'start', SERVICE])
if not healthy(record, record['version']):
raise RuntimeError(f'Update failed: {error}. Rollback restored code/data/unit but previous service health also failed. Backup: {backup}; failed state: {failed}') from error
raise RuntimeError(f'Update failed: {error}. Previous code, private data and service unit were restored. Installation is still present; do not run install.sh. Fix the cause and rerun update.sh. Backup: {backup}; failed state: {failed}') from error
print(f'Backup (contains private keys): {backup}')
return replacement
finally:
if geoip_stage is not None:
shutil.rmtree(geoip_stage, ignore_errors=True)
print(f'Backup (contains private keys): {backup}')
return replacement
release_geoip_update_lock(geoip_lock_fd)
def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bool = False) -> dict: