Skip to content

Z3 stack smoke test #21

Z3 stack smoke test

Z3 stack smoke test #21

Workflow file for this run

name: Z3 stack smoke test
# Build the Z3 stack images (zebra, zaino, zallet) from vendored source and
# prove the deploy/z3-stack/ bundle works end to end: zebra healthy, zaino
# serving lightwalletd gRPC, zallet answering wallet RPC with a node tip, no
# crash-loops, nothing exposed that shouldn't be. None of the checks need a
# synced chain, so the smoke phase takes minutes; the builds dominate and are
# cached in GHCR (`:buildcache` refs, shared with images.yml, so the two
# workflows keep each other warm). Registry cache has no 10 GiB Actions quota
# and no 7-day expiry; the build job also warms zero-zcashd (not part of the
# smoke stack) so release-day images.yml runs never start cold.
#
# Build definitions mirror deploy/z3-stack/docker-compose.build.yml; keep them
# in sync if you change contexts, Dockerfiles, or build args.
on:
pull_request:
paths:
- "deploy/z3-stack/**"
- "zebra/docker/Dockerfile"
- "zebra/docker/entrypoint.sh"
- "zaino/Dockerfile"
- "zaino/entrypoint.sh"
- "zallet/Dockerfile"
- ".github/workflows/z3-smoke.yml"
workflow_dispatch:
schedule:
# Weekly: catches interop breakage from subtree updates that don't touch
# the paths above.
- cron: "17 6 * * 1"
# packages: write is for pushing the GHCR build cache; on fork PRs GitHub
# forces the token to read-only, which is fine because PR runs write the
# ephemeral GHA cache instead (see the cache-to expression below).
permissions:
contents: read
packages: write
jobs:
# Populate the build cache for the images in parallel, so the smoke job's
# rebuilds are cache hits and its wall-clock is max(build) + smoke.
build:
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- name: zero-zebra
context: zebra
dockerfile: zebra/docker/Dockerfile
build_args: |
FEATURES=release_max_level_info,progress-bar,prometheus
- name: zero-zaino
context: zaino
dockerfile: zaino/Dockerfile
build_args: |
NO_TLS=true
rust_toolchain_file: zaino/rust-toolchain.toml
- name: zero-zallet
context: zallet
dockerfile: zallet/Dockerfile
build_args: |
FEATURES=zaino,rpc-cli,zcashd-import
# Not part of the z3 stack; built here only to keep its build cache
# warm (registry cache entries are only refreshed when read, and
# zcashd is otherwise built solely on releases). A warm rebuild is
# seconds; a cold one repopulates off the release critical path.
- name: zero-zcashd
context: zcashd
dockerfile: docker/zcashd/Dockerfile
build_args: ""
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
# Needed to push the GHCR build cache; pulls of the public cache refs
# are anonymous. On fork PRs the token is read-only, but those runs
# don't write to the registry (see cache-to below).
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve build args
id: args
run: |
set -euo pipefail
{
echo "build_args<<BUILD_ARGS_EOF"
printf '%s\n' "${{ matrix.build_args }}"
if [ -n "${{ matrix.rust_toolchain_file }}" ]; then
rv=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
"${{ matrix.rust_toolchain_file }}" | head -n1)
if ! [[ "$rv" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
echo "::error::unexpected channel '${rv}' in ${{ matrix.rust_toolchain_file }}"
exit 1
fi
echo "RUST_VERSION=${rv}"
fi
echo "BUILD_ARGS_EOF"
} >> "$GITHUB_OUTPUT"
# Cache refs are hardcoded lowercase: registry references reject the
# uppercase in github.repository_owner. PR runs can't push packages
# (read-only token on forks), so they write the ephemeral GHA cache
# instead; every run reads both, so PR builds still start warm.
- name: Build (cache only)
uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
target: runtime
platforms: linux/amd64
push: false
build-args: ${{ steps.args.outputs.build_args }}
cache-from: |
type=registry,ref=ghcr.io/shieldedlabs/${{ matrix.name }}:buildcache
type=gha,scope=${{ matrix.name }}
cache-to: ${{ github.event_name == 'pull_request' && format('type=gha,mode=max,scope={0}', matrix.name) || format('type=registry,ref=ghcr.io/shieldedlabs/{0}:buildcache,mode=max,image-manifest=true', matrix.name) }}
smoke:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 60
env:
ZERO_IMAGE_TAG: smoke
defaults:
run:
working-directory: deploy/z3-stack
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
- name: Resolve zaino RUST_VERSION
id: zaino_args
working-directory: .
run: |
set -euo pipefail
rv=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
zaino/rust-toolchain.toml | head -n1)
echo "rust_version=${rv}" >> "$GITHUB_OUTPUT"
# Rebuilds are cache hits from the build job; load the images locally
# under the :smoke tag that ZERO_IMAGE_TAG points the compose file at.
- name: Load zebra image
uses: docker/build-push-action@v7
with:
context: zebra
file: zebra/docker/Dockerfile
target: runtime
platforms: linux/amd64
load: true
tags: ghcr.io/shieldedlabs/zero-zebra:smoke
build-args: |
FEATURES=release_max_level_info,progress-bar,prometheus
cache-from: |
type=registry,ref=ghcr.io/shieldedlabs/zero-zebra:buildcache
type=gha,scope=zero-zebra
- name: Load zaino image
uses: docker/build-push-action@v7
with:
context: zaino
file: zaino/Dockerfile
target: runtime
platforms: linux/amd64
load: true
tags: ghcr.io/shieldedlabs/zero-zaino:smoke
build-args: |
NO_TLS=true
RUST_VERSION=${{ steps.zaino_args.outputs.rust_version }}
cache-from: |
type=registry,ref=ghcr.io/shieldedlabs/zero-zaino:buildcache
type=gha,scope=zero-zaino
- name: Load zallet image
uses: docker/build-push-action@v7
with:
context: zallet
file: zallet/Dockerfile
target: runtime
platforms: linux/amd64
load: true
tags: ghcr.io/shieldedlabs/zero-zallet:smoke
build-args: |
FEATURES=zaino,rpc-cli,zcashd-import
cache-from: |
type=registry,ref=ghcr.io/shieldedlabs/zero-zallet:buildcache
type=gha,scope=zero-zallet
# From here on this is the bundle's README walkthrough, scripted.
- name: One-time wallet init (offline)
run: |
set -euxo pipefail
docker run --rm -v "$PWD:/out" alpine:3.20 \
sh -c 'apk add --no-cache age >/dev/null && age-keygen -o /out/encryption-identity.txt'
sudo chown 1000:1000 encryption-identity.txt
sudo chmod 400 encryption-identity.txt
# Deliberately before zebra exists: init must work offline.
docker compose run --rm --no-deps zallet \
--datadir /var/lib/zallet --config /etc/zallet/zallet.toml init-wallet-encryption
docker compose run --rm --no-deps zallet \
--datadir /var/lib/zallet --config /etc/zallet/zallet.toml generate-mnemonic
- name: Start the stack
run: |
set -euxo pipefail
docker compose up -d
# zebra healthcheck gates zaino/zallet; it passes when RPC answers,
# long before the chain is synced.
deadline=$((SECONDS + 300))
until [ "$(docker inspect --format '{{.State.Health.Status}}' zebra)" = healthy ]; do
[ "$SECONDS" -lt "$deadline" ] || { echo "::error::zebra never became healthy"; exit 1; }
sleep 5
done
# Every RPC probe below carries a PER-CALL timeout on top of its retry
# deadline. The deadline alone only bounds retries: a probe that HANGS
# (the production z_listunspent hang was exactly this class) would never
# return to the deadline check and would burn the 60-minute job timeout
# as an unattributed failure. With per-call timeouts, a hang becomes a
# failed probe within seconds and the step that caught it is named.
- name: Zaino serves lightwalletd gRPC
run: |
set -euxo pipefail
# Plain grep (not -q): -q closes the pipe on match, and under
# pipefail the resulting SIGPIPE would read as failure.
deadline=$((SECONDS + 180))
until timeout 20 docker run --rm --network z3-stack_default \
-v "$GITHUB_WORKSPACE/zaino/packages/zaino-proto/lightwallet-protocol/walletrpc:/protos:ro" \
fullstorydev/grpcurl -plaintext -max-time 15 -import-path /protos -proto service.proto \
zaino:8137 cash.z.wallet.sdk.rpc.CompactTxStreamer/GetLightdInfo \
| grep chainName; do
[ "$SECONDS" -lt "$deadline" ] || { echo "::error::zaino gRPC never answered"; exit 1; }
sleep 5
done
- name: Zallet answers wallet RPC with a node tip
run: |
set -euxo pipefail
deadline=$((SECONDS + 180))
until timeout 20 docker compose exec -T zallet zallet \
--datadir /var/lib/zallet --config /etc/zallet/zallet.toml \
rpc getwalletstatus | grep node_tip; do
[ "$SECONDS" -lt "$deadline" ] || { echo "::error::zallet RPC never reported node_tip"; exit 1; }
sleep 5
done
- name: z_importaddress is compiled in (transparent-key-import)
run: |
set -euxo pipefail
# Watch-only transparent import is feature-gated at compile time
# (transparent-key-import, pulled in by zcashd-import in the
# Dockerfile). A build without it answers every z_importaddress call
# with "requires the transparent-key-import feature". Invalid hex
# with a parseable account UUID deterministically reaches the
# feature-gated parser instead, which must answer "Invalid hex
# encoding": a positive proof the real implementation is present,
# with no wallet-account state involved.
# (`zallet rpc` parses each param as JSON, hence the quoted strings.)
out=$(timeout 20 docker compose exec -T zallet zallet \
--datadir /var/lib/zallet --config /etc/zallet/zallet.toml \
rpc z_importaddress '"00000000-0000-0000-0000-000000000000"' '"zz"' 2>&1) || true
echo "$out"
echo "$out" | grep "Invalid hex encoding"
- name: z_listunspent honors its contract (import, filter, latency)
run: |
set -euxo pipefail
# Pin the wallet-RPC behavior classes that shipped as production
# incidents: a real watch-only import must succeed, filtered and
# unfiltered z_listunspent must answer promptly with well-formed
# results (the v11 dust sweep hung for ~10 minutes on a filtered
# call), and a bad filter address must be a clean error. The wallet
# is fresh and the chain unsynced, so results are structurally
# empty; the contract under test is shape and latency, not content.
# Public test constant (regtest/testnet-derived pubkey; the mainnet
# encoding of its address is derived by the wallet on import).
pubkey="0220f133a0751f6a70ce2dc506da68891b827296a0b13fb7883ceea25f7926f5d5"
zallet_rpc() {
timeout 20 docker compose exec -T zallet zallet \
--datadir /var/lib/zallet --config /etc/zallet/zallet.toml rpc "$@"
}
account=""
deadline=$((SECONDS + 120))
until account=$(zallet_rpc z_getnewaccount '"smoke"' | python3 -c 'import json,sys; print(json.load(sys.stdin)["account_uuid"])'); do
[ "$SECONDS" -lt "$deadline" ] || { echo "::error::z_getnewaccount never succeeded"; exit 1; }
sleep 5
done
imported=$(zallet_rpc z_importaddress "\"$account\"" "\"$pubkey\"" false)
echo "$imported"
address=$(echo "$imported" | python3 -c 'import json,sys; print(json.load(sys.stdin)["address"])')
# Unfiltered and filtered listings: valid JSON arrays, promptly.
zallet_rpc z_listunspent | python3 -c 'import json,sys; assert isinstance(json.load(sys.stdin), list)'
zallet_rpc z_listunspent 1 9999999 true "[\"$address\"]" \
| python3 -c 'import json,sys; assert json.load(sys.stdin) == []'
# A malformed filter address must be a clean parameter error.
out=$(zallet_rpc z_listunspent 1 9999999 true '["not-an-address"]' 2>&1) || true
echo "$out"
echo "$out" | grep "Not a valid Zcash address"
- name: No crash-loops, no accidental exposure
run: |
set -euxo pipefail
[ "$(docker inspect --format '{{.RestartCount}}' zaino)" = "0" ]
[ "$(docker inspect --format '{{.RestartCount}}' zallet)" = "0" ]
# Only zebra publishes anything, and only its P2P port.
docker port zebra | grep 8233
[ -z "$(docker port zaino)" ]
[ -z "$(docker port zallet)" ]
- name: Dump state on failure
if: failure()
run: |
docker compose ps || true
# Full logs per container, not a tail: a crash-looping container
# keeps every restart's output in one log, and the interesting
# part (the first crash) is at the top.
for c in zebra zaino zallet; do
echo "::group::$c (RestartCount=$(docker inspect --format '{{.RestartCount}}' "$c" 2>/dev/null || echo '?'))"
docker logs "$c" 2>&1 || true
echo "::endgroup::"
done