Skip to content
Open
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
32 changes: 30 additions & 2 deletions src/paude/backends/podman.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

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

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

Expand Down
36 changes: 36 additions & 0 deletions src/paude/container/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import subprocess
import sys
import time
from pathlib import Path
from typing import Any


Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -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():
Expand Down
17 changes: 6 additions & 11 deletions src/paude/mounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand Down
18 changes: 2 additions & 16 deletions tests/test_mounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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):
Expand Down
162 changes: 162 additions & 0 deletions tests/test_podman_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"