Three P1 findings from the review of #111062 (fixes #110850 remainder): - interrupted-detection keyed off `plan.default.has_gateway`, which is true for an installed-but-dead unit; `systemd_install` writes the unit before the start that can still be killed, so an apply interrupted at default/start (or mid-restart of a stopped default) was reported as "already multiplexed" and never recovered. `interrupted` is now derived from the manifest vs the LIVE default's recorded served_profiles: flag on + manifest + not every migrated profile served = resume. - the compensation `try` began after `_remove_secondary_gateways` and the flag write, so a later secondary's stop, a unit's daemon-reload or the config write failing left the first secondary removed with no rollback. The flag write, removals and default bring-up now all sit inside one boundary that rolls back through the manifest; `_preflight_apply` refuses failures knowable from the plan (root system unit without a recorded User=, unresolvable recorded user, an unwritable config.yaml) before any working gateway is stopped. - `_guard_unix_user` blocked an unknown SECONDARY principal but accepted `default_uid is None`; a default system unit whose User= this host cannot resolve is the same boundary from the other side and now blocks the update hook (same known uid still folds, different known uid still refuses).
167 lines
8.0 KiB
Python
167 lines
8.0 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 default_uid is None and plan.default.has_system_unit:
|
|
# Consolidating INTO a principal this host cannot identify is the same unknown boundary
|
|
# from the other side: the default's system unit names an account NSS does not resolve.
|
|
return ("The default gateway runs a system unit whose User= cannot be resolved on this host: "
|
|
"an unknown service principal is not folded into automatically.")
|
|
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."""
|
|
findings = [
|
|
finding
|
|
for profile in plan.standalone_secondaries
|
|
for guard in _AUTO_MIGRATION_GUARDS
|
|
if (finding := guard(plan, profile)) is not None
|
|
]
|
|
return list(dict.fromkeys(findings)) # a default-side finding repeats per secondary
|
|
|
|
|
|
# --------------------------------------------------------------------------- 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)
|