Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bca9e3e
Support Podman as the container engine
claude Sep 9, 2026
8fcdf4e
Fall back to host-gateway when the runtime cannot join apify-local
Pijukatel Sep 9, 2026
84e8cba
docs: Record rootless Podman and rootless Docker verification
Pijukatel Sep 9, 2026
283231b
docs: Keep README user-facing and requirements outcome-only for engin…
Pijukatel Sep 9, 2026
7713d31
docs: Mention Podman for the e2e suite in the development section
Pijukatel Sep 9, 2026
67b0651
Merge origin/master (browser view) and run the e2e matrix on Podman too
Pijukatel Sep 10, 2026
6f6aa37
Reach the browser-view sidecar on localhost when the runtime is not o…
Pijukatel Sep 10, 2026
76f394c
Record a build's tag before it turns SUCCEEDED
Pijukatel Sep 10, 2026
edb6522
docs: Create the data directory before starting the runtime
Pijukatel Sep 10, 2026
431dede
Qualify short FROM image names to Docker Hub before building
claude Sep 10, 2026
338d9e0
Create the browser-view X11 socket volume without a size cap
claude Sep 10, 2026
57776d0
Start the runtime on apify-local and route Actors to it by address wh…
claude Sep 10, 2026
fa6658f
Run Actors on the engine's default network when apify-local cannot st…
claude Sep 10, 2026
a439a2a
Pick the default-network route to the API from the runtime's own netw…
claude Sep 10, 2026
16b36d0
Leave out resource limits the engine cannot apply, and report both st…
claude Sep 10, 2026
36a8248
Podman 3.x: run Actors on the engine's default network from the start
claude Sep 10, 2026
3fd51fc
Name the runtime's own images localhost/... so Podman 3.x finds them …
claude Sep 10, 2026
f49771b
Import the sidecar image with its tag in the repo name; add a Podman …
claude Sep 10, 2026
7c720cd
Give a dev-folder run's node_modules volume a name (Podman 3.x reject…
claude Sep 10, 2026
4c265cc
Back the browser-view socket volume with a plain volume, not tmpfs
claude Sep 10, 2026
2b5aec9
Keep an image entrypoint the dev-folder mount would hide available to…
claude Sep 10, 2026
abddd05
e2e: read the hidden-entrypoint run's log from the API, not the CLI s…
claude Sep 10, 2026
5d289d3
Restate the image's Cmd when a dev-folder run starts through a preser…
claude Sep 10, 2026
46cc7b5
e2e: crawl the runtime's own console in the browser-view tests
claude Sep 10, 2026
ba83daf
Revert "e2e: crawl the runtime's own console in the browser-view tests"
claude Sep 10, 2026
c59680e
e2e: retry the browser-view tests once
claude Sep 10, 2026
7966c75
Remove the superseded run-time network fallback and stale comments
claude Sep 10, 2026
e4e6bf4
requirements: state engine-neutral behaviour, not how it is achieved
claude Sep 10, 2026
8998e59
e2e: push the browser-view sample once per suite so a retried case ca…
claude Sep 10, 2026
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
39 changes: 31 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,23 @@ jobs:
- run: pnpm test

e2e:
name: e2e ${{ matrix.file }} (apify-cli + Docker)
runs-on: ubuntu-latest
name: e2e ${{ matrix.file }} (apify-cli + ${{ matrix.engine }})
# `podman-3.4` is the Podman 3.x leg: Ubuntu 22.04's stock Podman 3.4.4, which the ubuntu-22.04
# runner image ships preinstalled - the CNI generation, with no usable user-defined networks.
runs-on: ${{ matrix.engine == 'podman-3.4' && 'ubuntu-22.04' || 'ubuntu-latest' }}
timeout-minutes: 30
strategy:
fail-fast: false
# One job per e2e file. Each file starts its own runtime container on the fixed host ports
# (3333/3000), so files cannot share a daemon; separate runners make them parallel instead.
# One job per e2e file and container engine. Each file starts its own runtime container on
# the fixed host ports (3333/3000), so files cannot share a daemon; separate runners make them
# parallel instead. The `podman` legs drive the runner's preinstalled Podman rootless, as the
# runner user - the same suite, unchanged, against the other supported engine (`test.md`):
# Podman 4 on the default runner, Podman 3.4 on the Ubuntu 22.04 one.
matrix:
engine:
- docker
- podman
- podman-3.4
file:
- actor-dev-loop
- debug-mode
Expand All @@ -48,16 +57,30 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# The suite manages Docker itself against the runner's daemon: it pre-pulls the
# Rootless Podman serves its Docker-compatible API on a socket only when asked to. The suite
# drives Podman through `CONTAINER_CLI=podman` and mounts the socket named by `DOCKER_HOST`
# into the runtime container.
- name: Serve the Podman API socket
if: startsWith(matrix.engine, 'podman')
run: |
podman --version
sock="$RUNNER_TEMP/podman.sock"
nohup podman system service --time=0 "unix://$sock" > "$RUNNER_TEMP/podman-service.log" 2>&1 &
for _ in $(seq 1 30); do [ -S "$sock" ] && break; sleep 1; done
podman --url "unix://$sock" info --format '{{.Host.Arch}}' >/dev/null
echo "CONTAINER_CLI=podman" >> "$GITHUB_ENV"
echo "DOCKER_HOST=unix://$sock" >> "$GITHUB_ENV"
# The suite manages the engine itself against the runner's daemon: it pre-pulls the
# Actor base images, builds the runtime image, starts the runtime container with
# the host Docker socket, and drives it with stock apify-cli via npx.
# the host engine's socket, and drives it with stock apify-cli via npx.
- run: pnpm exec vitest run test/e2e/${{ matrix.file }}.test.ts
# The e2e's runtime container is normally removed by the suite's afterAll; on
# failure it is left running, so its server-side view of any failed request
# (log-stream lifecycle included) is captured here for diagnosis.
- name: Dump runtime container logs on failure
if: failure()
run: |
for c in $(docker ps -aq --filter name=actor-runtime-e2e); do
docker logs --tail 300 "$c" || true
cli=${{ matrix.engine == 'docker' && 'docker' || 'podman' }}
for c in $($cli ps -aq --filter name=actor-runtime-e2e); do
$cli logs --tail 300 "$c" || true
done
3 changes: 3 additions & 0 deletions CLAUDE.MD
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Local Actor runtime is an Actor development tool for developing, running, and de
- Build the docker image `docker build -t actor-runtime .`
- Run the container `docker run --rm -p 3333:3333 -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock -v "$(pwd)/data:/data" actor-runtime`
- `-v "$(pwd)/data:/data"` shared volumes `data` is used to store internal actor runtime data. When exposed it can be directly inspected to determine internal state and storage backend (It is not recommended to manually edit those files. Any edit should be done through http API call).
- Podman (3.4 or newer) works the same way - mount Podman's Docker-compatible API socket where the runtime expects the Docker one:
`sudo systemctl enable --now podman.socket && podman build -t actor-runtime . && mkdir -p data && sudo podman run --rm -p 3333:3333 -p 3000:3000 -v /run/podman/podman.sock:/var/run/docker.sock -v "$(pwd)/data:/data" actor-runtime`
(rootless: mount `$XDG_RUNTIME_DIR/podman/podman.sock` instead and drop `sudo`). Unlike Docker, Podman does not create a missing `data` directory for the mount, hence the `mkdir -p`. See README.md's "Running with Podman" section for the details.

## Work through CLI

Expand Down
15 changes: 8 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# `--platform=$BUILDPLATFORM`: this stage's whole output is architecture-independent (a pure-Python
# wheel plus a .py file, tarred), so on a multi-arch build it runs once natively on the builder rather
# than once per target under QEMU. Requires BuildKit, which is the default builder in Docker >= 23.
FROM --platform=$BUILDPLATFORM python:3.11-slim AS debugpy-payload
FROM --platform=$BUILDPLATFORM docker.io/library/python:3.11-slim AS debugpy-payload
ARG DEBUGPY_VERSION=1.8.21
# Must match `services/debug-mode.ts`'s `PYTHON_DEBUG_PAYLOAD_DIR` - the in-Actor-container path the
# tar is extracted to.
Expand All @@ -32,14 +32,14 @@ RUN tar -cf /payload/debugpy-payload.tar -C /payload/root .
# --- Browser-view sidecar: an Alpine rootfs with x11vnc, tarred so the runtime can `docker import` it at
# run time without a registry. Not pinned to $BUILDPLATFORM: it runs on the Actor containers' daemon, so it
# must be the target architecture's.
FROM alpine:3.21 AS browser-viewer-rootfs
FROM docker.io/library/alpine:3.21 AS browser-viewer-rootfs
RUN apk add --no-cache x11vnc
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
COPY docker/browser-viewer.sh /apify-browser-viewer.sh
RUN chmod 755 /apify-browser-viewer.sh

# Tars the stage above and records its content hash, which the runtime uses as the imported image's tag.
FROM --platform=$BUILDPLATFORM alpine:3.21 AS browser-viewer-payload
FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.21 AS browser-viewer-payload
COPY --from=browser-viewer-rootfs / /rootfs
RUN mkdir -p /payload \
&& tar -cf /payload/rootfs.tar -C /rootfs . \
Expand All @@ -48,7 +48,7 @@ RUN mkdir -p /payload \
# Also architecture-independent: this stage only runs `tsc`, and the `dist/` it hands to the final
# stage is plain JavaScript. The final stage does its own `pnpm install --prod`, so the target
# architecture's native bindings still come from a native (emulated) install there.
FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS builder
FROM --platform=$BUILDPLATFORM docker.io/library/node:24-bookworm-slim AS builder

WORKDIR /usr/src/app

Expand All @@ -62,7 +62,7 @@ COPY tsconfig.json ./
COPY src ./src
RUN pnpm run build

FROM node:24-bookworm-slim
FROM docker.io/library/node:24-bookworm-slim

WORKDIR /usr/src/app

Expand All @@ -84,8 +84,9 @@ COPY --from=debugpy-payload /payload/debugpy-version.txt /opt/apify-debug-payloa
COPY --from=browser-viewer-payload /payload/rootfs.tar /opt/apify-browser-viewer/rootfs.tar
COPY --from=browser-viewer-payload /payload/version.txt /opt/apify-browser-viewer/version.txt

# The runtime talks to the host Docker socket via dockerode (no docker CLI needed in-image) and
# persists all storages under /data - mount both when running the container.
# The runtime talks to the host's Docker-Engine-API socket via dockerode (no docker CLI needed in-image;
# Podman's Docker-compatible socket works the same way) and persists all storages under /data - mount
# both when running the container.
VOLUME ["/data"]
ENV ACTOR_RUNTIME_DATA_DIR=/data

Expand Down
74 changes: 67 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ See `requirements/*.md` for the full behavioural spec (`system.md`, `api.md`,

```bash
docker build -t actor-runtime .
mkdir -p data
docker run --rm -p 3333:3333 -p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd)/data:/data" \
Expand All @@ -40,6 +41,64 @@ the real platform is reachable, the runtime also adopts that account's real user
the first time it sees the token; fully offline (or with any other non-empty token) it just keeps using
the single local user, with no error either way - see `requirements/cli.md`'s User bootstrap section.

## Running with Podman instead of Docker

The runtime talks to the container engine only through its Docker-compatible API socket, and Podman
serves that same API. Everything works the same on Docker and Podman, rootful or rootless; the only
difference is which socket you mount.

```bash
sudo systemctl enable --now podman.socket # one-time: serve Podman's API socket

podman build -t actor-runtime .
mkdir -p data
sudo podman run --rm -p 3333:3333 -p 3000:3000 \
-v /run/podman/podman.sock:/var/run/docker.sock \
-v "$(pwd)/data:/data" \
actor-runtime
```

Rootless Podman serves the socket at `$XDG_RUNTIME_DIR/podman/podman.sock` instead
(`systemctl --user enable --now podman.socket`); mount that path and drop the `sudo`. Rootless Docker
works the same way with its `$XDG_RUNTIME_DIR/docker.sock`. The socket can also be mounted at any other
path together with `-e DOCKER_HOST=unix:///that/path`.

```bash
mkdir -p data
podman run --rm -p 3333:3333 -p 3000:3000 \
-v "$XDG_RUNTIME_DIR/podman/podman.sock:/var/run/docker.sock" \
-v "$(pwd)/data:/data" \
actor-runtime
```

Good to know:

- Podman 3.4 (Ubuntu 22.04's stock package) and newer work. On Podman 4 and newer, Actors run on the
runtime's own `apify-local` network; on Podman 3.x they run on the engine's default network instead
(its user-defined networks are unreliable: Ubuntu 22.04's CNI plugins reject the config Podman writes),
and the runtime says so at startup. Whenever the runtime's own container is not on `apify-local`
(Podman 3.x, or rootless Podman, which refuses to attach it), Actors reach the API through the published
port 3333, so keep `-p 3333:3333` published on all interfaces. Optionally, on Podman 4 and newer, create
the network first and add `--network apify-local` to `podman run` for the direct route.
- Podman does not create a missing host directory for a bind mount (Docker does), hence the
`mkdir -p data` before `podman run`. `apify runtime start` creates its data directory itself.
- Actors run on the engine whose socket you mount, so a dev folder registered for the bind-mount dev
loop below is a path on the machine that engine runs on (inside the VM for `podman machine`), and
under a rootless engine it must be readable by that user.
- A short image name in an Actor's `FROM` line (`apify/actor-node:20`, `python:3.11`) means Docker Hub,
as on the platform. The runtime qualifies it to `docker.io/...` before building, so Podman resolves it
without any `unqualified-search-registries` entry in `registries.conf`. The build log shows the
substitution.
- A rootless engine can only enforce the per-run limits whose cgroup controllers are delegated to your
user: on cgroups v1 none are, and Ubuntu 22.04 delegates `memory` and `pids` but not `cpu`. The runtime
asks Podman which controllers it has, leaves out the limits it cannot apply, and says so at startup;
runs still start. (To get CPU limits under rootless Podman on Ubuntu 22.04, delegate the controller:
`sudo mkdir -p /etc/systemd/system/user@.service.d && printf '[Service]\nDelegate=cpu cpuset io memory pids\n' | sudo tee /etc/systemd/system/user@.service.d/delegate.conf && sudo systemctl daemon-reload`, then log out and in.)
- If you restart a hand-started `podman system service`, the socket file mounted into the runtime goes
stale; restart the runtime container too. The `podman.socket` unit does not have this problem.
- `podman images` lists the images the runtime builds as `actor-runtime/<actor>:<buildId>` under the
registry prefix Podman adds itself (`docker.io/` or `localhost/`, depending on the version).

## Rapid dev loop: bind-mounting your local source (no rebuild per edit)

After the one push+build above, register your Actor's local source folder so every future run picks up
Expand All @@ -50,10 +109,9 @@ apify api POST /actor-runtime/dev-folder/<actorId> --body '"/abs/path/to/sample_
```

`<actorId>` is the id `apify push --json` printed (`.actor.id`); the path must be absolute and must
already exist on the **host** - the runtime verifies this by actually trying to mount it, and rejects
the call with a clear error if the Actor has no build tagged `latest` yet (a stock `apify push` always
tags its build `latest`, so this is normally just "build at least once first") or the path can't be
confirmed.
already exist on the **host** - the runtime checks this and rejects the call with a clear error if the
path can't be confirmed. The check runs again at every run start, so a folder deleted after
registration fails the run instead of running against an empty directory.
The same thing is also a single-field form on the Actor's page in the console (`http://localhost:3000`).

From then on:
Expand All @@ -66,8 +124,10 @@ apify call --input '{"maxPages":3}' # picks up the new dist/, no rebuild

Node doesn't hot-reload a running process, so a local recompile is picked up by the **next** run's
container start, not by any run already in progress. `node_modules` inside the container still comes
from the built image - an anonymous volume preserves it underneath the bind mount - so a new dependency
in `package.json` still needs a real `apify push`/build; only source edits skip it. Clear the
from the built image - a per-run volume preserves it underneath the bind mount - so a new dependency
in `package.json` still needs a real `apify push`/build; only source edits skip it. An entrypoint script
the image keeps in its working directory (Apify's Playwright images start through an Xvfb wrapper there)
stays available too, unless your folder carries its own copy. Clear the
registration with an empty body (`--body '""'`) to go back to running purely from the built image. Full
mechanics: `requirements/actor-driver.md`'s "Bind mount volumes with Actor source code";
endpoint/console details: `requirements/api.md`'s `/actor-runtime/*` section and
Expand Down Expand Up @@ -161,7 +221,7 @@ added by hand.
pnpm install
pnpm run build # tsc
pnpm test # unit + integration (no Docker needed)
pnpm run test:e2e # full CLI-driven dev loop against a built image (requires Docker; the browser-view case pulls the ~2 GB Playwright base image)
pnpm run test:e2e # full CLI-driven dev loop against a built image (requires Docker, or Podman with CONTAINER_CLI=podman; the browser-view case pulls the ~2 GB Playwright base image)
pnpm run dev # run the server directly against ./data with tsx
```

Expand Down
4 changes: 4 additions & 0 deletions docker/browser-viewer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
# Env names must match `src/driver/docker-driver.ts`.

SOCKET_DIR=/tmp/.X11-unix
# The socket directory is a shared volume the Actor's unprivileged Xvfb must be able to write to. This
# sidecar mounts it first and runs as root, so it sets the mode itself rather than trusting the engine
# to copy it from the image.
chmod 1777 "$SOCKET_DIR" 2>/dev/null || true
PORT="${APIFY_BROWSER_VIEWER_PORT:-5900}"
if [ "$APIFY_BROWSER_VIEWER_INTERACTIVE" = "1" ]; then
INPUT_FLAG=""
Expand Down
17 changes: 13 additions & 4 deletions requirements/actor-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,16 @@
surfaces.
- Registration validates that the submitted value is an absolute POSIX path and that the path exists
**on the host** and is a directory.
- Validation never creates anything on the host and follows symlinks.
- Submitting the **empty string clears the registration** and skips validation.
- Every non-success outcome is classified: unable to verify at all (e.g. Docker unreachable)
reports "could not verify" - never "does not exist"; a path confirmed missing reports "path
does not exist"; a path that exists but is a file reports "path is not a directory"; anything
else unverifiable reports a generic "could not verify".
- **Registration has no build-first precondition** - it requires no build of the Actor to exist,
succeeded or otherwise.
- An image that starts through a file inside its working directory still starts under the mount: the
dev folder's copy of that file is used when it has one, the image's own copy otherwise.
- The working directory the mount covers is recorded **per build**, never on the Actor
(`storage.md`); the mount a run applies always uses the one from _that run's own resolved build_,
never any other build the Actor happens to have.
Expand All @@ -59,7 +62,8 @@
- The registration status the console and API report is the registered folder alone - never that a
mount "will apply", since that depends on which build a given run resolves.
- If the registered folder has since been deleted, moved, or made unreadable, the run must **fail
visibly** - never silently mount an empty directory in its place.
visibly** - never silently mount an empty directory in its place. The status message names the folder,
what is wrong with it, and how to clear the registration.
- The Actor image's own installed dependencies (e.g. `node_modules`) must remain available to the Actor
despite the mount covering the whole working directory.
- **Registering or clearing a dev folder never bumps the Actor's `modifiedAt`.**
Expand Down Expand Up @@ -141,9 +145,14 @@ start`, ...) is refused by name, naming both the `CMD` fix and how to clear debu

# Networking

- On startup, the runtime ensures a Docker network `apify-local` exists and joins it under the fixed
DNS alias `apify-api`. Every Actor container is started on that network, so it can reach the
runtime's API at `http://apify-api:3333` regardless of the host's own networking.
- Every Actor container reaches the runtime's API at `http://apify-api:3333`, whatever the host's own
networking, whichever supported engine runs the containers, and however the runtime itself was started
(as a container or not). The runtime provides the `apify-local` network with the DNS alias `apify-api`
for this; when it has to reach the same goal another way, it says so at startup.
- A per-run resource limit the engine cannot enforce for the current user is left out rather than
failing the run; the runtime says so at startup.
- A run the engine refuses to start fails with the engine's reason in both the run's status message and
its log.

# Actor run

Expand Down
Loading
Loading