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
169 changes: 169 additions & 0 deletions docs/docs/podman.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
sidebar_position: 10
---

# Podman Support

Perry supports running with Podman as an alternative to Docker. This allows you to use Perry in environments where Podman is preferred or required.

## Overview

When using Podman, Perry workspaces connect to an external container engine instead of running Docker-in-Docker. This is achieved through a podman-in-podman sidecar pattern where the workspace container connects to the host's Podman socket.

## Prerequisites

- **Podman** - [Install Podman](https://podman.io/getting-started/installation)
- **Podman socket enabled** - Required for container management
- **macOS or Linux** - Windows via WSL2

Verify Podman is running:

```bash
podman info
```

Enable the Podman socket:

```bash
# On systemd-based systems
systemctl --user enable --now podman.socket

# Verify socket is running
systemctl --user status podman.socket
```

## Configuration

Add the `runtime` field to your Perry configuration file (`~/.perry/config.json`):

```json
{
"runtime": "podman",
"port": 7391,
"host": "0.0.0.0",
"credentials": {
"env": {},
"files": {}
},
"scripts": {
"post_start": [],
"fail_on_error": false
}
}
```

The `runtime` field accepts two values:
- `"docker"` (default) - Use Docker with Docker-in-Docker
- `"podman"` - Use external Podman engine

## Building the Workspace Image

When building a workspace image for Podman, use the `RUNTIME` build argument:

```bash
# Build for Podman
podman build \
--build-arg RUNTIME=podman \
-t perry-workspace:podman \
-f perry/Dockerfile.base \
.

# Build for Docker (default)
docker build \
-t perry-workspace:latest \
-f perry/Dockerfile.base \
.
```

The `RUNTIME=podman` build argument:
- Skips Docker CE installation
- Omits containerd.io and Docker plugins
- Sets `DOCKER_HOST=tcp://host.containers.internal:2375` environment variable

## Podman-in-Podman Sidecar Pattern

Perry workspaces running with Podman use an external container engine. The workspace container connects to the host's Podman socket through the `DOCKER_HOST` environment variable.

### Container Creation

When `runtime: "podman"` is configured, Perry:
- Does NOT set `privileged: true` on workspace containers
- Skips the Docker-in-Docker volume (`workspace-name-docker` → `/var/lib/docker`)
- Relies on `DOCKER_HOST` for container operations

### Entrypoint Behavior

The workspace entrypoint (`perry/internal/src/commands/entrypoint.ts`) checks for the `DOCKER_HOST` environment variable:
- If set: Skips `ensureDockerd()` and `waitForDocker()`
- If not set: Starts Docker daemon as normal (Docker-in-Docker)

All other initialization (SSH, Tailscale, user scripts) proceeds normally.

## Networking

When using Podman, ensure the workspace container can reach the host's Podman socket:

```bash
# Start workspace with host network access
podman run \
--network slirp4netns:allow_host_loopback=true \
...
```

Or expose the Podman socket on a TCP port:

```bash
# Expose Podman socket on TCP (development only)
podman system service --time=0 tcp:0.0.0.0:2375
```

**Security Note**: Exposing the Podman socket on TCP without authentication is insecure. Use this only in trusted development environments.

## Differences from Docker

| Feature | Docker | Podman |
|---------|--------|--------|
| Privileged mode | Required | Not used |
| Docker-in-Docker volume | Created | Skipped |
| Container engine | Internal (dind) | External (host) |
| Socket location | `/var/run/docker.sock` | Via `DOCKER_HOST` |

## Troubleshooting

### Workspace can't connect to Podman

Check that `DOCKER_HOST` is set correctly:

```bash
perry exec <workspace-name> -- env | grep DOCKER_HOST
```

Verify the Podman socket is accessible:

```bash
podman system connection list
```

### Permission denied errors

Ensure the workspace user has access to the Podman socket. You may need to adjust socket permissions or run Podman in rootless mode.

### Container operations fail

Check Podman logs:

```bash
journalctl --user -u podman.socket -f
```

## Limitations

- Docker Compose may have compatibility issues with Podman
- Some Docker-specific features may not work identically
- Performance characteristics differ from Docker-in-Docker

## Next Steps

- [Workspaces](./workspaces.md) - Learn about workspace management
- [Configuration](./configuration/overview.md) - Advanced configuration options
- [Troubleshooting](./troubleshooting.md) - Common issues and solutions
31 changes: 18 additions & 13 deletions perry/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

FROM ubuntu:noble

ARG RUNTIME=docker

ENV DEBIAN_FRONTEND=noninteractive

# Install prerequisites for adding Docker repository
Expand All @@ -15,20 +17,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
lsb-release \
&& rm -rf /var/lib/apt/lists/*

# Add Docker's official GPG key and repository
RUN install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
# Add Docker's official GPG key and repository (only for docker runtime)
RUN if [ "$RUNTIME" = "docker" ]; then \
install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
fi

# Install Docker Engine, CLI, and essential tools
# Install Docker Engine, CLI, and essential tools (conditionally install Docker packages)
RUN apt-get update && apt-get install -y --no-install-recommends \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin \
$(if [ "$RUNTIME" = "docker" ]; then echo "docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"; fi) \
bash \
sudo \
openssh-server \
Expand Down Expand Up @@ -101,11 +101,16 @@ ENV BUN_INSTALL=/usr/local
RUN bash -lc "curl -fsSL https://bun.sh/install | bash" \
&& bun --version

# Set DOCKER_HOST for podman runtime (external container engine)
RUN if [ "$RUNTIME" = "podman" ]; then \
echo "ENV DOCKER_HOST=tcp://host.containers.internal:2375" >> /etc/environment; \
fi

# Create workspace user with passwordless sudo
RUN useradd -m -s /bin/bash workspace \
&& echo "workspace:workspace" | chpasswd \
&& usermod -aG sudo workspace \
&& usermod -aG docker workspace \
&& (getent group docker && usermod -aG docker workspace || true) \
&& echo "%sudo ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# Configure npm to use user-writable global directory
Expand Down
29 changes: 22 additions & 7 deletions perry/internal/src/commands/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@ export const runEntrypoint = async () => {
} catch (error) {
console.log(`[entrypoint] Failed to add SSH key (non-fatal): ${(error as Error).message}`);
}
console.log("[entrypoint] Starting Docker daemon...");
ensureDockerd();
const ready = await waitForDocker();
if (!ready) {
process.exit(1);
return;

// Skip Docker daemon setup if DOCKER_HOST is set (external container engine)
const useExternalDocker = !!process.env.DOCKER_HOST;
if (!useExternalDocker) {
console.log("[entrypoint] Starting Docker daemon...");
ensureDockerd();
const ready = await waitForDocker();
if (!ready) {
process.exit(1);
return;
}
} else {
console.log("[entrypoint] Using external container engine at DOCKER_HOST");
}

console.log("[entrypoint] Running workspace initialization as workspace user...");
try {
await runCommand("sudo", ["-u", "workspace", "-E", "/usr/local/bin/workspace-internal", "init"], {
Expand All @@ -44,5 +52,12 @@ export const runEntrypoint = async () => {
await waitForTailscaled();
}
void monitorServices();
await tailDockerdLogs();

// Skip tailing dockerd logs if using external container engine
if (!useExternalDocker) {
await tailDockerdLogs();
} else {
// Keep process alive for external container engine mode
await new Promise(() => {});
}
};
3 changes: 2 additions & 1 deletion perry/internal/src/lib/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ const isProcessRunning = async (name: string) => {
export const monitorServices = async () => {
console.log("[entrypoint] Starting service monitor...");
const hasTailscale = !!process.env.TS_AUTHKEY;
const useExternalDocker = !!process.env.DOCKER_HOST;
while (true) {
await delay(10000);
if (!(await isProcessRunning("dockerd"))) {
if (!useExternalDocker && !(await isProcessRunning("dockerd"))) {
console.log("[entrypoint] Restarting Docker daemon...");
startDockerd();
await delay(2000);
Expand Down
19 changes: 13 additions & 6 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ export function createRouter(ctx: RouterContext) {
if (workspace.status === 'running') {
try {
const containerName = getContainerName(input.name);
const client = await createWorkerClient(containerName);
const client = await createWorkerClient(containerName, {
runtime: ctx.config.get().runtime,
});
const health = await client.health();
workerVersion = health.version;
} catch {
Expand Down Expand Up @@ -951,8 +953,9 @@ export function createRouter(ctx: RouterContext) {
}

const containerName = `workspace-${input.workspaceName}`;
const runtime = ctx.config.get().runtime;

const rawSessions = await discoverAllSessions(containerName, execInContainer);
const rawSessions = await discoverAllSessions(containerName, execInContainer, runtime);

const customNames = await getSessionNamesForWorkspace(ctx.stateDir, input.workspaceName);

Expand All @@ -973,7 +976,7 @@ export function createRouter(ctx: RouterContext) {

const detailsResults = await Promise.all(
paginatedRawSessions.map((rawSession) =>
getAgentSessionDetails(containerName, rawSession, execInContainer)
getAgentSessionDetails(containerName, rawSession, execInContainer, runtime)
)
);

Expand Down Expand Up @@ -1057,15 +1060,17 @@ export function createRouter(ctx: RouterContext) {
? toClientAgentType(record.agentType)
: input.agentType;

const runtime = ctx.config.get().runtime;
result = resolvedAgentType
? await getSessionMessages(
containerName,
agentSessionId,
resolvedAgentType,
execInContainer,
input.projectPath
input.projectPath,
runtime
)
: await findSessionMessages(containerName, agentSessionId, execInContainer);
: await findSessionMessages(containerName, agentSessionId, execInContainer, runtime);

if (result && !record) {
const agentType = toRegistryAgentType(result.agentType || resolvedAgentType);
Expand Down Expand Up @@ -1201,6 +1206,7 @@ export function createRouter(ctx: RouterContext) {
}

const containerName = `workspace-${input.workspaceName}`;
const runtime = ctx.config.get().runtime;

const record = await resolveSessionRecord(input.sessionId);
const agentSessionId = record?.agentSessionId || input.sessionId;
Expand All @@ -1209,7 +1215,8 @@ export function createRouter(ctx: RouterContext) {
containerName,
agentSessionId,
agentType,
execInContainer
execInContainer,
runtime
);

if (!result.success) {
Expand Down
1 change: 1 addition & 0 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export async function loadAgentConfig(configDir?: string): Promise<AgentConfig>
},
skills: Array.isArray(config.skills) ? config.skills : [],
mcpServers: Array.isArray(config.mcpServers) ? config.mcpServers : [],
runtime: config.runtime || 'docker',
allowHostAccess: config.allowHostAccess ?? true,
ssh: {
autoAuthorizeHostKeys: config.ssh?.autoAuthorizeHostKeys ?? true,
Expand Down
Loading