461 lines
23 KiB
Python
461 lines
23 KiB
Python
"""Runtime install/update operations for deploy.py."""
|
|
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:
|
|
relative = path.relative_to(source)
|
|
return bool(relative.parts) and relative.parts[0] in ACME_RUNTIME_NAMES
|
|
|
|
def checked_tree(source: Path) -> None:
|
|
"""Accept durable data; managed ACME Python environments are rebuildable."""
|
|
boundary = source.resolve(strict=True)
|
|
if source.is_symlink():
|
|
raise ValueError('Data root cannot be a symlink.')
|
|
for p in source.rglob('*'):
|
|
if _under_acme_runtime(source, p):
|
|
continue
|
|
st = p.lstat()
|
|
if stat.S_ISLNK(st.st_mode):
|
|
target = os.readlink(p)
|
|
if os.path.isabs(target) or not p.resolve(strict=True).is_relative_to(boundary):
|
|
raise ValueError('Data backup contains an external or absolute symlink.')
|
|
elif not (stat.S_ISDIR(st.st_mode) or stat.S_ISREG(st.st_mode)):
|
|
raise ValueError('Data backup contains a special file.')
|
|
|
|
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]:
|
|
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)
|
|
|
|
def _run_user(account, data: Path, args: list[str], timeout: int) -> subprocess.CompletedProcess:
|
|
env = os.environ.copy(); env.update(HOME=str(data), PATH='/usr/local/bin:/usr/bin:/bin')
|
|
env.pop('PYTHONHOME', None); env.pop('PYTHONPATH', None)
|
|
return subprocess.run(['/usr/sbin/runuser', '-u', account.pw_name, '--', *args], check=False, stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout, env=env)
|
|
|
|
def _acme_python(data: Path) -> Path:
|
|
return data / 'acme-python' / 'bin/python'
|
|
|
|
def _acme_certbot(data: Path) -> Path:
|
|
return data / 'acme-python' / 'bin/certbot'
|
|
|
|
def _acme_runtime_valid(data: Path, account) -> bool:
|
|
python = _acme_python(data)
|
|
if not python.is_file():
|
|
return False
|
|
certbot = _acme_certbot(data)
|
|
if not certbot.is_file():
|
|
return False
|
|
checks = ([str(python), '-I', '-c', 'import certbot, certbot_dns_cloudflare, certbot_dns_ovh, certbot_dns_rfc2136'],
|
|
[str(certbot), '--version'])
|
|
try:
|
|
return all(_run_user(account, data, command, 30).returncode == 0 for command in checks)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
def prepare_managed_acme_runtime(data: Path, account) -> None:
|
|
if _acme_runtime_valid(data, account):
|
|
return
|
|
for name in ACME_RUNTIME_NAMES:
|
|
path = data / name
|
|
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
|
path.unlink(missing_ok=True)
|
|
elif path.is_dir():
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
system_python = shutil.which('python3') or shutil.which('python')
|
|
if not system_python:
|
|
print('WARNING: managed ACME runtime rebuild skipped: Python is unavailable.', file=sys.stderr); return
|
|
target = data / 'acme-python'
|
|
try:
|
|
created = _run_user(account, data, [system_python, '-m', 'venv', str(target)], 180)
|
|
if created.returncode != 0:
|
|
bootstrap = data / 'acme-python.virtualenv-bootstrap'
|
|
pip_ok = _run_user(account, data, [system_python, '-m', 'pip', '--version'], 30)
|
|
if pip_ok.returncode != 0:
|
|
raise RuntimeError(created.stderr.strip()[-1200:])
|
|
install_ve = _run_user(account, data, [system_python, '-m', 'pip', 'install', '--disable-pip-version-check',
|
|
'--no-input', '--no-cache-dir', '--retries', '3', '--timeout', '30', '--target', str(bootstrap), 'virtualenv>=20,<21'], 600)
|
|
if install_ve.returncode != 0:
|
|
raise RuntimeError(install_ve.stderr.strip()[-1200:])
|
|
create_ve = _run_user(account, data, [system_python, '-c',
|
|
"import runpy,sys;sys.path.insert(0,sys.argv[1]);sys.argv=['virtualenv',sys.argv[2]];runpy.run_module('virtualenv',run_name='__main__')",
|
|
str(bootstrap), str(target)], 180)
|
|
shutil.rmtree(bootstrap, ignore_errors=True)
|
|
if create_ve.returncode != 0:
|
|
raise RuntimeError(create_ve.stderr.strip()[-1200:])
|
|
python = _acme_python(data)
|
|
if _run_user(account, data, [str(python), '-m', 'pip', '--version'], 30).returncode != 0:
|
|
ensured = _run_user(account, data, [str(python), '-m', 'ensurepip', '--upgrade'], 120)
|
|
if ensured.returncode != 0:
|
|
raise RuntimeError(ensured.stderr.strip()[-1200:])
|
|
installed = _run_user(account, data, [str(python), '-m', 'pip', 'install', '--disable-pip-version-check',
|
|
'--no-input', '--no-cache-dir', '--retries', '3', '--timeout', '30', *CERTBOT_PACKAGES], 600)
|
|
if installed.returncode != 0 or not _acme_runtime_valid(data, account):
|
|
raise RuntimeError(installed.stderr.strip()[-1200:])
|
|
print(f'Managed ACME runtime ready: {target}')
|
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
for name in ACME_RUNTIME_NAMES:
|
|
shutil.rmtree(data / name, ignore_errors=True)
|
|
print(f'WARNING: managed ACME runtime rebuild failed; system Certbot fallback remains available: {error}', file=sys.stderr)
|
|
|
|
def restore_managed_acme_runtime(failed: Path, restored: Path) -> None:
|
|
for name in ACME_RUNTIME_NAMES:
|
|
source, target = failed / name, restored / name
|
|
if source.is_dir() and not source.is_symlink() and not target.exists():
|
|
try:
|
|
source.rename(target)
|
|
except OSError:
|
|
shutil.copytree(source, target, symlinks=True)
|
|
|
|
|
|
def chown_tree(path: Path, uid: int, gid: int) -> None:
|
|
os.chown(path, uid, gid)
|
|
for child in path.rglob('*'):
|
|
os.chown(child, uid, gid, follow_symlinks=False)
|
|
|
|
|
|
def ensure_service_data_access(data: Path, account) -> None:
|
|
"""Repair the managed data-root metadata and verify service-user traversal.
|
|
|
|
systemd applies WorkingDirectory after dropping to User=/Group=. A stale
|
|
root-owned 0700 data directory therefore fails as status=200/CHDIR before
|
|
RustProxy itself can emit diagnostics. Repair only the managed data root;
|
|
never chmod/chown parent directories. Then fail before service downtime if a
|
|
parent directory still prevents traversal.
|
|
"""
|
|
try:
|
|
st = data.lstat()
|
|
except FileNotFoundError as error:
|
|
raise ValueError(f'Data directory is missing: {data}') from error
|
|
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
|
raise ValueError(f'Data directory must be a real directory: {data}')
|
|
|
|
os.chown(data, account.pw_uid, account.pw_gid)
|
|
os.chmod(data, 0o700)
|
|
|
|
probe = subprocess.run(
|
|
['runuser', '-u', account.pw_name, '--', 'test', '-x', str(data)],
|
|
check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
if probe.returncode != 0:
|
|
raise ValueError(
|
|
f'Service user {account.pw_name} cannot traverse data directory {data}; '
|
|
'check execute/search permissions on its parent directories (for example with namei -l).'
|
|
)
|
|
|
|
|
|
def switch(prefix: Path, release: str) -> None:
|
|
if not re.fullmatch(r'[A-Za-z0-9_.-]+', release) or '..' in release:
|
|
raise ValueError('Invalid release name.')
|
|
temporary = prefix / ('current-' + uuid.uuid4().hex)
|
|
os.symlink('releases/' + release, temporary)
|
|
os.replace(temporary, prefix / 'current')
|
|
|
|
|
|
def running_release_matches(record: dict) -> bool:
|
|
"""Check the actual systemd process executable without publishing a version API.
|
|
|
|
This installer targets Linux/systemd. Fail closed if /proc or MainPID cannot
|
|
establish that the service runs the exact staged, version-checked binary.
|
|
"""
|
|
try:
|
|
result = subprocess.run(['systemctl', 'show', '--property=MainPID', '--value', SERVICE],
|
|
check=False, capture_output=True, text=True, timeout=3)
|
|
if result.returncode != 0 or not result.stdout.strip().isdigit():
|
|
return False
|
|
pid = int(result.stdout.strip())
|
|
if pid <= 1:
|
|
return False
|
|
expected = Path(record['prefix']) / 'releases' / record['release'] / 'rust-proxy'
|
|
return Path(f'/proc/{pid}/exe').samefile(expected)
|
|
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
|
|
def admin_health_endpoint(record: dict) -> tuple[str, bool, str]:
|
|
"""Resolve the active persisted administration listener and scheme.
|
|
|
|
The installer record contains only the startup CLI address. Since 0.9.x the
|
|
dashboard can persist both a different admin bind and HTTPS in config.sqlite3.
|
|
Update health checks must follow that active intent or a healthy HTTPS panel is
|
|
mistaken for a failed release and rollback can fail for the same reason.
|
|
"""
|
|
raw = record['admin']
|
|
tls = False
|
|
allowed_hosts: list[str] = []
|
|
database = Path(record['data_dir']) / 'config.sqlite3'
|
|
try:
|
|
connection = sqlite3.connect(f'file:{database}?mode=ro', uri=True, timeout=2)
|
|
try:
|
|
row = connection.execute('SELECT document FROM configuration WHERE id = 1').fetchone()
|
|
finally:
|
|
connection.close()
|
|
if row:
|
|
document = json.loads(row[0])
|
|
administration = document.get('administration') or {}
|
|
if isinstance(administration, dict):
|
|
if administration.get('listen'):
|
|
raw = address(str(administration['listen']))
|
|
tls = bool(administration.get('tls', False))
|
|
values = administration.get('allowed_hosts') or []
|
|
if isinstance(values, list):
|
|
allowed_hosts = [str(value).strip() for value in values if str(value).strip()]
|
|
except (OSError, sqlite3.Error, ValueError, TypeError, json.JSONDecodeError):
|
|
# Old releases and damaged/uninitialized databases used the startup HTTP
|
|
# listener. The real startup diagnostics remain authoritative if this
|
|
# compatibility read cannot resolve persisted settings.
|
|
pass
|
|
|
|
host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1))
|
|
ip = ipaddress.ip_address(host)
|
|
connect_host = ('::1' if ip.version == 6 else '127.0.0.1') if ip.is_unspecified else host
|
|
authority_host = allowed_hosts[0] if allowed_hosts else connect_host
|
|
try:
|
|
authority_ip = ipaddress.ip_address(authority_host)
|
|
authority = f'[{authority_ip}]:{port}' if authority_ip.version == 6 else f'{authority_ip}:{port}'
|
|
except ValueError:
|
|
authority = f'{authority_host}:{port}'
|
|
connect_raw = f'[{connect_host}]:{port}' if ':' in connect_host else f'{connect_host}:{port}'
|
|
return connect_raw, tls, authority
|
|
|
|
|
|
def healthy(record: dict, version: str, attempts: int = 30) -> bool:
|
|
raw, tls, health_host = admin_health_endpoint(record)
|
|
host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1))
|
|
for _ in range(attempts):
|
|
if tls:
|
|
# Local deployment health is process identity/liveness, not PKI validation.
|
|
# The configured admin certificate may intentionally be self-signed or for
|
|
# a DNS name while we connect to the local socket.
|
|
conn = http.client.HTTPSConnection(host, int(port), timeout=2, context=ssl._create_unverified_context())
|
|
else:
|
|
conn = http.client.HTTPConnection(host, int(port), timeout=2)
|
|
try:
|
|
conn.request('GET', '/healthz', headers={'Host': health_host})
|
|
response = conn.getresponse(); content = response.read(4097)
|
|
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
|
|
if (active and response.status == 200 and len(content) <= 4096
|
|
and json.loads(content).get('status') == 'ok' and record.get('version') == version
|
|
and running_release_matches(record)):
|
|
return True
|
|
except (OSError, ValueError, http.client.HTTPException, ssl.SSLError):
|
|
pass
|
|
finally:
|
|
conn.close()
|
|
time.sleep(1)
|
|
return False
|
|
|
|
|
|
def stage(binary: Path, prefix: Path, user: str, sha: str) -> tuple[str, str]:
|
|
release = 'build-' + sha[:24]
|
|
releases = prefix / 'releases'; releases.mkdir(mode=0o755, exist_ok=True); os.chmod(releases, 0o755)
|
|
directory = releases / release
|
|
if not directory.exists():
|
|
directory.mkdir(mode=0o755); os.chmod(directory, 0o755)
|
|
try:
|
|
shutil.copyfile(binary, directory / 'rust-proxy')
|
|
os.chmod(directory / 'rust-proxy', 0o755)
|
|
except BaseException:
|
|
shutil.rmtree(directory); raise
|
|
if directory.is_symlink() or (directory / 'rust-proxy').is_symlink() or digest(directory / 'rust-proxy') != sha:
|
|
raise ValueError('Staged binary checksum mismatch.')
|
|
# Never execute the supplied binary as root, even for its version banner.
|
|
result = run(['runuser', '-u', user, '--', str(directory / 'rust-proxy'), '--version'], capture_output=True, text=True, timeout=15)
|
|
matched = re.fullmatch(r'(?:rust-proxy|RustProxy) (\d+\.\d+\.\d+(?:[-+][\w.-]+)?)\s*', result.stdout)
|
|
if not matched:
|
|
raise ValueError('Binary did not return a RustProxy version.')
|
|
return release, matched[1]
|
|
|
|
|
|
def backup_stopped(record: dict, prefix: Path) -> Path:
|
|
# Service stop must happen before this call: ACME files and SQLite form one backup.
|
|
data = Path(record['data_dir'])
|
|
lock_fd = os.open(data / 'process.lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
account = pwd.getpwnam(record['user']); os.fchown(lock_fd, account.pw_uid, account.pw_gid)
|
|
backups = prefix / 'backups'; backups.mkdir(mode=0o700, exist_ok=True)
|
|
backup = backups / ('before-' + record['version'] + '-' + uuid.uuid4().hex[:12]); backup.mkdir(mode=0o700)
|
|
copy_data(data, backup / 'data')
|
|
write_private(backup / 'install.json', json.dumps(record, indent=2).encode())
|
|
if UNIT_PATH.exists():
|
|
shutil.copyfile(UNIT_PATH, backup / 'rustproxy.service'); os.chmod(backup / 'rustproxy.service', 0o600)
|
|
return backup
|
|
finally:
|
|
os.close(lock_fd)
|
|
|
|
|
|
def restore_service_unit(backup: Path) -> None:
|
|
saved = backup / 'rustproxy.service'
|
|
if saved.exists():
|
|
copy_private(saved, UNIT_PATH, 0o644)
|
|
else:
|
|
UNIT_PATH.unlink(missing_ok=True)
|
|
run(['systemctl', 'daemon-reload'])
|
|
|
|
|
|
def reset_saved_config(data: Path) -> None:
|
|
"""Development-only reset of desired config while preserving auth/certs/audit data."""
|
|
database = data / 'config.sqlite3'
|
|
if database.is_symlink() or not database.is_file():
|
|
raise ValueError('Cannot reset configuration: config.sqlite3 is missing or unsafe.')
|
|
connection = sqlite3.connect(f'file:{database}?mode=rw', uri=True, timeout=5)
|
|
try:
|
|
row = connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='configuration'").fetchone()
|
|
if row is None:
|
|
raise ValueError('Cannot reset configuration: configuration table is missing.')
|
|
connection.execute('DELETE FROM configuration WHERE id = 1')
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
print('Stored vhost/settings configuration cleared; account, certificates and other private data were preserved.')
|
|
|
|
|
|
def service_failure_details() -> str:
|
|
chunks: list[str] = []
|
|
commands = (
|
|
['systemctl', 'show', SERVICE, '--property=ActiveState,SubState,ExecMainStatus', '--no-pager'],
|
|
['journalctl', '-u', SERVICE, '-n', '25', '--no-pager', '--output=cat'],
|
|
)
|
|
for command in commands:
|
|
try:
|
|
result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=5)
|
|
output = ((result.stdout or '') + (result.stderr or '')).strip()
|
|
if output:
|
|
chunks.append(output)
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return '\n'.join(chunks)[-8000:]
|
|
|
|
|
|
def stop_for_managed_update(data: Path) -> None:
|
|
"""Ask RustProxy 0.14.0+ to force hot cache into the enabled cold tier before exit.
|
|
|
|
Older installed versions ignore the marker and still follow their ordinary graceful
|
|
shutdown policy, so the first upgrade to 0.14.0 remains backward compatible.
|
|
The marker is removed before backup so it never becomes persisted application data.
|
|
"""
|
|
marker = data / '.managed-update'
|
|
marker.unlink(missing_ok=True)
|
|
write_private(marker, b'1\n', 0o644)
|
|
try:
|
|
run(['systemctl', 'stop', SERVICE])
|
|
finally:
|
|
marker.unlink(missing_ok=True)
|
|
|
|
|
|
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'])
|
|
geoip_lock_fd: int | None = None
|
|
geoip_stage: Path | None = None
|
|
try:
|
|
# Repair missing or stale installer-owned shell setup on upgrades.
|
|
install_shell_path(prefix)
|
|
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)
|
|
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
|
|
install_shell_path(prefix)
|
|
install_logrotate(replacement)
|
|
print(f'Backup (contains private keys): {backup}')
|
|
return replacement
|
|
finally:
|
|
if geoip_stage is not None:
|
|
shutil.rmtree(geoip_stage, ignore_errors=True)
|
|
release_geoip_update_lock(geoip_lock_fd)
|
|
|
|
|
|
def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bool = False) -> dict:
|
|
import grp
|
|
prefix = Path(record['prefix']); data = Path(record['data_dir'])
|
|
if (prefix / 'install.json').exists() or (prefix / 'current').exists() or UNIT_PATH.exists():
|
|
raise ValueError('Installation/service already exists. A failed update rolls back to the previous installed release, so rerun update.sh after fixing the reported cause. install.sh never overwrites an existing installation.')
|
|
if data.exists() and any(data.iterdir()):
|
|
raise ValueError('First installation requires an empty data directory; existing data will never be reset.')
|
|
try:
|
|
account = pwd.getpwnam(record['user'])
|
|
if account.pw_uid == 0 or account.pw_shell not in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'):
|
|
raise ValueError('Existing service account must be non-root with a non-login shell.')
|
|
except KeyError:
|
|
run(['useradd', '--system', '--user-group', '--home-dir', str(data), '--no-create-home', '--shell', '/usr/sbin/nologin', record['user']])
|
|
account = pwd.getpwnam(record['user'])
|
|
record['group'] = grp.getgrgid(account.pw_gid).gr_name
|
|
prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755)
|
|
data.mkdir(parents=True, mode=0o700, exist_ok=True); os.chmod(data, 0o700); os.chown(data, account.pw_uid, account.pw_gid)
|
|
if install_geoip:
|
|
download_geoip(data, account.pw_uid, account.pw_gid)
|
|
release, version = stage(binary, prefix, record['user'], sha)
|
|
record.update(release=release, version=version, sha256=sha)
|
|
switch(prefix, release)
|
|
install_shell_path(prefix)
|
|
install_logrotate(record)
|
|
# Record is written even if initial startup fails, so an update can repair it.
|
|
write_private(prefix / 'install.json', json.dumps(record, indent=2).encode())
|
|
write_private(UNIT_PATH, service_unit(record).encode(), 0o644)
|
|
run(['systemctl', 'daemon-reload']); run(['systemctl', 'enable', SERVICE])
|
|
if start:
|
|
run(['systemctl', 'start', SERVICE])
|
|
if not healthy(record, version):
|
|
run(['systemctl', 'stop', SERVICE])
|
|
raise RuntimeError('Installed but startup failed. Data was retained; inspect journalctl -u rustproxy.')
|
|
return record
|
|
|
|
|