From 84feec0c7d2f162f09a6610333366e05cb225715 Mon Sep 17 00:00:00 2001 From: Joe Doss Date: Thu, 9 Apr 2026 00:09:19 -0500 Subject: [PATCH] Add psi install --stdout for container-mode token rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In container mode, `psi install` writes containers.conf.d/psi.conf to the container's filesystem, not the host's, so the README's documented token rotation procedure ("re-run psi install") silently no-ops on the host. The only workaround was a manual podman cp dance. Add a --stdout flag that prints the rendered driver conf instead of writing it. Container deployments pipe it to the host file: podman exec psi-secrets psi install --stdout \ | sudo tee /etc/containers/containers.conf.d/psi.conf > /dev/null The new render_driver_conf() helper is side-effect-free — it does not create state_dir or touch the filesystem, since the caller is explicitly asking for bytes. Update the FCOS install section and the token rotation section of the README to document the container-mode one-liner. --- README.md | 14 ++++++++++++- psi/cli.py | 20 +++++++++++++++++-- psi/installer.py | 17 ++++++++++++++++ tests/test_install_cli.py | 42 +++++++++++++++++++++++++++++++++++++++ tests/test_installer.py | 29 +++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/test_install_cli.py diff --git a/README.md b/README.md index 9bd0700..3d4a88b 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,17 @@ sudo psi install This writes `containers.conf.d/psi.conf` which configures Podman's shell secret driver to talk to PSI's Unix socket. +**Container mode:** when PSI itself runs as a container, `psi install` inside the container writes +to the container's filesystem, not the host's. Render the conf to stdout and pipe it to the host +file instead: + +```bash +sudo podman exec psi-secrets psi install --stdout \ + | sudo tee /etc/containers/containers.conf.d/psi.conf > /dev/null +``` + +The host's Podman picks it up on the next secret lookup; no daemon reload needed. + ### 4. Start PSI and register secrets ```bash @@ -395,7 +406,8 @@ so only the config owner can read it. 1. Update the config/credential 2. Restart `psi-secrets.service` -3. Re-run `psi install` +3. Re-run `psi install` (native mode) or pipe `psi install --stdout` from the container to the + host's `containers.conf.d/psi.conf` (container mode — see [container mode install](#3-install-the-shell-driver)) 4. Reload systemd Containers started during the window between steps will fail secret lookups. diff --git a/psi/cli.py b/psi/cli.py index 03023ad..b9c94b5 100644 --- a/psi/cli.py +++ b/psi/cli.py @@ -116,11 +116,27 @@ def serve(config: ConfigOption = None) -> None: @app.command() -def install(config: ConfigOption = None) -> None: +def install( + config: ConfigOption = None, + stdout: Annotated[ + bool, + typer.Option( + "--stdout", + help=( + "Print the driver conf to stdout instead of writing it. " + "Use in container mode: `podman exec psi-secrets psi install " + "--stdout | sudo tee /etc/containers/containers.conf.d/psi.conf`." + ), + ), + ] = False, +) -> None: """Generate Podman shell driver config and state directory.""" - from psi.installer import install_driver_conf + from psi.installer import install_driver_conf, render_driver_conf settings = load_settings(config, scope=detect_scope()) + if stdout: + typer.echo(render_driver_conf(settings), nl=False) + return install_driver_conf(settings) diff --git a/psi/installer.py b/psi/installer.py index 548ab56..744ecf5 100644 --- a/psi/installer.py +++ b/psi/installer.py @@ -82,6 +82,23 @@ def install_driver_conf(settings: PsiSettings) -> None: logger.info("Wrote {}", conf_path) +def render_driver_conf(settings: PsiSettings) -> str: + """Return the Podman shell driver config as a string. + + Side-effect-free counterpart to :func:`install_driver_conf`. Used by the + ``psi install --stdout`` path so container-mode deployments can pipe the + rendered conf to the host's ``containers.conf.d/`` without needing a + bind mount of the host's container config directory: + + podman exec psi-secrets psi install --stdout \\ + | sudo tee /etc/containers/containers.conf.d/psi.conf > /dev/null + """ + from psi.token import resolve_socket_token + + token = resolve_socket_token(settings) + return generate_driver_conf(settings.scope, token=token) + + def _install_native(settings: PsiSettings, enable: bool) -> None: """Install native systemd units.""" psi_path = _find_psi_path() diff --git a/tests/test_install_cli.py b/tests/test_install_cli.py new file mode 100644 index 0000000..eec08aa --- /dev/null +++ b/tests/test_install_cli.py @@ -0,0 +1,42 @@ +"""Tests for the `psi install` CLI command.""" + +from __future__ import annotations + +from unittest.mock import patch + +from typer.testing import CliRunner + +from psi.cli import app + +runner = CliRunner() + + +class TestInstallStdout: + def test_stdout_prints_conf_and_skips_install(self) -> None: + """`psi install --stdout` prints the conf and never calls install_driver_conf.""" + rendered = '[secrets]\ndriver = "shell"\n' + with ( + patch("psi.cli.load_settings") as mock_load, + patch("psi.installer.render_driver_conf", return_value=rendered) as mock_render, + patch("psi.installer.install_driver_conf") as mock_install, + ): + result = runner.invoke(app, ["install", "--stdout"]) + + assert result.exit_code == 0 + assert rendered in result.stdout + assert "Wrote" not in result.stdout + mock_render.assert_called_once_with(mock_load.return_value) + mock_install.assert_not_called() + + def test_default_writes_conf_and_skips_render(self) -> None: + """`psi install` (no flag) calls install_driver_conf, not render_driver_conf.""" + with ( + patch("psi.cli.load_settings") as mock_load, + patch("psi.installer.render_driver_conf") as mock_render, + patch("psi.installer.install_driver_conf") as mock_install, + ): + result = runner.invoke(app, ["install"]) + + assert result.exit_code == 0 + mock_install.assert_called_once_with(mock_load.return_value) + mock_render.assert_not_called() diff --git a/tests/test_installer.py b/tests/test_installer.py index 164eb33..1440a8e 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -15,6 +15,7 @@ _write_provider_setup_units_native, _write_refresh_timers, install_driver_conf, + render_driver_conf, ) from psi.models import SystemdScope @@ -102,6 +103,34 @@ def test_uses_default_mode_without_token(self, tmp_path: Path) -> None: assert oct(conf_path.stat().st_mode & 0o777) == "0o644" +class TestRenderDriverConf: + def test_returns_curl_based_conf_no_token(self, tmp_path: Path) -> None: + settings = _mock_settings(tmp_path) + settings.socket_token = None + conf = render_driver_conf(settings) + assert 'driver = "shell"' in conf + assert "curl -sf --unix-socket" in conf + assert "Authorization" not in conf + + def test_includes_authorization_header_when_token_present(self, tmp_path: Path) -> None: + settings = _mock_settings(tmp_path) + settings.socket_token = "tokabc12345" + conf = render_driver_conf(settings) + assert "Authorization: Bearer tokabc12345" in conf + + def test_does_not_touch_filesystem(self, tmp_path: Path) -> None: + """--stdout path must not create state_dir or write any file.""" + conf_dir = tmp_path / "containers.conf.d" + state_dir = tmp_path / "state" + settings = _mock_settings(tmp_path) + settings.state_dir = state_dir + settings.socket_token = None + with patch("psi.installer._containers_conf_dir", return_value=conf_dir): + render_driver_conf(settings) + assert not conf_dir.exists() + assert not state_dir.exists() + + class TestScopeAwarePaths: def test_systemd_unit_dir_system(self) -> None: assert _systemd_unit_dir(SystemdScope.SYSTEM) == Path("/etc/systemd/system")