feat: add pyhl container image for device plugin deployments #353
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Test examples (regression gate) | |
| on: | |
| workflow_dispatch: | |
| pull_request: | |
| branches: [main] | |
| push: | |
| branches: [main] | |
| paths: | |
| - "examples/**" | |
| - "runtimes/**" | |
| - "host/**" | |
| - ".github/workflows/test-examples.yml" | |
| # Pushing a new commit to the same ref cancels any in-flight run on | |
| # the previous commit — don't waste CI on stale code. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Build-only regression gate — each example is built from scratch | |
| # (rootfs + kernel) in its own job. We do NOT execute the guests | |
| # because GitHub-hosted Linux runners don't expose /dev/kvm; hyperlight | |
| # aborts with "No hypervisor was found" before any guest code runs. | |
| # | |
| # Catches the kinds of regressions that hit us this session: | |
| # missing CONFIG_* guards on renamed kernel branches, NuGet version | |
| # drift, Dockerfile dependency issues. The runtime-test job below | |
| # runs each example under KVM on ubuntu-latest and greps the guest | |
| # output for an expected string, so actual runtime crashes (like the | |
| # cpiovfs-vs-ramfs type-confusion that broke powershell) surface too. | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_BASE: ghcr.io/${{ github.repository }} | |
| jobs: | |
| build-example: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: read | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| example: | |
| - helloworld-c | |
| - rust | |
| - go | |
| - shell | |
| - python | |
| - python-tools | |
| - nodejs | |
| - dotnet | |
| - dotnet-nativeaot | |
| - hostfs-posix-c | |
| - hostfs-posix-py | |
| - multifn-c | |
| - python-agent | |
| - python-agent-driver | |
| - powershell | |
| - networking-py | |
| - go-http | |
| - dotnet-http | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.25.1" | |
| cache: false | |
| - name: Install just | |
| uses: extractions/setup-just@v2 | |
| - name: Cache kraft-hyperlight | |
| id: kraft-cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: /usr/local/bin/kraft-hyperlight | |
| key: kraft-hyperlight-linux-${{ hashFiles('.github/workflows/test-examples.yml') }} | |
| - name: Build kraft-hyperlight | |
| if: steps.kraft-cache.outputs.cache-hit != 'true' | |
| run: | | |
| git clone --branch hyperlight-platform --depth 1 \ | |
| https://github.com/danbugs/kraftkit.git /tmp/kraftkit | |
| cd /tmp/kraftkit && go build -o kraft-hyperlight ./cmd/kraft | |
| sudo mv kraft-hyperlight /usr/local/bin/ | |
| - name: Build local-python-base images (python-agent-driver only) | |
| if: matrix.example == 'python-agent-driver' | |
| env: | |
| DOCKER_BUILDKIT: "0" | |
| run: | | |
| docker build --target base -t local-python-base-dev:latest \ | |
| -f runtimes/python.Dockerfile runtimes/ | |
| docker build -t local-python-base:latest \ | |
| -f runtimes/python.Dockerfile runtimes/ | |
| - name: Build rootfs | |
| working-directory: examples/${{ matrix.example }} | |
| env: | |
| DOCKER_BUILDKIT: "0" | |
| run: | | |
| just rootfs | |
| - name: Build kernel | |
| working-directory: examples/${{ matrix.example }} | |
| run: | | |
| kraft-hyperlight --no-prompt build --plat hyperlight --arch x86_64 || true | |
| # Fallback: kraft's source-resolution is flaky — it can partially | |
| # clone (e.g. unikraft but not libelf) or leave dirs empty. Check | |
| # for the actual kernel output; if missing, nuke everything and | |
| # clone + build from scratch. | |
| if ! ls .unikraft/build/*_hyperlight-x86_64 >/dev/null 2>&1; then | |
| echo "::warning::kraft build produced no kernel; cloning manually and rebuilding" | |
| UK_SOURCE=$(awk '/^unikraft:/{f=1} f && /source:/{print $2; exit}' kraft.yaml) | |
| UK_BRANCH=$(awk '/^unikraft:/{f=1} f && /version:/{print $2; exit}' kraft.yaml) | |
| ELF_SOURCE=$(awk '/app-elfloader:/{f=1} f && /source:/{print $2; exit}' kraft.yaml) | |
| ELF_BRANCH=$(awk '/app-elfloader:/{f=1} f && /version:/{print $2; exit}' kraft.yaml) | |
| mkdir -p .unikraft/apps .unikraft/libs | |
| rm -rf .unikraft/unikraft .unikraft/apps/elfloader .unikraft/libs/libelf .unikraft/build | |
| git clone --branch "$UK_BRANCH" --depth 1 "$UK_SOURCE" .unikraft/unikraft | |
| git clone --branch "$ELF_BRANCH" --depth 1 "$ELF_SOURCE" .unikraft/apps/elfloader | |
| git clone --branch staging --depth 1 https://github.com/unikraft/lib-libelf.git .unikraft/libs/libelf | |
| kraft-hyperlight --no-prompt build --plat hyperlight --arch x86_64 | |
| fi | |
| - name: Verify artifacts produced | |
| working-directory: examples/${{ matrix.example }} | |
| run: | | |
| kernel=$(ls .unikraft/build/*_hyperlight-x86_64 2>/dev/null | head -1) | |
| cpio=$(ls *-initrd.cpio initrd.cpio 2>/dev/null | head -1) | |
| if [ -z "$kernel" ] || [ ! -f "$kernel" ]; then | |
| echo "FAIL: no kernel artifact under .unikraft/build/" | |
| ls -la .unikraft/build/ 2>/dev/null || echo " (no .unikraft/build dir)" | |
| exit 1 | |
| fi | |
| if [ -z "$cpio" ] || [ ! -f "$cpio" ]; then | |
| echo "FAIL: no CPIO rootfs produced" | |
| ls -la *.cpio 2>/dev/null || echo " (no .cpio files)" | |
| exit 1 | |
| fi | |
| echo "PASS: kernel=$(stat -c '%s' $kernel) bytes, cpio=$(stat -c '%s' $cpio) bytes" | |
| # Runtime test. GitHub-hosted ubuntu-latest runners DO expose /dev/kvm | |
| # (verified 2026-04-20); the udev snippet below makes the device node | |
| # world-accessible so Hyperlight can create a VM under the default | |
| # runner user. A presence check still short-circuits the job with a | |
| # ::warning:: if a future runner ships without KVM, so it degrades | |
| # gracefully rather than failing mysteriously. | |
| # | |
| # Full matrix — every example actually runs and its expected output is | |
| # greped from the guest console. This is the real regression gate; | |
| # build-example above only catches link-time errors. | |
| # | |
| # Memory values are read from each example's Justfile at runtime | |
| # (the `memory` variable), so the matrix only carries args/expect. | |
| runtime-test: | |
| runs-on: ubuntu-latest | |
| needs: build-example | |
| permissions: | |
| contents: read | |
| packages: read | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - example: helloworld-c | |
| args: "" | |
| expect: "Hello from C on Hyperlight" | |
| - example: rust | |
| args: "" | |
| expect: "Hello from Rust on Hyperlight" | |
| - example: go | |
| args: "" | |
| expect: "Hello from Go on Hyperlight" | |
| - example: shell | |
| args: "-- /demo.sh" | |
| expect: "Hello from Shell on Hyperlight" | |
| - example: python | |
| args: "-- /hello.py" | |
| expect: "Hello from Python on Hyperlight" | |
| - example: python-tools | |
| args: "-- /test_tools.py" | |
| expect: "Tool returned" | |
| needs_echo_tool: true | |
| - example: nodejs | |
| args: "-- /app/hello.js" | |
| expect: "Hello from Node.js on Hyperlight" | |
| - example: dotnet | |
| args: "" | |
| expect: "Hello, World! From .NET on Hyperlight" | |
| - example: dotnet-nativeaot | |
| args: "" | |
| expect: "Hello, World! From .NET NativeAOT on Hyperlight" | |
| - example: hostfs-posix-c | |
| args: "" | |
| expect: "done\\." | |
| needs_mount: true | |
| - example: hostfs-posix-py | |
| args: "-- /hostfs_demo.py" | |
| expect: "done\\." | |
| needs_mount: true | |
| - example: multifn-c | |
| args: "" | |
| expect: "RUN: world" | |
| driver: multifn-test | |
| - example: python-agent | |
| args: "-- /agent.py" | |
| expect: "done\\." | |
| needs_mount: true | |
| - example: python-agent-driver | |
| args: "" | |
| expect: "hello from driver" | |
| driver: pydriver-run | |
| - example: powershell | |
| args: "-- -NoProfile -File /scripts/hello.ps1" | |
| expect: "Hello, World! From PowerShell on Hyperlight" | |
| - example: networking-py | |
| args: "--net -- /urllib_get.py" | |
| expect: "SUCCESS: urllib GET worked!" | |
| - example: networking-py | |
| args: "--net -- /urllib_get_no_timeout.py" | |
| expect: "SUCCESS: urllib GET \\(no timeout\\) worked!" | |
| - example: networking-py | |
| args: "--port 8080 -- /echo_server_test.py" | |
| expect: "SUCCESS: bind\\+listen on port 8080 allowed" | |
| - example: go-http | |
| args: "--port 8080 -- /bin/server" | |
| expect: "Hello from Hyperlight-Unikraft!" | |
| http_port: "8080" | |
| - example: dotnet-http | |
| args: "--port 8080 -- /app/KestrelHyperlight" | |
| expect: "Hello from Kestrel on Hyperlight!" | |
| http_port: "8080" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: host -> target | |
| - name: Enable KVM permissions (no-op if device absent) | |
| run: | | |
| echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | |
| | sudo tee /etc/udev/rules.d/99-kvm4all.rules | |
| sudo udevadm control --reload-rules | |
| sudo udevadm trigger --name-match=kvm || true | |
| - name: Check KVM availability | |
| id: kvm_check | |
| run: | | |
| if [ -c /dev/kvm ] && [ -r /dev/kvm ] && [ -w /dev/kvm ]; then | |
| echo "available=true" >> $GITHUB_OUTPUT | |
| ls -la /dev/kvm | |
| else | |
| echo "available=false" >> $GITHUB_OUTPUT | |
| echo "::warning::/dev/kvm is not available on this runner; runtime test skipped" | |
| fi | |
| - name: Install Go (for kraft-hyperlight) | |
| if: steps.kvm_check.outputs.available == 'true' | |
| run: | | |
| curl -sL https://go.dev/dl/go1.25.1.linux-amd64.tar.gz | sudo tar -C /usr/local -xz | |
| echo "/usr/local/go/bin" >> $GITHUB_PATH | |
| - name: Install just | |
| if: steps.kvm_check.outputs.available == 'true' | |
| uses: extractions/setup-just@v2 | |
| - name: Build kraft-hyperlight | |
| if: steps.kvm_check.outputs.available == 'true' | |
| run: | | |
| git clone --branch hyperlight-platform --depth 1 \ | |
| https://github.com/danbugs/kraftkit.git /tmp/kraftkit | |
| cd /tmp/kraftkit && go build -o kraft-hyperlight ./cmd/kraft | |
| sudo mv kraft-hyperlight /usr/local/bin/ | |
| - name: Build host binaries | |
| if: steps.kvm_check.outputs.available == 'true' | |
| run: | | |
| cd host | |
| cargo build --release --features wasm-host-fns --bin hyperlight-unikraft --bin multifn-test --bin pydriver-run | |
| sudo cp target/release/hyperlight-unikraft /usr/local/bin/ | |
| - name: Build echo Wasm host function | |
| if: steps.kvm_check.outputs.available == 'true' && matrix.needs_echo_tool == true | |
| run: | | |
| rustup target add wasm32-wasip1 | |
| cargo build --manifest-path examples/echo-wasm-host-fxn/Cargo.toml --release --target wasm32-wasip1 | |
| - name: Build local-python-base images (python-agent-driver only) | |
| if: steps.kvm_check.outputs.available == 'true' && matrix.example == 'python-agent-driver' | |
| env: | |
| DOCKER_BUILDKIT: "0" | |
| run: | | |
| docker build --target base -t local-python-base-dev:latest \ | |
| -f runtimes/python.Dockerfile runtimes/ | |
| docker build -t local-python-base:latest \ | |
| -f runtimes/python.Dockerfile runtimes/ | |
| - name: Build rootfs + kernel | |
| if: steps.kvm_check.outputs.available == 'true' | |
| working-directory: examples/${{ matrix.example }} | |
| env: | |
| DOCKER_BUILDKIT: "0" | |
| run: | | |
| just rootfs | |
| kraft-hyperlight --no-prompt build --plat hyperlight --arch x86_64 || true | |
| if ! ls .unikraft/build/*_hyperlight-x86_64 >/dev/null 2>&1; then | |
| echo "::warning::kraft build produced no kernel; cloning manually and rebuilding" | |
| UK_SOURCE=$(awk '/^unikraft:/{f=1} f && /source:/{print $2; exit}' kraft.yaml) | |
| UK_BRANCH=$(awk '/^unikraft:/{f=1} f && /version:/{print $2; exit}' kraft.yaml) | |
| ELF_SOURCE=$(awk '/app-elfloader:/{f=1} f && /source:/{print $2; exit}' kraft.yaml) | |
| ELF_BRANCH=$(awk '/app-elfloader:/{f=1} f && /version:/{print $2; exit}' kraft.yaml) | |
| mkdir -p .unikraft/apps .unikraft/libs | |
| rm -rf .unikraft/unikraft .unikraft/apps/elfloader .unikraft/libs/libelf .unikraft/build | |
| git clone --branch "$UK_BRANCH" --depth 1 "$UK_SOURCE" .unikraft/unikraft | |
| git clone --branch "$ELF_BRANCH" --depth 1 "$ELF_SOURCE" .unikraft/apps/elfloader | |
| git clone --branch staging --depth 1 https://github.com/unikraft/lib-libelf.git .unikraft/libs/libelf | |
| kraft-hyperlight --no-prompt build --plat hyperlight --arch x86_64 | |
| fi | |
| - name: Prepare pydriver-run script (python-agent-driver only) | |
| if: steps.kvm_check.outputs.available == 'true' && matrix.driver == 'pydriver-run' | |
| run: echo 'print("hello from driver")' > /tmp/tiny.py | |
| - name: Run and check output | |
| if: steps.kvm_check.outputs.available == 'true' | |
| working-directory: examples/${{ matrix.example }} | |
| run: | | |
| # Strict mode: expected output must appear AND the driver | |
| # process must exit cleanly. A guest that prints the right | |
| # banner and then crashes no longer counts as passing. | |
| # NOTE: `set -o pipefail` is scoped to the driver invocation | |
| # below, not applied globally — otherwise the artifact-lookup | |
| # pipelines fail when one of the glob patterns doesn't match. | |
| kernel=$(ls .unikraft/build/*_hyperlight-x86_64 2>/dev/null | head -1 || true) | |
| cpio=$(ls *-initrd.cpio initrd.cpio 2>/dev/null | head -1 || true) | |
| if [ -z "$kernel" ] || [ -z "$cpio" ]; then | |
| echo "FAIL: missing kernel ($kernel) or initrd ($cpio)" | |
| ls -la .unikraft/build/ *.cpio 2>&1 | head -40 || true | |
| exit 1 | |
| fi | |
| # Read memory from the example's Justfile (single source of truth). | |
| memory=$(grep '^memory' Justfile | sed 's/memory.*:= *"//' | sed 's/".*//') | |
| if [ -z "$memory" ]; then | |
| echo "::notice::No memory variable in Justfile, using binary default" | |
| fi | |
| expect='${{ matrix.expect }}' | |
| mount_args="" | |
| if [ "${{ matrix.needs_mount }}" = "true" ]; then | |
| mount_dir="$RUNNER_TEMP/hostfs-${{ matrix.example }}" | |
| mkdir -p "$mount_dir" | |
| mount_args="--mount $mount_dir:/host" | |
| fi | |
| tool_args="" | |
| if [ "${{ matrix.needs_echo_tool }}" = "true" ]; then | |
| tool_args="--tool echo=../echo-wasm-host-fxn/target/wasm32-wasip1/release/echo-wasm-host-fxn.wasm" | |
| fi | |
| # HTTP server examples: start in background, poll, curl, kill. | |
| http_port="${{ matrix.http_port }}" | |
| if [ -n "$http_port" ]; then | |
| mem_args="" | |
| if [ -n "$memory" ]; then | |
| mem_args="-m $memory" | |
| fi | |
| hyperlight-unikraft -q $mem_args "$kernel" --initrd "$cpio" $mount_args $tool_args ${{ matrix.args }} & | |
| server_pid=$! | |
| sleep 3 | |
| ready=0 | |
| for i in $(seq 1 30); do | |
| if curl -s --max-time 10 "http://127.0.0.1:${http_port}" >/dev/null 2>&1; then | |
| ready=1 | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| if [ "$ready" -ne 1 ]; then | |
| echo "FAIL: server did not become ready within timeout" | |
| kill "$server_pid" 2>/dev/null || true | |
| exit 1 | |
| fi | |
| response=$(curl -s --max-time 10 "http://127.0.0.1:${http_port}") | |
| kill "$server_pid" 2>/dev/null || true | |
| wait "$server_pid" 2>/dev/null || true | |
| echo "=== HTTP response ===" | |
| echo "$response" | |
| if echo "$response" | grep -qF "$expect"; then | |
| echo "PASS: matched /$expect/" | |
| else | |
| echo "FAIL: did not match /$expect/" | |
| exit 1 | |
| fi | |
| exit 0 | |
| fi | |
| case "${{ matrix.driver }}" in | |
| multifn-test) | |
| cmd=(timeout 60 /home/runner/work/hyperlight-unikraft/hyperlight-unikraft/host/target/release/multifn-test "$kernel" "$cpio") | |
| ;; | |
| pydriver-run) | |
| cmd=(timeout 120 /home/runner/work/hyperlight-unikraft/hyperlight-unikraft/host/target/release/pydriver-run "$kernel" "$cpio" /tmp/tiny.py) | |
| ;; | |
| *) | |
| mem_args="" | |
| if [ -n "$memory" ]; then | |
| mem_args="-m $memory" | |
| fi | |
| cmd=(timeout 120 hyperlight-unikraft -q $mem_args "$kernel" --initrd "$cpio" $mount_args $tool_args ${{ matrix.args }}) | |
| ;; | |
| esac | |
| set +e | |
| output=$("${cmd[@]}" 2>&1) | |
| exit_code=$? | |
| set -e | |
| output=$(printf '%s' "$output" | tr -d '\0') | |
| echo "=== output (exit=$exit_code) ===" | |
| echo "$output" | head -40 | |
| matched=0 | |
| echo "$output" | grep -qE "$expect" && matched=1 | |
| if [ "$matched" -ne 1 ]; then | |
| echo "FAIL: did not match /$expect/" | |
| exit 1 | |
| fi | |
| if [ "$exit_code" -ne 0 ]; then | |
| echo "FAIL: driver exited with $exit_code after printing expected output" | |
| exit 1 | |
| fi | |
| echo "PASS: matched /$expect/ and exited cleanly" | |
| # Build each example's kernel+initrd on Linux once and upload as a | |
| # per-example artifact. Matches the Windows runtime matrix so Windows | |
| # gets the same example coverage without needing kraft-hyperlight or | |
| # a Linux docker toolchain on Windows. | |
| package-images-for-windows: | |
| runs-on: ubuntu-latest | |
| needs: build-example | |
| permissions: | |
| contents: read | |
| packages: read | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| example: | |
| - helloworld-c | |
| - rust | |
| - go | |
| - shell | |
| - python | |
| - python-tools | |
| - nodejs | |
| - dotnet | |
| - dotnet-nativeaot | |
| - hostfs-posix-c | |
| - hostfs-posix-py | |
| - multifn-c | |
| - python-agent | |
| - python-agent-driver | |
| - powershell | |
| - networking-py | |
| - go-http | |
| - dotnet-http | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version: "1.25.1" | |
| cache: false | |
| - name: Install just | |
| uses: extractions/setup-just@v2 | |
| - name: Cache kraft-hyperlight | |
| id: kraft-cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: /usr/local/bin/kraft-hyperlight | |
| key: kraft-hyperlight-linux-${{ hashFiles('.github/workflows/test-examples.yml') }} | |
| - name: Build kraft-hyperlight | |
| if: steps.kraft-cache.outputs.cache-hit != 'true' | |
| run: | | |
| git clone --branch hyperlight-platform --depth 1 \ | |
| https://github.com/danbugs/kraftkit.git /tmp/kraftkit | |
| cd /tmp/kraftkit && go build -o kraft-hyperlight ./cmd/kraft | |
| sudo mv kraft-hyperlight /usr/local/bin/ | |
| - name: Build local-python-base images (python-agent-driver only) | |
| if: matrix.example == 'python-agent-driver' | |
| env: | |
| DOCKER_BUILDKIT: "0" | |
| run: | | |
| docker build --target base -t local-python-base-dev:latest \ | |
| -f runtimes/python.Dockerfile runtimes/ | |
| docker build -t local-python-base:latest \ | |
| -f runtimes/python.Dockerfile runtimes/ | |
| - name: Build rootfs + kernel | |
| working-directory: examples/${{ matrix.example }} | |
| env: | |
| DOCKER_BUILDKIT: "0" | |
| run: | | |
| just rootfs | |
| kraft-hyperlight --no-prompt build --plat hyperlight --arch x86_64 || true | |
| if ! ls .unikraft/build/*_hyperlight-x86_64 >/dev/null 2>&1; then | |
| echo "::warning::kraft build produced no kernel; cloning manually and rebuilding" | |
| UK_SOURCE=$(awk '/^unikraft:/{f=1} f && /source:/{print $2; exit}' kraft.yaml) | |
| UK_BRANCH=$(awk '/^unikraft:/{f=1} f && /version:/{print $2; exit}' kraft.yaml) | |
| ELF_SOURCE=$(awk '/app-elfloader:/{f=1} f && /source:/{print $2; exit}' kraft.yaml) | |
| ELF_BRANCH=$(awk '/app-elfloader:/{f=1} f && /version:/{print $2; exit}' kraft.yaml) | |
| mkdir -p .unikraft/apps .unikraft/libs | |
| rm -rf .unikraft/unikraft .unikraft/apps/elfloader .unikraft/libs/libelf .unikraft/build | |
| git clone --branch "$UK_BRANCH" --depth 1 "$UK_SOURCE" .unikraft/unikraft | |
| git clone --branch "$ELF_BRANCH" --depth 1 "$ELF_SOURCE" .unikraft/apps/elfloader | |
| git clone --branch staging --depth 1 https://github.com/unikraft/lib-libelf.git .unikraft/libs/libelf | |
| kraft-hyperlight --no-prompt build --plat hyperlight --arch x86_64 | |
| fi | |
| - name: Package image | |
| working-directory: examples/${{ matrix.example }} | |
| run: | | |
| mkdir -p /tmp/image | |
| cp .unikraft/build/*_hyperlight-x86_64 /tmp/image/kernel | |
| cpio_file=$(ls *-initrd.cpio 2>/dev/null | head -1) | |
| if [ -z "$cpio_file" ]; then | |
| cpio_file=$(ls initrd.cpio 2>/dev/null | head -1) | |
| fi | |
| cp "$cpio_file" /tmp/image/initrd.cpio | |
| ls -la /tmp/image/ | |
| - name: Upload image artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: windows-image-${{ matrix.example }} | |
| path: /tmp/image/ | |
| retention-days: 1 | |
| if-no-files-found: error | |
| # Windows runtime — 1:1 with Linux runtime-test: same examples, same | |
| # expected output patterns, same drivers (multifn-test, pydriver-run). | |
| # The only difference is we can't build the images on Windows (no | |
| # kraft-hyperlight + no Linux docker), so we download them from the | |
| # package-images-for-windows job. | |
| # | |
| # Memory values are read from each example's Justfile at runtime. | |
| runtime-test-windows: | |
| runs-on: windows-latest | |
| needs: package-images-for-windows | |
| permissions: | |
| contents: read | |
| packages: read | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - example: helloworld-c | |
| args: "" | |
| expect: "Hello from C on Hyperlight" | |
| - example: rust | |
| args: "" | |
| expect: "Hello from Rust on Hyperlight" | |
| - example: go | |
| args: "" | |
| expect: "Hello from Go on Hyperlight" | |
| - example: shell | |
| args: "-- /demo.sh" | |
| expect: "Hello from Shell on Hyperlight" | |
| - example: python | |
| args: "-- /hello.py" | |
| expect: "Hello from Python on Hyperlight" | |
| - example: python-tools | |
| args: "-- /test_tools.py" | |
| expect: "Tool returned" | |
| needs_echo_tool: true | |
| - example: nodejs | |
| args: "-- /app/hello.js" | |
| expect: "Hello from Node.js on Hyperlight" | |
| - example: dotnet | |
| args: "" | |
| expect: "Hello, World! From .NET on Hyperlight" | |
| - example: dotnet-nativeaot | |
| args: "" | |
| expect: "Hello, World! From .NET NativeAOT on Hyperlight" | |
| - example: hostfs-posix-c | |
| args: "" | |
| expect: "done\\." | |
| needs_mount: true | |
| - example: hostfs-posix-py | |
| args: "-- /hostfs_demo.py" | |
| expect: "done\\." | |
| needs_mount: true | |
| - example: multifn-c | |
| args: "" | |
| expect: "RUN: world" | |
| driver: multifn-test | |
| - example: python-agent | |
| args: "-- /agent.py" | |
| expect: "done\\." | |
| needs_mount: true | |
| - example: python-agent-driver | |
| args: "" | |
| expect: "hello from driver" | |
| driver: pydriver-run | |
| - example: powershell | |
| args: "-- -NoProfile -File /scripts/hello.ps1" | |
| expect: "Hello, World! From PowerShell on Hyperlight" | |
| - example: networking-py | |
| args: "--net -- /urllib_get.py" | |
| expect: "SUCCESS: urllib GET worked!" | |
| - example: networking-py | |
| args: "--net -- /urllib_get_no_timeout.py" | |
| expect: "SUCCESS: urllib GET \\(no timeout\\) worked!" | |
| - example: networking-py | |
| args: "--port 8080 -- /echo_server_test.py" | |
| expect: "SUCCESS: bind\\+listen on port 8080 allowed" | |
| - example: go-http | |
| args: "--port 8080 -- /bin/server" | |
| expect: "Hello from Hyperlight-Unikraft!" | |
| http_port: "8080" | |
| - example: dotnet-http | |
| args: "--port 8080 -- /app/KestrelHyperlight" | |
| expect: "Hello from Kestrel on Hyperlight!" | |
| http_port: "8080" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: host -> target | |
| - name: Ensure surrogate build consistency | |
| shell: pwsh | |
| run: | | |
| $hlsExe = "host\target\release\build\hyperlight-host-*\out\..\..\hls\x86_64-pc-windows-msvc\release\hyperlight_surrogate.exe" | |
| if (-not (Resolve-Path $hlsExe -ErrorAction SilentlyContinue)) { | |
| Write-Host "Surrogate missing — clearing hyperlight-host fingerprints to force rebuild" | |
| Get-ChildItem "host\target\release\.fingerprint" -Filter "hyperlight-host-*" -Directory -ErrorAction SilentlyContinue | | |
| Remove-Item -Recurse -Force | |
| } | |
| - name: Build host binaries | |
| shell: pwsh | |
| run: | | |
| cd host | |
| cargo build --release --features wasm-host-fns --bin hyperlight-unikraft --bin multifn-test --bin pydriver-run --bin pyhl | |
| Copy-Item target\release\hyperlight-unikraft.exe $env:USERPROFILE\.cargo\bin\ -Force | |
| Copy-Item target\release\multifn-test.exe $env:USERPROFILE\.cargo\bin\ -Force | |
| Copy-Item target\release\pydriver-run.exe $env:USERPROFILE\.cargo\bin\ -Force | |
| Copy-Item target\release\pyhl.exe $env:USERPROFILE\.cargo\bin\ -Force | |
| - name: Build echo Wasm host function | |
| if: matrix.needs_echo_tool == true | |
| shell: pwsh | |
| run: | | |
| rustup target add wasm32-wasip1 | |
| cargo build --manifest-path examples/echo-wasm-host-fxn/Cargo.toml --release --target wasm32-wasip1 | |
| - name: Download prebuilt image | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: windows-image-${{ matrix.example }} | |
| path: image | |
| - name: Run and check output | |
| shell: pwsh | |
| run: | | |
| $kernel = (Resolve-Path "image/kernel").Path | |
| $cpio = (Resolve-Path "image/initrd.cpio").Path | |
| $expect = '${{ matrix.expect }}' | |
| $driver = '${{ matrix.driver }}' | |
| $runArgs = '${{ matrix.args }}' | |
| $needsMount = '${{ matrix.needs_mount }}' | |
| $needsEchoTool = '${{ matrix.needs_echo_tool }}' | |
| # Read memory from the example's Justfile (single source of truth). | |
| $memory = '' | |
| $justfile = "examples/${{ matrix.example }}/Justfile" | |
| if (Test-Path $justfile) { | |
| $memLine = Get-Content $justfile | Where-Object { $_ -match '^\s*memory\s*:=' } | |
| if ($memLine -match '"([^"]+)"') { | |
| $memory = $Matches[1] | |
| } | |
| } | |
| $mountArgs = @() | |
| if ($needsMount -eq 'true') { | |
| $mountDir = Join-Path $env:RUNNER_TEMP ('hostfs-' + '${{ matrix.example }}') | |
| New-Item -ItemType Directory -Force -Path $mountDir | Out-Null | |
| # Preopen::parse_cli splits on the last ':' and treats the right | |
| # side as the guest path iff it starts with '/', so Windows | |
| # drive-letter colons in $mountDir are handled correctly. | |
| $mountArgs = @('--mount', ($mountDir + ':/host')) | |
| } | |
| $toolArgs = @() | |
| if ($needsEchoTool -eq 'true') { | |
| $toolArgs = @('--tool', 'echo=examples/echo-wasm-host-fxn/target/wasm32-wasip1/release/echo-wasm-host-fxn.wasm') | |
| } | |
| # Strict mode: expected output must appear AND the driver | |
| # must exit 0. Matches Linux semantics. | |
| $PSNativeCommandUseErrorActionPreference = $false | |
| $ErrorActionPreference = 'Continue' | |
| # HTTP server examples: start in background, poll, curl, kill. | |
| $httpPort = '${{ matrix.http_port }}' | |
| if ($httpPort -ne '') { | |
| $argList = @() | |
| if ($runArgs -ne '') { | |
| $argList = $runArgs.Split(' ') | Where-Object { $_ -ne '' } | |
| } | |
| $memArgs = @() | |
| if ($memory -ne '') { | |
| $memArgs = @('-m', $memory) | |
| } | |
| $stderrLog = Join-Path $env:RUNNER_TEMP 'hl-stderr.log' | |
| $proc = Start-Process -FilePath 'hyperlight-unikraft' ` | |
| -ArgumentList (@('-q') + $memArgs + @($kernel, '--initrd', $cpio) + $argList) ` | |
| -PassThru -NoNewWindow -RedirectStandardError $stderrLog | |
| # Give the server time to boot the unikernel and start listening | |
| Start-Sleep -Seconds 5 | |
| $ready = $false | |
| for ($i = 0; $i -lt 30; $i++) { | |
| try { | |
| $null = Invoke-WebRequest -Uri "http://127.0.0.1:${httpPort}" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop | |
| $ready = $true | |
| break | |
| } catch { | |
| Start-Sleep -Seconds 1 | |
| } | |
| } | |
| if (-not $ready) { | |
| Write-Host "FAIL: server did not become ready within timeout" | |
| Write-Host "=== stderr (last 200 lines) ===" | |
| Get-Content $stderrLog -Tail 200 -ErrorAction SilentlyContinue | |
| Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue | |
| exit 1 | |
| } | |
| $raw = (Invoke-WebRequest -Uri "http://127.0.0.1:${httpPort}" -UseBasicParsing -TimeoutSec 10).Content | |
| if ($raw -is [byte[]]) { $resp = [System.Text.Encoding]::UTF8.GetString($raw) } else { $resp = $raw } | |
| Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue | |
| Write-Host "=== HTTP response ===" | |
| Write-Host $resp | |
| if ($resp -match [regex]::Escape($expect)) { | |
| Write-Host "PASS: matched /$expect/" | |
| exit 0 | |
| } else { | |
| Write-Host "FAIL: did not match /$expect/" | |
| exit 1 | |
| } | |
| } | |
| # Helper: run a command with a per-test timeout (seconds). | |
| function Invoke-WithTimeout { | |
| param([int]$Seconds, [string]$Exe, [string[]]$ArgList) | |
| $outFile = Join-Path $env:RUNNER_TEMP 'hl-test-out.log' | |
| $proc = Start-Process -FilePath $Exe -ArgumentList $ArgList ` | |
| -NoNewWindow -RedirectStandardOutput $outFile -RedirectStandardError "$outFile.err" -PassThru | |
| if (-not $proc.WaitForExit($Seconds * 1000)) { | |
| Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue | |
| Write-Host "FAIL: timed out after ${Seconds}s" | |
| Get-Content $outFile -ErrorAction SilentlyContinue | |
| Get-Content "$outFile.err" -ErrorAction SilentlyContinue | |
| exit 1 | |
| } | |
| $stdoutContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue | |
| $stderrContent = Get-Content "$outFile.err" -Raw -ErrorAction SilentlyContinue | |
| $script:out = "$stdoutContent$stderrContent".Trim() | |
| $script:rc = $proc.ExitCode | |
| } | |
| switch ($driver) { | |
| 'multifn-test' { | |
| Invoke-WithTimeout -Seconds 60 -Exe 'multifn-test' -ArgList @($kernel, $cpio) | |
| } | |
| 'pydriver-run' { | |
| "print('hello from driver')" | Out-File -Encoding ascii tiny.py | |
| $tiny = (Resolve-Path "tiny.py").Path | |
| Invoke-WithTimeout -Seconds 120 -Exe 'pydriver-run' -ArgList @($kernel, $cpio, $tiny) | |
| } | |
| default { | |
| $argList = @() | |
| if ($runArgs -ne '') { | |
| $argList = $runArgs.Split(' ') | Where-Object { $_ -ne '' } | |
| } | |
| $memArgs = @() | |
| if ($memory -ne '') { | |
| $memArgs = @('-m', $memory) | |
| } | |
| Invoke-WithTimeout -Seconds 120 -Exe 'hyperlight-unikraft' ` | |
| -ArgList (@('-q') + $memArgs + @($kernel, '--initrd', $cpio) + $mountArgs + $toolArgs + $argList) | |
| } | |
| } | |
| if ($null -eq $out) { $out = '' } | |
| Write-Host "=== output (exit=$rc) ===" | |
| Write-Host $out | |
| if (-not ($out -match $expect)) { | |
| Write-Host "FAIL: did not match /$expect/" | |
| exit 1 | |
| } | |
| if ($rc -ne 0) { | |
| Write-Host "FAIL: driver exited with $rc after printing expected output" | |
| exit 1 | |
| } | |
| Write-Host "PASS: matched /$expect/ and exited cleanly" | |
| exit 0 | |
| # pyhl snapshot-to-disk end-to-end — runs the exact same flow on both | |
| # Linux and Windows: | |
| # 1. pyhl setup --from <src-dir> (warmup + persist snapshot) | |
| # 2. pyhl run hi.py (hello) | |
| # 3. pyhl run pandas_test.py (pandas — exercises the warm snapshot) | |
| # 4. pyhl run --repeat 4 pandas (hermetic rewind, 5 iterations) | |
| # Both OSes consume the python-agent-driver image built on Linux in | |
| # package-images-for-windows. This is the real CoW/restore regression | |
| # gate — MAP_PRIVATE on Linux and PAGE_WRITECOPY+FILE_MAP_COPY on | |
| # Windows both have to stay working. | |
| pyhl-snapshot-test: | |
| needs: package-images-for-windows | |
| permissions: | |
| contents: read | |
| packages: read | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest] | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: host -> target | |
| - name: Ensure surrogate build consistency (Windows only) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $hlsExe = "host\target\release\build\hyperlight-host-*\out\..\..\hls\x86_64-pc-windows-msvc\release\hyperlight_surrogate.exe" | |
| if (-not (Resolve-Path $hlsExe -ErrorAction SilentlyContinue)) { | |
| Write-Host "Surrogate missing — clearing hyperlight-host fingerprints to force rebuild" | |
| Get-ChildItem "host\target\release\.fingerprint" -Filter "hyperlight-host-*" -Directory -ErrorAction SilentlyContinue | | |
| Remove-Item -Recurse -Force | |
| } | |
| # Linux needs /dev/kvm access to run the guest. | |
| - name: Enable KVM permissions (Linux only) | |
| if: runner.os == 'Linux' | |
| run: | | |
| echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | |
| | sudo tee /etc/udev/rules.d/99-kvm4all.rules | |
| sudo udevadm control --reload-rules | |
| sudo udevadm trigger --name-match=kvm || true | |
| - name: Check KVM availability (Linux only) | |
| if: runner.os == 'Linux' | |
| id: kvm_check | |
| run: | | |
| if [ -c /dev/kvm ] && [ -r /dev/kvm ] && [ -w /dev/kvm ]; then | |
| echo "available=true" >> $GITHUB_OUTPUT | |
| ls -la /dev/kvm | |
| else | |
| echo "available=false" >> $GITHUB_OUTPUT | |
| echo "::warning::/dev/kvm is not available; pyhl test skipped" | |
| fi | |
| - name: Install pyhl (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| cd host | |
| cargo build --release --bin pyhl | |
| sudo cp target/release/pyhl /usr/local/bin/ | |
| - name: Install pyhl (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| cd host | |
| cargo build --release --bin pyhl | |
| Copy-Item target\release\pyhl.exe $env:USERPROFILE\.cargo\bin\ -Force | |
| - name: Download prebuilt python-agent-driver image | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: windows-image-python-agent-driver | |
| path: pyhl-image | |
| # `pyhl setup --from` expects a build-tree shape: | |
| # <dir>/.unikraft/build/*_hyperlight-x86_64 | |
| # <dir>/*-initrd.cpio | |
| - name: Lay out pyhl setup source dir (Linux) | |
| if: runner.os == 'Linux' && steps.kvm_check.outputs.available == 'true' | |
| run: | | |
| mkdir -p src-dir/.unikraft/build | |
| mv pyhl-image/kernel src-dir/.unikraft/build/pyhl-kernel_hyperlight-x86_64 | |
| mv pyhl-image/initrd.cpio src-dir/pyhl-initrd.cpio | |
| ls -laR src-dir | |
| - name: Lay out pyhl setup source dir (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force -Path src-dir/.unikraft/build | Out-Null | |
| Move-Item pyhl-image/kernel src-dir/.unikraft/build/pyhl-kernel_hyperlight-x86_64 | |
| Move-Item pyhl-image/initrd.cpio src-dir/pyhl-initrd.cpio | |
| Get-ChildItem -Recurse src-dir | |
| - name: pyhl setup --from (warms up + persists snapshot) | |
| if: runner.os == 'Windows' || steps.kvm_check.outputs.available == 'true' | |
| shell: pwsh | |
| run: pyhl setup --from src-dir --force | |
| - name: pyhl run (hello) | |
| if: runner.os == 'Windows' || steps.kvm_check.outputs.available == 'true' | |
| shell: pwsh | |
| run: | | |
| "print('hello from pyhl CI')" | Out-File -Encoding ascii hi.py | |
| $out = pyhl run -v hi.py 2>&1 | |
| Write-Host $out | |
| if ($out -match 'hello from pyhl CI') { | |
| Write-Host "PASS: matched /hello from pyhl CI/" | |
| } else { | |
| Write-Error "FAIL: did not match /hello from pyhl CI/" | |
| exit 1 | |
| } | |
| - name: pyhl run (pandas — exercises the warm snapshot) | |
| if: runner.os == 'Windows' || steps.kvm_check.outputs.available == 'true' | |
| shell: pwsh | |
| run: | | |
| @" | |
| import pandas as pd, numpy as np | |
| df = pd.DataFrame({'x': np.arange(5)}) | |
| print('pandas_ok:', df.sum().to_dict()) | |
| "@ | Out-File -Encoding ascii pandas_test.py | |
| $out = pyhl run -v pandas_test.py 2>&1 | |
| Write-Host $out | |
| if ($out -match 'pandas_ok:') { | |
| Write-Host "PASS: matched /pandas_ok:/" | |
| } else { | |
| Write-Error "FAIL: did not match /pandas_ok:/" | |
| exit 1 | |
| } | |
| - name: pyhl run --repeat 4 (exercise hermetic rewind) | |
| if: runner.os == 'Windows' || steps.kvm_check.outputs.available == 'true' | |
| shell: pwsh | |
| run: | | |
| $out = pyhl run -v --repeat 4 pandas_test.py 2>&1 | |
| Write-Host $out | |
| # Expect the pandas output five times (run 1/5 .. run 5/5) | |
| $hits = ($out | Select-String -Pattern 'pandas_ok:' -AllMatches).Matches.Count | |
| if ($hits -ge 5) { | |
| Write-Host "PASS: --repeat produced $hits pandas_ok lines" | |
| } else { | |
| Write-Error "FAIL: expected >=5 pandas_ok lines, got $hits" | |
| exit 1 | |
| } | |
| - name: pyhl run (busybox subprocess demo) | |
| if: runner.os == 'Windows' || steps.kvm_check.outputs.available == 'true' | |
| shell: pwsh | |
| run: | | |
| $out = pyhl run examples/python-agent-driver/demo_busybox.py 2>&1 | |
| Write-Host $out | |
| if ($out -match 'hello from hyperlight guest') { | |
| Write-Host "PASS: busybox subprocess demo" | |
| } else { | |
| Write-Error "FAIL: did not match /hello from hyperlight guest/" | |
| exit 1 | |
| } | |
| - name: pyhl run (pip install subprocess demo) | |
| if: runner.os == 'Windows' || steps.kvm_check.outputs.available == 'true' | |
| shell: pwsh | |
| run: | | |
| $out = pyhl run --net examples/python-agent-driver/demo_pip_install.py 2>&1 | |
| Write-Host $out | |
| if ($out -match 'Installed and imported six') { | |
| Write-Host "PASS: pip install subprocess demo" | |
| } else { | |
| Write-Error "FAIL: did not match /Installed and imported six/" | |
| exit 1 | |
| } | |
| test-examples-passed: | |
| if: always() | |
| needs: | |
| [ | |
| build-example, | |
| runtime-test, | |
| package-images-for-windows, | |
| runtime-test-windows, | |
| pyhl-snapshot-test, | |
| ] | |
| runs-on: ubuntu-latest | |
| permissions: {} | |
| steps: | |
| - run: | | |
| # Every job must succeed. "skipped" means a dependency failed | |
| # and the job never ran — that must fail the gate, not sneak | |
| # through as green. | |
| declare -A results=( | |
| [build-example]="${{ needs.build-example.result }}" | |
| [runtime-test]="${{ needs.runtime-test.result }}" | |
| [package-images-for-windows]="${{ needs.package-images-for-windows.result }}" | |
| [runtime-test-windows]="${{ needs.runtime-test-windows.result }}" | |
| [pyhl-snapshot-test]="${{ needs.pyhl-snapshot-test.result }}" | |
| ) | |
| failed=0 | |
| for job in "${!results[@]}"; do | |
| r="${results[$job]}" | |
| if [[ "$r" != "success" ]]; then | |
| echo "FAIL: $job = $r" | |
| failed=1 | |
| fi | |
| done | |
| if [[ "$failed" -eq 1 ]]; then | |
| exit 1 | |
| fi | |
| echo "All checks passed" |