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"