Skip to content
Draft
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
74 changes: 74 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,76 @@ 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 stores. A store
# is kept between runs and named for the scripts that build and pin its
# images, so a change to either starts a fresh store and removes the rest.
env:
STORE_HASH: ${{ hashFiles('scripts/elfuse-oci.py', 'scripts/ci/oci-runtime.sh') }}
run: |
set -euo pipefail
chmod +x build/elfuse
stores="$HOME/.cache/elfuse-ci/oci-runtime/${{ matrix.key }}-$RUNNER_NAME"
store="$stores/$STORE_HASH"
for old in "$stores"/*; do
if [ -d "$old" ] && [ "$old" != "$store" ]; then
ELFUSE_OCI_STORE="$old" scripts/elfuse-oci.py clean > /dev/null
rm -rf "$old"
fi
done
ELFUSE_OCI_STORE="$store" 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`, and async preemption of a spinning goroutine |
| `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` |
11 changes: 11 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,14 @@ 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
```

The node and redis lanes also need `nc`, and the node lane `curl`. Unlike the
smoke lane, a runtime lane leaves its images in the store, so a later run skips
the pull.
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
}
180 changes: 180 additions & 0 deletions scripts/ci/oci-runtime.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/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

# Pinned by digest: the store is kept between runs, and a run reuses the digest
# its reference first resolved to.
PYTHON=$MIRROR/python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
NODE=$MIRROR/node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
REDIS=$MIRROR/redis:7-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
GOLANG=$MIRROR/golang:1.23-alpine@sha256:383395b794dffa5b53012a212365d40c8e37109a626ca30d6151c8348d380b5f
TEMURIN=$MIRROR/eclipse-temurin:21@sha256:3e3c176ffed168beb42c607be9bc1639b466cf00261a0fb04425562c9d0c5c2b
GCC=$MIRROR/gcc:14@sha256:6f37e44a9b41181de5dd97366ec732652f5d895a72b5549c64694efed50a97e8

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.
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

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

server_printed_or_gone()
{
server_gone || 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.
# Return 1 if the guest exits first.
start_server()
{
local pattern="$1"
shift
srv_outfile="${srv_outfile:-$(mktemp)}"
"$@" > "$srv_outfile" 2>&1 &
guest=$!
wait_for 60 "server output matching $pattern" server_printed_or_gone "$pattern"
! server_gone
}

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

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

start_server '^PORT=' \
"$BIN" run --entrypoint /usr/local/bin/node "$NODE" \
-e "$(cat "$WL/node-server.js")" || fail "node server exited before readiness"
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
local fetch=(curl -fsS --connect-timeout 5 --max-time 10)
while [ "$i" -lt "$reqs" ]; do
body="$("${fetch[@]}" "http://127.0.0.1:$port/")" \
|| 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.
"${fetch[@]}" -o /dev/null "http://127.0.0.1:$port/quit" || true
await_server_exit "node server after /quit"
}

run_redis()
{
require_nc

# Warm the image, so the readiness wait covers only the server's startup.
# pull would resolve the digest against the registry even when it is stored.
"$BIN" run --entrypoint /bin/true "$REDIS"

# Redis treats port 0 as disabled, and another process can bind the chosen
# port first; only that failure retries on another port.
local port try=0
while :; do
port=$((20000 + RANDOM % 20000))
Comment thread
henrybear327 marked this conversation as resolved.
# Make BGSAVE the only background save.
start_server 'Ready to accept connections' \
"$BIN" run --entrypoint /usr/local/bin/redis-server "$REDIS" \
--bind 127.0.0.1 --port "$port" --save '' --dir /data && break
try=$((try + 1))
if [ "$try" -ge 10 ] || ! grep -q 'bind: Address in use' "$srv_outfile"; then
fail "redis server exited before readiness"
fi
done
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 "$REDIS" \
-c "$(cat "$WL/redis-driver.sh")" sh "$port"

await_server_exit "redis server after SHUTDOWN"
}

case "$key" in
python)
run_capture python elfuse-oci-python-workload-ok \
"$BIN" run --entrypoint /usr/local/bin/python3 "$PYTHON" \
-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 "$PYTHON" \
-c "$(cat "$WL/unix-socket.py")"
;;
node) run_node ;;
redis) run_redis ;;
go)
run_capture go elfuse-oci-go-workload-ok \
"$BIN" run "$GOLANG" /bin/sh -c "$(cat "$WL/go-workload.sh")"
;;
jvm)
run_capture jvm elfuse-oci-jvm-workload-ok \
"$BIN" run "$TEMURIN" /bin/sh -c "$(cat "$WL/jvm-workload.sh")"
;;
c)
run_capture c elfuse-oci-c-workload-ok \
"$BIN" run "$GCC" /bin/sh -c "$(cat "$WL/c-workload.sh")"
;;
*) fail "unknown runtime key: $key" ;;
esac

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