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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions psi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
17 changes: 17 additions & 0 deletions psi/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions tests/test_install_cli.py
Original file line number Diff line number Diff line change
@@ -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()
29 changes: 29 additions & 0 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
_write_provider_setup_units_native,
_write_refresh_timers,
install_driver_conf,
render_driver_conf,
)
from psi.models import SystemdScope

Expand Down Expand Up @@ -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")
Expand Down
Loading