`hermes gateway migrate --multiplex` ran its one fallible step LAST (install + start the default gateway) with nothing around it. On a fleet whose secondary ran a system unit as root (#110850) that step raised, leaving the flag on, the secondary's unit removed and no gateway anywhere, and the re-run hit the "already multiplexing (flag on)" short-circuit over an empty fleet. - apply_migration(): the default bring-up runs inside a rollback. On failure the manifest written before the first destructive step restores the flag and reinstalls every recorded per-profile gateway with its recorded User=. - MigrationPlan.interrupted: flag on + manifest present + no live default gateway is a half-applied migration, not "already multiplexed"; the re-run resumes from the manifest (target manager and User= read from it, since the units themselves are gone) instead of refusing. Flag off + leftover manifest refuses to overwrite it and points at --standalone. - ProfileGateway.services records EVERY installed unit (user and system) and the manifest carries them; apply stops/uninstalls all of them and rollback reinstalls all of them, so a second owner is never left live beside the multiplexer. The unattended hook treats a two-unit profile as an ambiguous topology and refuses (review finding on #110205). - gateway_identity(): an unresolvable User= on a system unit stays None instead of borrowing the profile directory's owner; the unattended hook treats the unknown principal as a boundary (review finding on #110205). - auto_migration_opted_out(): reads the effective config (load_config_readonly under the default home), so a managed `false` wins over a user `true` and a YAML string "false" is an opt-out, not a truthy value (review finding on #110205). Builds on KoNit-K's #110854 (run_as_user threaded through install, preserved from the removed system unit).
161 lines
7.5 KiB
Python
161 lines
7.5 KiB
Python
"""Boundaries the AUTOMATIC multiplex migration (``hermes update``) must not cross, and the opt-out.
|
|
|
|
The multiplexer replaces a kernel-enforced boundary (separate UNIX users, separate service domains,
|
|
separate HERMES_HOME trees) with in-process isolation. An operator may choose that with
|
|
``hermes gateway migrate --multiplex``; an unattended update hook must not choose it for them.
|
|
``build_migration_plan`` records the same findings as NOTICES so a dry run shows them; only
|
|
:func:`maybe_auto_migrate_after_update` treats them as blockers (#109954).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Callable, Optional
|
|
|
|
if TYPE_CHECKING:
|
|
from hermes_cli.gateway_migrate import MigrationPlan, ProfileGateway
|
|
|
|
|
|
# --------------------------------------------------------------------------- identity resolution
|
|
|
|
|
|
def _pid_uid(pid: int) -> Optional[int]:
|
|
"""Owner uid of a live process: ``/proc`` where it exists, ``ps`` on macOS; None when unknown."""
|
|
with contextlib.suppress(OSError):
|
|
return os.stat(f"/proc/{pid}").st_uid
|
|
from hermes_cli.gateway import is_macos
|
|
if not is_macos():
|
|
return None
|
|
with contextlib.suppress(OSError, ValueError, subprocess.SubprocessError):
|
|
result = subprocess.run(["ps", "-o", "uid=", "-p", str(pid)], capture_output=True, text=True, encoding="utf-8",
|
|
check=False, timeout=2)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return int(result.stdout.strip())
|
|
return None
|
|
|
|
|
|
def _system_unit_uid(unit_path: Path) -> Optional[int]:
|
|
"""uid a system unit runs as: its ``User=`` (root when absent); None when the name is unknown."""
|
|
from hermes_cli.gateway import _read_systemd_user_from_unit
|
|
user = _read_systemd_user_from_unit(unit_path)
|
|
if user is None:
|
|
return 0
|
|
import pwd
|
|
with contextlib.suppress(KeyError):
|
|
return pwd.getpwnam(user).pw_uid
|
|
return None
|
|
|
|
|
|
def gateway_identity(home: Path, pid: Optional[int], services: list[tuple[str, bool]]) -> tuple[Optional[int], Path]:
|
|
"""``(uid, runtime_home)`` of the gateway that serves ``home``.
|
|
|
|
uid: the live process owner; else, for a system unit, its ``User=`` — and ONLY that: a system
|
|
unit's principal is whatever systemd will run, so an unresolvable ``User=`` stays None rather than
|
|
borrowing the profile directory's owner (a stopped unit pinned to an absent NSS user is not the
|
|
account that owns the files). Without a system unit (user-scope systemd / launchd / detached), the
|
|
profile directory owner is the account the gateway runs as. None means unknown. runtime_home: the
|
|
HERMES_HOME an installed unit pins, which is where the gateway really runs; ``home`` otherwise.
|
|
"""
|
|
from hermes_cli.gateway import _hermes_home_pinned_by_unit, get_systemd_unit_path
|
|
from hermes_cli.gateway_migrate import _home_env
|
|
|
|
uid: Optional[int] = _pid_uid(pid) if pid is not None else None
|
|
runtime_home = home
|
|
has_system_unit = False
|
|
for kind, system in services:
|
|
if kind != "systemd":
|
|
continue
|
|
with _home_env(home):
|
|
unit_path = get_systemd_unit_path(system=system)
|
|
pinned = _hermes_home_pinned_by_unit(unit_path)
|
|
if pinned and runtime_home == home:
|
|
runtime_home = Path(pinned).expanduser()
|
|
if system:
|
|
has_system_unit = True
|
|
if uid is None:
|
|
uid = _system_unit_uid(unit_path)
|
|
if uid is None and not has_system_unit:
|
|
with contextlib.suppress(OSError):
|
|
uid = home.stat().st_uid
|
|
return uid, runtime_home
|
|
|
|
|
|
# --------------------------------------------------------------------------- guards
|
|
|
|
|
|
def _service_label(profile: ProfileGateway) -> str:
|
|
return profile.service_label() if profile.services else "no service manager (detached)"
|
|
|
|
|
|
def _guard_service_domain(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
|
|
"""Different manager or scope than the default gateway (system vs user systemd, launchd vs systemd,
|
|
or any service when the default is detached: the auto path never elects a secondary's manager).
|
|
Two units on one profile is an ambiguous topology the unattended path does not resolve either."""
|
|
if len(profile.services) > 1:
|
|
return (f"Profile '{profile.name}' has more than one installed service ({profile.service_label()}): "
|
|
f"an ambiguous service topology is not folded automatically.")
|
|
if set(profile.services) == set(plan.default.services):
|
|
return None
|
|
return (f"Profile '{profile.name}' runs under {_service_label(profile)} while the default gateway "
|
|
f"runs under {_service_label(plan.default)}: a different service domain is not folded automatically.")
|
|
|
|
|
|
def _guard_unix_user(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
|
|
default_uid = plan.default.uid
|
|
if profile.uid is None and profile.has_system_unit:
|
|
# Unknown principal is not "same user": the unit names an account this host cannot resolve.
|
|
return (f"Profile '{profile.name}' runs a system unit whose User= cannot be resolved on this host: "
|
|
f"an unknown service principal is not folded automatically.")
|
|
if default_uid is None or profile.uid is None or profile.uid == default_uid:
|
|
return None
|
|
return (f"Profile '{profile.name}' runs as uid {profile.uid} while the default gateway runs as uid "
|
|
f"{default_uid}: a UNIX privilege boundary is not folded automatically.")
|
|
|
|
|
|
def _guard_home_tree(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
|
|
profiles_root = (plan.default_home / "profiles").resolve()
|
|
runtime_home = (profile.runtime_home or profile.home).resolve()
|
|
if runtime_home.is_relative_to(profiles_root):
|
|
return None
|
|
return (f"Profile '{profile.name}' runs with HERMES_HOME={runtime_home}, outside {profiles_root}: "
|
|
f"the multiplexer would serve {profile.home} instead of the live home.")
|
|
|
|
|
|
_AUTO_MIGRATION_GUARDS: tuple[Callable[[MigrationPlan, ProfileGateway], Optional[str]], ...] = (
|
|
_guard_service_domain,
|
|
_guard_unix_user,
|
|
_guard_home_tree,
|
|
)
|
|
|
|
|
|
def auto_migration_blockers(plan: MigrationPlan) -> list[str]:
|
|
"""Every boundary a standalone secondary sits behind; empty when the fleet is one user, one service
|
|
domain, one profiles/ tree — the only shape ``hermes update`` may fold on its own."""
|
|
return [
|
|
finding
|
|
for profile in plan.standalone_secondaries
|
|
for guard in _AUTO_MIGRATION_GUARDS
|
|
if (finding := guard(plan, profile)) is not None
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- opt-out
|
|
|
|
|
|
def auto_migration_opted_out(default_home: Path) -> bool:
|
|
"""``gateway.auto_multiplex_migration: false`` in the DEFAULT profile's EFFECTIVE config: the same
|
|
``load_config`` the rest of the CLI reads (``DEFAULT_CONFIG`` + config.yaml + the managed overlay), so
|
|
an administrator's managed ``false`` wins over a user's ``true`` and a YAML string ``"false"`` is
|
|
false, not truthy. Only the nested key counts, there is no top-level alias."""
|
|
from hermes_cli.config import load_config_readonly
|
|
from hermes_cli.gateway_migrate import _home_env
|
|
from utils import is_truthy_value
|
|
with _home_env(default_home):
|
|
gateway_section = load_config_readonly().get("gateway")
|
|
if not isinstance(gateway_section, dict):
|
|
return False
|
|
return not is_truthy_value(gateway_section.get("auto_multiplex_migration"), default=True)
|