Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions psi/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from psi.files import write_text_secure
from psi.models import DeployMode, SystemdScope
from psi.systemd import daemon_reload
from psi.unitgen import (
generate_container_provider_setup_quadlet,
generate_container_serve_quadlet,
Expand Down Expand Up @@ -210,13 +211,8 @@ def _ensure_dir(path: Path) -> None:


def _daemon_reload(scope: SystemdScope) -> None:
"""Run systemctl daemon-reload."""
cmd = ["systemctl"]
if scope == SystemdScope.USER:
cmd.append("--user")
cmd.append("daemon-reload")
subprocess.run(cmd, check=True)
logger.info("Reloaded systemd.")
"""Reload systemd via the shared D-Bus-first helper."""
daemon_reload(scope)


def _enable_units(
Expand Down
43 changes: 2 additions & 41 deletions psi/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
from __future__ import annotations

import os
import subprocess
import time
from typing import TYPE_CHECKING

import httpx
from loguru import logger

from psi.errors import ProviderError
from psi.models import SystemdScope
from psi.systemd import daemon_reload

if TYPE_CHECKING:
from psi.cache import Cache
Expand Down Expand Up @@ -69,7 +68,7 @@ def run_setup(
cache.close()

logger.info("Reloading systemd...")
_systemd_daemon_reload(settings.scope)
daemon_reload(settings.scope)
logger.info("Setup complete.")


Expand Down Expand Up @@ -212,44 +211,6 @@ def _fetch_and_register_infisical(
provider.close()


def _systemd_daemon_reload(scope: SystemdScope) -> None:
"""Reload systemd via D-Bus, falling back to systemctl.

Logs a warning and skips if neither D-Bus nor systemctl is available
(e.g. minimal test containers without systemd).
"""
try:
_dbus_daemon_reload(scope)
return
except Exception as e:
logger.debug("D-Bus daemon-reload failed ({}), falling back to systemctl", e)

cmd = ["systemctl"]
if scope == SystemdScope.USER:
cmd.append("--user")
cmd.append("daemon-reload")
try:
subprocess.run(cmd, check=True)
except FileNotFoundError:
logger.warning("systemctl not available, skipping daemon-reload")


def _dbus_daemon_reload(scope: SystemdScope) -> None:
"""Reload systemd via D-Bus. Raises on any failure."""
import dbus

bus = dbus.SessionBus() if scope == SystemdScope.USER else dbus.SystemBus()
systemd = bus.get_object(
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
)
manager = dbus.Interface(
systemd,
"org.freedesktop.systemd1.Manager",
)
manager.Reload()


def _register_secrets(
settings: PsiSettings,
workload_name: str,
Expand Down
53 changes: 52 additions & 1 deletion psi/systemd.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,58 @@
import subprocess
from datetime import UTC, datetime

from psi.models import TimerInfo
from loguru import logger

from psi.models import SystemdScope, TimerInfo


def daemon_reload(scope: SystemdScope) -> None:
"""Reload systemd, preferring D-Bus and falling back to systemctl.

Works correctly when called from inside a container that has the system
D-Bus socket mounted, and gracefully no-ops on minimal environments where
neither D-Bus nor systemctl is available (e.g. build containers).

Args:
scope: System or user systemd instance.
"""
try:
_dbus_daemon_reload(scope)
return
except Exception as e:
logger.debug("D-Bus daemon-reload failed ({}), falling back to systemctl", e)

cmd = ["systemctl"]
if scope == SystemdScope.USER:
cmd.append("--user")
cmd.append("daemon-reload")
try:
subprocess.run(cmd, check=True)
logger.info("Reloaded systemd.")
except FileNotFoundError:
logger.warning("systemctl not available, skipping daemon-reload")
except subprocess.CalledProcessError as e:
logger.warning(
"systemctl daemon-reload failed ({}); skipping — "
"run 'systemctl daemon-reload' on the host manually.",
e,
)


def _dbus_daemon_reload(scope: SystemdScope) -> None:
"""Reload systemd via D-Bus. Raises on any failure."""
import dbus

bus = dbus.SessionBus() if scope == SystemdScope.USER else dbus.SystemBus()
systemd = bus.get_object(
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
)
manager = dbus.Interface(
systemd,
"org.freedesktop.systemd1.Manager",
)
manager.Reload()


def get_timer_info(
Expand Down
6 changes: 5 additions & 1 deletion psi/unitgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def generate_container_provider_setup_quadlet(
"Requires=psi-secrets.service",
"",
"[Container]",
f"ContainerName=psi-{provider}-setup",
f"Image={image}",
f"Exec=setup --provider {provider}",
"Network=host",
Expand Down Expand Up @@ -231,6 +232,7 @@ def generate_container_tls_renew_quadlet(image: str, settings: PsiSettings) -> s
"Wants=network-online.target",
"",
"[Container]",
"ContainerName=psi-tls-renew",
f"Image={image}",
"Exec=tls renew",
"Network=host",
Expand Down Expand Up @@ -348,6 +350,7 @@ def generate_container_serve_quadlet(image: str, settings: PsiSettings) -> str:
"Wants=network-online.target",
"",
"[Container]",
"ContainerName=psi-secrets",
f"Image={image}",
"Exec=serve",
"Network=host",
Expand All @@ -363,11 +366,12 @@ def generate_container_serve_quadlet(image: str, settings: PsiSettings) -> str:

lines.extend(cache_container)

# Quadlet rejects Type=simple for .container units — it sets Type=notify
# automatically. Only Restart and the cache credential go in [Service].
lines.extend(
[
"",
"[Service]",
"Type=simple",
"Restart=on-failure",
f"RuntimeDirectory={runtime_dir.name}",
]
Expand Down
55 changes: 0 additions & 55 deletions tests/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
_generate_drop_in,
_is_retryable,
_setup_infisical_workload,
_systemd_daemon_reload,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -203,60 +202,6 @@ def test_other_exception_is_not_retryable(self) -> None:
assert not _is_retryable(ValueError("nope"))


class TestSystemdDaemonReload:
def test_dbus_failure_falls_back_to_subprocess(self) -> None:
"""D-Bus failures (import error, missing bus socket) fall back to systemctl."""
with (
patch(
"psi.setup._dbus_daemon_reload",
side_effect=RuntimeError("DBusException: bus not found"),
),
patch("psi.setup.subprocess.run") as mock_run,
):
_systemd_daemon_reload(SystemdScope.SYSTEM)

mock_run.assert_called_once_with(["systemctl", "daemon-reload"], check=True)

def test_dbus_import_error_falls_back(self) -> None:
with (
patch("psi.setup._dbus_daemon_reload", side_effect=ImportError("no dbus")),
patch("psi.setup.subprocess.run") as mock_run,
):
_systemd_daemon_reload(SystemdScope.SYSTEM)

mock_run.assert_called_once_with(["systemctl", "daemon-reload"], check=True)

def test_user_scope_uses_user_flag_in_fallback(self) -> None:
with (
patch("psi.setup._dbus_daemon_reload", side_effect=ImportError("no dbus")),
patch("psi.setup.subprocess.run") as mock_run,
):
_systemd_daemon_reload(SystemdScope.USER)

mock_run.assert_called_once_with(["systemctl", "--user", "daemon-reload"], check=True)

def test_dbus_success_skips_subprocess(self) -> None:
with (
patch("psi.setup._dbus_daemon_reload") as mock_dbus,
patch("psi.setup.subprocess.run") as mock_run,
):
_systemd_daemon_reload(SystemdScope.SYSTEM)

mock_dbus.assert_called_once()
mock_run.assert_not_called()

def test_missing_systemctl_is_skipped_with_warning(self) -> None:
"""When neither D-Bus nor systemctl is available, log and skip."""
with (
patch("psi.setup._dbus_daemon_reload", side_effect=ImportError("no dbus")),
patch(
"psi.setup.subprocess.run",
side_effect=FileNotFoundError(2, "No such file", "systemctl"),
),
):
_systemd_daemon_reload(SystemdScope.SYSTEM)


class TestSetupRetry:
def test_retries_on_connect_error_then_succeeds(self, tmp_path: Path) -> None:
call_count = 0
Expand Down
76 changes: 75 additions & 1 deletion tests/test_systemd.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
import unittest.mock
from unittest.mock import patch

from psi.systemd import _systemctl_show, _usec_to_iso, get_timer_info, get_unit_state
from psi.models import SystemdScope
from psi.systemd import (
_systemctl_show,
_usec_to_iso,
daemon_reload,
get_timer_info,
get_unit_state,
)


class TestUsecToIso:
Expand Down Expand Up @@ -113,3 +120,70 @@ def test_systemctl_show_no_user_flag_by_default(self) -> None:
_systemctl_show("test.service", ["ActiveState"])
cmd = mock.call_args[0][0]
assert "--user" not in cmd


class TestDaemonReload:
def test_dbus_success_skips_subprocess(self) -> None:
with (
patch("psi.systemd._dbus_daemon_reload") as mock_dbus,
patch("psi.systemd.subprocess.run") as mock_run,
):
daemon_reload(SystemdScope.SYSTEM)
mock_dbus.assert_called_once()
mock_run.assert_not_called()

def test_dbus_failure_falls_back_to_subprocess(self) -> None:
with (
patch(
"psi.systemd._dbus_daemon_reload",
side_effect=RuntimeError("bus not found"),
),
patch("psi.systemd.subprocess.run") as mock_run,
):
daemon_reload(SystemdScope.SYSTEM)
mock_run.assert_called_once_with(["systemctl", "daemon-reload"], check=True)

def test_dbus_import_error_falls_back(self) -> None:
with (
patch("psi.systemd._dbus_daemon_reload", side_effect=ImportError("no dbus")),
patch("psi.systemd.subprocess.run") as mock_run,
):
daemon_reload(SystemdScope.SYSTEM)
mock_run.assert_called_once_with(["systemctl", "daemon-reload"], check=True)

def test_user_scope_uses_user_flag_in_fallback(self) -> None:
with (
patch("psi.systemd._dbus_daemon_reload", side_effect=ImportError("no dbus")),
patch("psi.systemd.subprocess.run") as mock_run,
):
daemon_reload(SystemdScope.USER)
mock_run.assert_called_once_with(["systemctl", "--user", "daemon-reload"], check=True)

def test_missing_systemctl_is_skipped_with_warning(self) -> None:
"""When neither D-Bus nor systemctl is available, log and skip rather than raise."""
with (
patch("psi.systemd._dbus_daemon_reload", side_effect=ImportError("no dbus")),
patch(
"psi.systemd.subprocess.run",
side_effect=FileNotFoundError(2, "No such file", "systemctl"),
),
):
daemon_reload(SystemdScope.SYSTEM)

def test_systemctl_error_is_skipped_with_warning(self) -> None:
"""CalledProcessError from systemctl is downgraded to a warning (not raised).

This is the regression test for the container-mode installer crash: inside
a psi container, `systemctl` exits with 'System has not been booted with
systemd' and CalledProcessError — installer must not abort.
"""
import subprocess

with (
patch("psi.systemd._dbus_daemon_reload", side_effect=ImportError("no dbus")),
patch(
"psi.systemd.subprocess.run",
side_effect=subprocess.CalledProcessError(1, "systemctl"),
),
):
daemon_reload(SystemdScope.SYSTEM)
38 changes: 38 additions & 0 deletions tests/test_unitgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,41 @@ def test_native_serve_no_settings_is_safe(self) -> None:
content = generate_native_serve_service("/usr/bin/psi", SystemdScope.SYSTEM)
assert "psi-cache-key" not in content
assert "StateDirectory=psi" in content


class TestQuadletTranslatability:
"""Ensure quadlet .container files translate cleanly under podman quadlet."""

def test_serve_quadlet_does_not_set_invalid_type_simple(self, tmp_path: Path) -> None:
"""Quadlet rejects Type=simple for .container units.

Setting it causes podman's quadlet generator to fail with
'invalid service Type "simple"' and the resulting .service unit is
never created. Long-running containers must use the quadlet default
(Type=notify) or Type=exec.
"""
settings = _mock_settings(tmp_path, cache_backend="hsm")
content = generate_container_serve_quadlet("psi:latest", settings)
assert "Type=simple" not in content

def test_setup_quadlet_uses_oneshot(self, tmp_path: Path) -> None:
"""Type=oneshot is valid for quadlet .container units."""
settings = _mock_settings(tmp_path, cache_backend="hsm")
content = generate_container_provider_setup_quadlet("psi:latest", settings, "infisical")
assert "Type=oneshot" in content

def test_serve_quadlet_has_container_name(self, tmp_path: Path) -> None:
"""ContainerName=psi-secrets lets operators `podman exec psi-secrets`."""
settings = _mock_settings(tmp_path)
content = generate_container_serve_quadlet("psi:latest", settings)
assert "ContainerName=psi-secrets" in content

def test_setup_quadlet_has_container_name(self, tmp_path: Path) -> None:
settings = _mock_settings(tmp_path)
content = generate_container_provider_setup_quadlet("psi:latest", settings, "infisical")
assert "ContainerName=psi-infisical-setup" in content

def test_tls_renew_quadlet_has_container_name(self, tmp_path: Path) -> None:
settings = _mock_settings(tmp_path)
content = generate_container_tls_renew_quadlet("psi:latest", settings)
assert "ContainerName=psi-tls-renew" in content
Loading