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
63 changes: 63 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# runtime-macos : HVF runtime tests on self-hosted Apple Silicon,
# including release, ASAN, UBSAN, and TSAN variants
# oci-smoke : OCI front end lane on self-hosted Apple Silicon
# oci-runtime : elfuse runtime lanes on OCI images, self-hosted
#
# Runtime and sanitizer tests require Hypervisor.framework, which
# GitHub-hosted macOS runners do not expose. Those tests run on self-hosted
Expand Down Expand Up @@ -460,3 +461,65 @@ jobs:
set -euo pipefail
chmod +x build/elfuse
ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-smoke-$RUNNER_NAME" scripts/ci/oci-smoke.sh

oci-runtime:
name: OCI runtime (${{ matrix.key }})
needs: build-macos
if: >
github.repository == 'sysprog21/elfuse' &&
(github.event_name == 'push' || github.event_name == 'pull_request' ||
github.event_name == 'workflow_dispatch' ||
github.event_name == 'merge_group')
runs-on: [self-hosted, macOS, arm64]
timeout-minutes: ${{ matrix.timeout }}
strategy:
fail-fast: false
matrix:
include:
- { key: python, timeout: 30 }
- { key: node, timeout: 30 }
- { key: go, timeout: 30 }
- { key: jvm, timeout: 45 }
- { key: c, timeout: 45 }
- { key: redis, timeout: 30 }
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Download prebuilt elfuse binary
uses: actions/download-artifact@v7
with:
name: elfuse-${{ runner.os }}-${{ runner.arch }}
path: build

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: '1.27'
cache: false

- name: Build crane and umoci
# go install refuses umoci: its go.mod has a replace directive.
env:
CRANE_VERSION: v0.22.1
UMOCI_VERSION: v0.6.0
run: |
set -euo pipefail
bin="$RUNNER_TEMP/oci-tools"
GOBIN="$bin" go install \
"github.com/google/go-containerregistry/cmd/crane@$CRANE_VERSION"
umoci="github.com/opencontainers/umoci@$UMOCI_VERSION"
go mod download "$umoci"
go build -C "$(go env GOMODCACHE)/$umoci" -o "$bin/umoci" ./cmd/umoci
echo "$bin" >> "$GITHUB_PATH"
"$bin/crane" version
"$bin/umoci" --version

- name: Run the ${{ matrix.key }} lane
# A downloaded artifact keeps its signature but not its execute bit.
# Runners on one host share $HOME, so each gets its own store.
run: |
set -euo pipefail
chmod +x build/elfuse
ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-runtime-${{ matrix.key }}-$RUNNER_NAME" \
scripts/ci/oci-runtime.sh ${{ matrix.key }}
12 changes: 12 additions & 0 deletions docs/oci-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,15 @@ to any more, left when a tag moved and was pulled again, shows as `<none>`.
`scripts/ci/oci-smoke.sh`: those table tests, then pull, run, list, and clean
under elfuse on Alpine and Debian images, an image whose `/etc` climbs to a host
directory, and `clean` refusing a non-store and a store in use.

The self-hosted `oci-runtime` job runs elfuse itself on real runtimes, one lane
per key of `scripts/ci/oci-runtime.sh`:

| Key | Coverage |
|-----|----------|
| `python` | Threaded SQLite writers, a filesystem walk, subprocesses, and a Unix socket path too deep for the host's `sun_path` |
| `node` | Filesystem, zlib, JSON, and crypto work, then 100 requests to an HTTP server guest |
| `go` | `gofmt` over a generated tree with `GOMAXPROCS=2` |
| `jvm` | `javac`, a thread pool, SHA-256 hashing, and `ProcessBuilder` |
| `c` | A multi-file `make` project and a 1000-function compile |
| `redis` | A server guest driven by a second guest through `SET` and `BGSAVE` |
9 changes: 9 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,12 @@ ELFUSE_OCI_STORE=/tmp/oci-scratch scripts/ci/oci-smoke.sh

The lane needs macOS with Hypervisor.framework and network access, and ends by
cleaning the store. Its coverage is in [oci-images.md](oci-images.md#validation).

### Running The Runtime Lanes Locally

```sh
make elfuse
ELFUSE_OCI_STORE=/tmp/oci-scratch scripts/ci/oci-runtime.sh python # or node, go, jvm, c, redis
```

Every lane also needs `nc`, and the node lane `curl`.
36 changes: 36 additions & 0 deletions scripts/ci/oci-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Helpers shared by the OCI CI lanes.
set -euo pipefail

fail()
{
echo "FAIL: $*" >&2
exit 1
}

# Drain stdin so pipefail does not report the producer's SIGPIPE.
expect_grep()
{
grep -F -- "$1" > /dev/null
}

run_capture()
{
local desc="$1" pattern="$2" out
shift 2
out="$("$@")" || fail "$desc: exited $?: $out"
printf '%s: %s\n' "$desc" "$out"
printf '%s\n' "$out" | expect_grep "$pattern" || fail "$desc: output lacks $pattern"
}

# Poll a predicate without letting its failures escape set -e.
wait_for()
{
local timeout="$1" desc="$2" i=0
shift 2
while ! "$@" > /dev/null; do
i=$((i + 1))
[ "$i" -le $((timeout * 2)) ] || fail "timed out after ${timeout}s waiting for: $desc"
sleep 0.5
done
}
165 changes: 165 additions & 0 deletions scripts/ci/oci-runtime.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# Run one elfuse runtime lane on an OCI image and require its sentinel. The keys
# are the case arms below and the oci-runtime matrix in build.yml. Usage:
# ELFUSE_OCI_STORE=<store> scripts/ci/oci-runtime.sh <key>
# shellcheck source=scripts/ci/oci-lib.sh
. "$(dirname "$0")/oci-lib.sh"

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BIN="$ROOT/scripts/elfuse-oci.py"
: "${ELFUSE_OCI_STORE:?set ELFUSE_OCI_STORE to the store directory to use}"
export ELFUSE_OCI_STORE

# The library images through mirror.gcr.io, so the lane does not depend on
# Docker Hub's anonymous pull limit.
MIRROR=mirror.gcr.io/library

key="${1:?usage: oci-runtime.sh <key>}"
WL="$ROOT/scripts/ci/workloads"

guest=""
srv_outfile=""
on_exit()
{
rc=$?
# Reap the guest before a persistent runner reuses its port or clone.
if [ -n "$guest" ] && kill -0 "$guest" 2> /dev/null; then
kill "$guest" 2> /dev/null || true
wait "$guest" 2> /dev/null || true
fi
if [ "$rc" -ne 0 ] && [ -s "$srv_outfile" ]; then
echo "--- server guest output ---" >&2
cat "$srv_outfile" >&2
fi
[ -z "$srv_outfile" ] || rm -f "$srv_outfile"
exit "$rc"
}
trap on_exit EXIT

server_printed()
{
kill -0 "$guest" 2> /dev/null || fail "server guest exited before readiness"
grep -Eq "$1" "$srv_outfile"
}

server_gone()
{
! kill -0 "$guest" 2> /dev/null
}

# Start a server guest in the background and wait for pattern in its output.
start_server()
{
local pattern="$1"
shift
srv_outfile="$(mktemp)"
"$@" > "$srv_outfile" 2>&1 &
guest=$!
wait_for 60 "server output matching $pattern" server_printed "$pattern"
}

await_server_exit()
{
wait_for 10 "$1 exit" server_gone
wait "$guest" || {
guest=""
fail "$1 exited non-zero"
}
guest=""
}

run_node()
{
run_capture node-compute elfuse-oci-node-compute-ok \
"$BIN" run --entrypoint /usr/local/bin/node "$MIRROR/node:22-alpine" \
-e "$(cat "$WL/node-compute.js")"

start_server '^PORT=' \
"$BIN" run --entrypoint /usr/local/bin/node "$MIRROR/node:22-alpine" \
-e "$(cat "$WL/node-server.js")"
local port
port="$(awk -F= '/^PORT=/{print $2; exit}' "$srv_outfile")"
# Flushed output can precede listen readiness.
wait_for 30 "node server on 127.0.0.1:$port" nc -z 127.0.0.1 "$port"

local i=0 reqs=100 body
while [ "$i" -lt "$reqs" ]; do
body="$(curl -fsS "http://127.0.0.1:$port/")" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curl has no deadline, so a guest that accepts the connection and then stops responding holds the self-hosted runner until the job timeout. The /quit request below can hang the same way before await_server_exit gets to start its 10 second check. Add --connect-timeout 5 --max-time 10 to both calls.

|| fail "node server request $i failed"
[ "$body" = elfuse-node-server-ok ] \
|| fail "node server request $i returned '$body'"
i=$((i + 1))
done
printf 'node server answered %d requests\n' "$reqs"

# The guest can reset /quit while exiting successfully.
curl -fsS -o /dev/null "http://127.0.0.1:$port/quit" || true
await_server_exit "node server after /quit"
}

run_redis()
{
# Redis treats port 0 as disabled.
local port="" try=0
while [ "$try" -lt 10 ]; do
port=$((20000 + RANDOM % 20000))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a race between the nc -z probe and redis binding the port. Runners on this host share loopback, so another lane can take the port in between, and that shows up as "server guest exited before readiness" with nothing pointing at the real cause. As a follow-up, retry start_server with a fresh port when the guest exits before it is ready.

if ! nc -z 127.0.0.1 "$port" 2> /dev/null; then
break
fi
port=""
try=$((try + 1))
done
[ -n "$port" ] || fail "no free loopback port for redis after 10 probes"

# Make BGSAVE the only background save.
start_server 'Ready to accept connections' \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clean at the start of the lane leaves the store empty, so this run pulls, unpacks and seals redis:7-alpine inside the 60 second readiness window that start_server allows. A slow pull from the mirror then fails as a server timeout. There is a second effect: during the pull, $guest is still the Python wrapper and not elfuse, so on_exit kills only the wrapper and leaves its crane, umoci or hdiutil child running against the shared store. Pull the image first, so the window measures only server startup and $guest is always elfuse:

Suggested change
start_server 'Ready to accept connections' \
"$BIN" pull "$MIRROR/redis:7-alpine" > /dev/null
start_server 'Ready to accept connections' \

"$BIN" run --entrypoint /usr/local/bin/redis-server "$MIRROR/redis:7-alpine" \
--bind 127.0.0.1 --port "$port" --save '' --dir /data
wait_for 30 "redis server on 127.0.0.1:$port" nc -z 127.0.0.1 "$port"

run_capture redis-driver elfuse-oci-redis-workload-ok \
"$BIN" run --entrypoint /bin/sh "$MIRROR/redis:7-alpine" \
-c "$(cat "$WL/redis-driver.sh")" sh "$port"

await_server_exit "redis server after SHUTDOWN"
}

command -v nc > /dev/null 2>&1 || {
echo "nc is required" >&2
exit 2
}

"$BIN" clean > /dev/null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean deletes the images, refs, run and tmp directories, so the per-key, per-runner store set in build.yml never keeps anything. Every push downloads and seals gcc:14, eclipse-temurin:21, golang:1.23-alpine and the rest again, on six parallel jobs. That costs time and makes every run depend on the registry. oci-smoke.sh already tests clean, so this lane could drop both calls (here and at the end) and keep the cache. Pinning the images by digest would also keep the image under test fixed between runs.


case "$key" in
python)
run_capture python elfuse-oci-python-workload-ok \
"$BIN" run --entrypoint /usr/local/bin/python3 "$MIRROR/python:3.12-slim" \
-c "$(cat "$WL/python-workload.py")"

# A socket path too deep for the host's sun_path is shortened there and
# must read back as the guest bound it.
run_capture 'unix socket' elfuse-unix-sock-ok \
"$BIN" run --entrypoint /usr/local/bin/python3 "$MIRROR/python:3.12-slim" \
-c "$(cat "$WL/unix-socket.py")"
;;
node) run_node ;;
redis) run_redis ;;
go)
run_capture go elfuse-oci-go-workload-ok \
"$BIN" run "$MIRROR/golang:1.23-alpine" /bin/sh -c "$(cat "$WL/go-workload.sh")"
;;
jvm)
run_capture jvm elfuse-oci-jvm-workload-ok \
"$BIN" run "$MIRROR/eclipse-temurin:21" /bin/sh -c "$(cat "$WL/jvm-workload.sh")"
;;
c)
run_capture c elfuse-oci-c-workload-ok \
"$BIN" run "$MIRROR/gcc:14" /bin/sh -c "$(cat "$WL/c-workload.sh")"
;;
*) fail "unknown runtime key: $key" ;;
esac

"$BIN" clean > /dev/null

echo "runtime $key OK"
36 changes: 2 additions & 34 deletions scripts/ci/oci-smoke.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Drive scripts/elfuse-oci.py end to end under elfuse. Usage:
# ELFUSE_OCI_STORE=<store> scripts/ci/oci-smoke.sh
set -euo pipefail
# shellcheck source=scripts/ci/oci-lib.sh
. "$(dirname "$0")/oci-lib.sh"

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BIN="$ROOT/scripts/elfuse-oci.py"
Expand All @@ -27,39 +28,6 @@ on_exit()
}
trap on_exit EXIT

fail()
{
echo "FAIL: $*" >&2
exit 1
}

# Drain stdin so pipefail does not report the producer's SIGPIPE.
expect_grep()
{
grep -F -- "$1" > /dev/null
}

run_capture()
{
local desc="$1" pattern="$2" out
shift 2
out="$("$@")" || fail "$desc: exited $?: $out"
printf '%s: %s\n' "$desc" "$out"
printf '%s\n' "$out" | expect_grep "$pattern" || fail "$desc: output lacks $pattern"
}

# Poll a predicate without letting its failures escape set -e.
wait_for()
{
local timeout="$1" desc="$2" i=0
shift 2
while ! "$@" > /dev/null; do
i=$((i + 1))
[ "$i" -lt $((timeout * 2)) ] || fail "timed out after ${timeout}s waiting for: $desc"
sleep 0.5
done
}

runs_gone()
{
[ -z "$(ls "$ELFUSE_OCI_STORE/run" 2> /dev/null)" ]
Expand Down
Loading
Loading