From 10ba46854ab47eaa71f6a6018d6a0bea84b7fff7 Mon Sep 17 00:00:00 2001 From: Ken Dreyer Date: Fri, 13 Feb 2026 16:15:49 -0500 Subject: [PATCH] use Podman secrets for gcloud ADC credentials The gcloud bind mount fails silently when SELinux is in enforcing mode. Even without SELinux, the container cannot read the file with rootless UID mapping. Use Podman secrets to inject the credentials with tmpfs. This is a best-practice for secrets handling, and bypasses the SELinux problem. Co-Authored-By: Claude Opus 4.6 --- src/paude/backends/podman.py | 32 ++++++- src/paude/container/runner.py | 36 ++++++++ src/paude/mounts.py | 17 ++-- tests/test_mounts.py | 18 +--- tests/test_podman_session.py | 162 ++++++++++++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 29 deletions(-) diff --git a/src/paude/backends/podman.py b/src/paude/backends/podman.py index 990040b..9272634 100644 --- a/src/paude/backends/podman.py +++ b/src/paude/backends/podman.py @@ -138,6 +138,24 @@ def _volume_name(self, session_name: str) -> str: """Get volume name for a session.""" return f"paude-{session_name}-workspace" + def _ensure_gcp_adc_secret(self) -> str | None: + """Create or replace the GCP ADC Podman secret. + + Returns: + Secret spec string for --secret, or None if ADC file missing. + """ + adc_file = "application_default_credentials.json" + adc_path = Path.home() / ".config" / "gcloud" / adc_file + if not adc_path.is_file(): + return None + + secret_name = "paude-gcp-adc" # noqa: S105 + target = "/home/paude/.config/gcloud/application_default_credentials.json" + + self._runner.create_secret(secret_name, adc_path) + + return f"{secret_name},target={target}" + def create_session(self, config: SessionConfig) -> Session: """Create a new session (does not start it). @@ -196,6 +214,10 @@ def create_session(self, config: SessionConfig) -> Session: if claude_args: env["PAUDE_CLAUDE_ARGS"] = " ".join(claude_args) + # Create GCP ADC secret (if credentials exist) + secret_spec = self._ensure_gcp_adc_secret() + secrets = [secret_spec] if secret_spec else None + # Create container (stopped) # Use sleep infinity as entrypoint to keep container alive # The actual session setup happens when attaching via exec @@ -213,10 +235,12 @@ def create_session(self, config: SessionConfig) -> Session: labels=labels, entrypoint="sleep", command=["infinity"], + secrets=secrets, ) except Exception: - # Cleanup volume on failure + # Cleanup volume and secret on failure self._runner.remove_volume(volume_name, force=True) + self._runner.remove_secret("paude-gcp-adc") raise print(f"Session '{session_name}' created (stopped).", file=sys.stderr) @@ -273,9 +297,10 @@ def delete_session(self, name: str, confirm: bool = False) -> None: print(f"Removing container {container_name}...", file=sys.stderr) self._runner.remove_container(container_name, force=True) - # Remove volume + # Remove volume and secret print(f"Removing volume {volume_name}...", file=sys.stderr) self._runner.remove_volume(volume_name, force=True) + self._runner.remove_secret("paude-gcp-adc") print(f"Session '{name}' deleted.", file=sys.stderr) @@ -309,6 +334,9 @@ def start_session(self, name: str) -> int: print(f"Starting session '{name}'...", file=sys.stderr) + # Recreate GCP ADC secret with latest credentials + self._ensure_gcp_adc_secret() + # Start the container self._runner.start_container(container_name) diff --git a/src/paude/container/runner.py b/src/paude/container/runner.py index 3f82f43..41b8622 100644 --- a/src/paude/container/runner.py +++ b/src/paude/container/runner.py @@ -6,6 +6,7 @@ import subprocess import sys import time +from pathlib import Path from typing import Any @@ -39,6 +40,34 @@ class ContainerRunner: _proxy_counter = 0 + def create_secret(self, name: str, source_file: Path) -> None: + """(Re)Create a Podman secret from a file. + + Args: + name: Secret name. + source_file: Path to the source file. + + Raises: + subprocess.CalledProcessError: If secret creation fails. + """ + subprocess.run( + ["podman", "secret", "create", "--replace=true", + name, str(source_file)], + capture_output=True, + check=True, + ) + + def remove_secret(self, name: str) -> None: + """Remove a Podman secret, ignoring errors. + + Args: + name: Secret name. + """ + subprocess.run( + ["podman", "secret", "rm", name], + capture_output=True, + ) + def create_container( self, name: str, @@ -50,6 +79,7 @@ def create_container( labels: dict[str, str] | None = None, entrypoint: str | None = None, command: list[str] | None = None, + secrets: list[str] | None = None, ) -> str: """Create a container without starting it. @@ -63,6 +93,8 @@ def create_container( labels: Labels to attach to the container. entrypoint: Optional entrypoint override. command: Optional command to run (after image in podman create). + secrets: Optional list of secret specs (e.g., + ["name,target=/path"]). Returns: Container ID. @@ -85,6 +117,10 @@ def create_container( if network: cmd.extend(["--network", network]) + if secrets: + for secret in secrets: + cmd.extend(["--secret", secret]) + cmd.extend(mounts) for key, value in env.items(): diff --git a/src/paude/mounts.py b/src/paude/mounts.py index 6f59ba1..a04a4ae 100644 --- a/src/paude/mounts.py +++ b/src/paude/mounts.py @@ -31,12 +31,13 @@ def build_mounts(workspace: Path, home: Path) -> list[str]: Note: Workspace is NOT mounted here - it uses a named volume at /pvc/workspace. Users sync code via git remote (paude remote add + git push/pull). + Note: gcloud ADC credentials are injected via Podman secrets, not bind mounts. + Mounts (in order): - 1. gcloud config (ro, if exists) - 2. Claude seed directory (ro, if exists) - 3. Plugins at original host path (ro, if exists) - 4. gitconfig (ro, if exists) - 5. claude.json seed (ro, if exists) + 1. Claude seed directory (ro, if exists) + 2. Plugins at original host path (ro, if exists) + 3. gitconfig (ro, if exists) + 4. claude.json seed (ro, if exists) Args: workspace: Path to the workspace directory (for reference, not mounted). @@ -47,12 +48,6 @@ def build_mounts(workspace: Path, home: Path) -> list[str]: """ mounts: list[str] = [] - # gcloud config (ro) - gcloud_dir = home / ".config" / "gcloud" - resolved_gcloud = resolve_path(gcloud_dir) - if resolved_gcloud and resolved_gcloud.is_dir(): - mounts.extend(["-v", f"{resolved_gcloud}:/home/paude/.config/gcloud:ro"]) - # Claude seed directory (ro) claude_dir = home / ".claude" resolved_claude = resolve_path(claude_dir) diff --git a/tests/test_mounts.py b/tests/test_mounts.py index 8619e20..62050d8 100644 --- a/tests/test_mounts.py +++ b/tests/test_mounts.py @@ -25,8 +25,8 @@ def test_workspace_is_not_bind_mounted(self, tmp_path: Path): # Workspace should NOT be in mounts - it uses a named volume at /pvc assert str(workspace) not in mount_str - def test_gcloud_mount_read_only(self, tmp_path: Path): - """gcloud mount is read-only when .config/gcloud exists.""" + def test_gcloud_not_bind_mounted(self, tmp_path: Path): + """gcloud directory is not bind mounted (uses Podman secrets instead).""" workspace = tmp_path / "workspace" workspace.mkdir() home = tmp_path / "home" @@ -37,20 +37,6 @@ def test_gcloud_mount_read_only(self, tmp_path: Path): mounts = build_mounts(workspace, home) mount_str = " ".join(mounts) - assert "/home/paude/.config/gcloud:ro" in mount_str - - def test_gcloud_mount_skipped_when_missing(self, tmp_path: Path): - """gcloud mount skipped when directory missing.""" - workspace = tmp_path / "workspace" - workspace.mkdir() - home = tmp_path / "home" - home.mkdir() - # Don't create gcloud dir - - mounts = build_mounts(workspace, home) - mount_str = " ".join(mounts) - - # Check that .config/gcloud mount is not present (not just "gcloud" substring) assert ".config/gcloud" not in mount_str def test_claude_seed_mount_read_only(self, tmp_path: Path): diff --git a/tests/test_podman_session.py b/tests/test_podman_session.py index e48eafa..8388a42 100644 --- a/tests/test_podman_session.py +++ b/tests/test_podman_session.py @@ -717,3 +717,165 @@ def test_find_session_returns_none_when_no_match( session = backend.find_session_for_workspace(Path("/home/user/my-project")) assert session is None + + +class TestPodmanBackendGcpAdcSecret: + """Tests for GCP ADC secret handling.""" + + @patch("paude.backends.podman.Path") + @patch("paude.backends.podman.ContainerRunner") + def test_create_session_creates_secret_when_adc_exists( + self, mock_runner_class: MagicMock, mock_path_class: MagicMock + ) -> None: + """create_session creates a Podman secret when ADC file exists.""" + mock_runner = MagicMock() + mock_runner.container_exists.return_value = False + mock_runner_class.return_value = mock_runner + + # Mock Path.home() to return a path where ADC exists + mock_home = MagicMock() + mock_adc = MagicMock() + mock_adc.is_file.return_value = True + mock_home.__truediv__ = lambda self, key: ( + MagicMock(__truediv__=lambda self, k: MagicMock(__truediv__=lambda self, k2: mock_adc)) + ) + mock_path_class.home.return_value = mock_home + + backend = PodmanBackend() + backend._runner = mock_runner + + config = SessionConfig( + name="test-session", + workspace=Path("/home/user/project"), + image="paude:latest", + ) + backend.create_session(config) + + mock_runner.create_secret.assert_called_once() + assert mock_runner.create_secret.call_args[0][0] == "paude-gcp-adc" + + @patch("paude.backends.podman.Path") + @patch("paude.backends.podman.ContainerRunner") + def test_create_session_passes_secret_to_create_container( + self, mock_runner_class: MagicMock, mock_path_class: MagicMock + ) -> None: + """create_session passes the secret spec to create_container.""" + mock_runner = MagicMock() + mock_runner.container_exists.return_value = False + mock_runner_class.return_value = mock_runner + + mock_home = MagicMock() + mock_adc = MagicMock() + mock_adc.is_file.return_value = True + mock_home.__truediv__ = lambda self, key: ( + MagicMock(__truediv__=lambda self, k: MagicMock(__truediv__=lambda self, k2: mock_adc)) + ) + mock_path_class.home.return_value = mock_home + + backend = PodmanBackend() + backend._runner = mock_runner + + config = SessionConfig( + name="test-session", + workspace=Path("/home/user/project"), + image="paude:latest", + ) + backend.create_session(config) + + call_kwargs = mock_runner.create_container.call_args[1] + expected_target = "/home/paude/.config/gcloud/application_default_credentials.json" + assert call_kwargs["secrets"] == [f"paude-gcp-adc,target={expected_target}"] + + @patch("paude.backends.podman.Path") + @patch("paude.backends.podman.ContainerRunner") + def test_create_session_skips_secret_when_adc_missing( + self, mock_runner_class: MagicMock, mock_path_class: MagicMock + ) -> None: + """create_session skips secret when ADC file does not exist.""" + mock_runner = MagicMock() + mock_runner.container_exists.return_value = False + mock_runner_class.return_value = mock_runner + + mock_home = MagicMock() + mock_adc = MagicMock() + mock_adc.is_file.return_value = False + mock_home.__truediv__ = lambda self, key: ( + MagicMock(__truediv__=lambda self, k: MagicMock(__truediv__=lambda self, k2: mock_adc)) + ) + mock_path_class.home.return_value = mock_home + + backend = PodmanBackend() + backend._runner = mock_runner + + config = SessionConfig( + name="test-session", + workspace=Path("/home/user/project"), + image="paude:latest", + ) + backend.create_session(config) + + mock_runner.create_secret.assert_not_called() + call_kwargs = mock_runner.create_container.call_args[1] + assert call_kwargs["secrets"] is None + + @patch("paude.backends.podman.Path") + @patch("paude.backends.podman.ContainerRunner") + def test_create_session_cleans_up_secret_on_failure( + self, mock_runner_class: MagicMock, mock_path_class: MagicMock + ) -> None: + """create_session cleans up the secret when container creation fails.""" + mock_runner = MagicMock() + mock_runner.container_exists.return_value = False + mock_runner.create_container.side_effect = RuntimeError("Container failed") + mock_runner_class.return_value = mock_runner + + mock_home = MagicMock() + mock_adc = MagicMock() + mock_adc.is_file.return_value = True + mock_home.__truediv__ = lambda self, key: ( + MagicMock(__truediv__=lambda self, k: MagicMock(__truediv__=lambda self, k2: mock_adc)) + ) + mock_path_class.home.return_value = mock_home + + backend = PodmanBackend() + backend._runner = mock_runner + + config = SessionConfig( + name="test-session", + workspace=Path("/home/user/project"), + image="paude:latest", + ) + + with pytest.raises(RuntimeError): + backend.create_session(config) + + # Secret should be cleaned up on failure + mock_runner.remove_secret.assert_called_once_with("paude-gcp-adc") + + @patch("paude.backends.podman.Path") + @patch("paude.backends.podman.ContainerRunner") + def test_start_session_recreates_secret( + self, mock_runner_class: MagicMock, mock_path_class: MagicMock + ) -> None: + """start_session recreates the secret before starting.""" + mock_runner = MagicMock() + mock_runner.container_exists.return_value = True + mock_runner.get_container_state.return_value = "exited" + mock_runner.attach_container.return_value = 0 + mock_runner_class.return_value = mock_runner + + mock_home = MagicMock() + mock_adc = MagicMock() + mock_adc.is_file.return_value = True + mock_home.__truediv__ = lambda self, key: ( + MagicMock(__truediv__=lambda self, k: MagicMock(__truediv__=lambda self, k2: mock_adc)) + ) + mock_path_class.home.return_value = mock_home + + backend = PodmanBackend() + backend._runner = mock_runner + + backend.start_session("my-session") + + mock_runner.create_secret.assert_called_once() + assert mock_runner.create_secret.call_args[0][0] == "paude-gcp-adc"