04e0df0c89
Adds an Azurite service and a scenario that audits the Azure backend
through `?storage=azurite`. It is the only coverage of that code path in
the tree: `AzureBlobBackend` has unit tests for its name parser and
ordering, but nothing else speaks the protocol, and a paid account is
not an option for CI. Azurite implements the real Blob REST API, so this
exercises SharedKey signing, prefix/marker paging, and the 256-way shard
walk with its termination.
## Harness
`docker-compose.test.yml` gains an azurite service on 10000 (tmpfs, so
it dies with the stack). `spawn-db.sh` provisions the container itself,
because `AzureBlobBackend::initialize` verifies rather than creates —
signed by hand with curl + openssl rather than pulling a ~700 MB `az`
image for one PUT. Two traps are commented there: the account key is
base64 but HMAC wants raw bytes, and the canonicalized resource repeats
the account name (`/{acc}/{acc}/{container}`) because the emulator puts
in the path what real Azure puts in the host. Getting that wrong yields
403, not a hint.
The `azurite` entry is declared in `server.env` but never activated, so
the suite's active backend stays local and only this file reaches Azure.
## What it asserts, and what it cannot
A failure surfaces as `ok: false`, because an enumeration error now
fails the run rather than degrading to a per-row probe.
It deliberately asserts no finding count. The container starts empty and
the job's grace window is an hour, so a freshly-uploaded blob is skipped
in both directions by design — an audit here can only report zero, and
"zero findings" would pass whether enumeration worked or returned
nothing. The one positive assert, `scanned_count != 0`, therefore sits
on the local control, which does hold blobs; `scanned_count` accumulates
via `checkpoint`, which the empty-page early return skips.
## No cutover, deliberately
Putting real bytes in the container means `backend_migration
?storage=azurite`, which hangs on the first blob: `head_check` issues a
~40-byte ranged GET, `azure_core` 0.21 attaches
`x-ms-range-get-content-crc64` to anything under 4 MiB, Azurite 500s,
and the deterministic error is retried forever while
`migration_readonly` refuses writes app-wide. The full chain and the
rejected workaround are in the file header. The scenario is still
ordered last in `run.sh` — it is the only one needing a second service,
and the cutover comes back there once the official SDK lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
6.7 KiB
Bash
Executable File
144 lines
6.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
COMPOSE_FILE="$(dirname "$0")/docker-compose.test.yml"
|
|
|
|
wait_for_port() {
|
|
local host="$1" port="$2" timeout="${3:-30}"
|
|
local deadline=$(( $(date +%s) + timeout ))
|
|
until nc -z "$host" "$port" 2>/dev/null; do
|
|
[[ $(date +%s) -ge $deadline ]] && echo "Timeout waiting for $host:$port" >&2 && exit 1
|
|
sleep 0.5
|
|
done
|
|
}
|
|
|
|
# Postgres opens its TCP socket during `initdb` (well before the server can
|
|
# actually serve queries) and slams it back shut until startup completes —
|
|
# that's the classic "server closed the connection unexpectedly" race when
|
|
# a follow-up `psql` runs too quickly. The compose file has a `pg_isready`
|
|
# healthcheck; we mirror it here so this script is the single source of
|
|
# "the DB is ready" for callers (tests/api/run.sh, just test-integration).
|
|
#
|
|
# Tries the in-container pg_isready first (always available, no host deps),
|
|
# then falls back to a SELECT-1 probe via psql.
|
|
wait_for_postgres_ready() {
|
|
local timeout="${1:-60}"
|
|
local deadline=$(( $(date +%s) + timeout ))
|
|
until docker compose -f "$COMPOSE_FILE" exec -T postgres-test \
|
|
pg_isready -U oxicloud_test -d oxicloud_test -h 127.0.0.1 >/dev/null 2>&1; do
|
|
[[ $(date +%s) -ge $deadline ]] && echo "Timeout waiting for postgres readiness" >&2 && exit 1
|
|
sleep 0.5
|
|
done
|
|
# Belt-and-braces: pg_isready returns 0 as soon as the server accepts
|
|
# connections, but a query may still race the very first request. One
|
|
# successful SELECT confirms the round-trip works end-to-end.
|
|
#
|
|
# Retry the probe a handful of times — under CPU pressure (e.g. a parallel
|
|
# cargo build hammering a self-hosted runner) the role/db init can complete
|
|
# a beat after pg_isready returns success. A single-shot probe in that
|
|
# window produces spurious "Postgres reported ready but a sample query
|
|
# failed" failures. Show the last error if every retry fails so operators
|
|
# see the actual psql diagnostic.
|
|
local last_err
|
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
if last_err=$(PGPASSWORD=oxicloud_test psql -h 127.0.0.1 -p 5433 \
|
|
-U oxicloud_test -d oxicloud_test \
|
|
-v ON_ERROR_STOP=1 -c 'SELECT 1' 2>&1 >/dev/null); then
|
|
return 0
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
echo "Postgres reported ready but a sample query failed after 10 retries: $last_err" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Create the blob container Azurite serves.
|
|
#
|
|
# `AzureBlobBackend::initialize` VERIFIES the container exists and fails
|
|
# with a 404 if it does not — it deliberately does not create one, since
|
|
# auto-creating would turn a typo'd container name into a silently
|
|
# working empty container. So the harness provisions it, exactly as an
|
|
# operator would in production.
|
|
#
|
|
# Signed by hand rather than shelling out to the Azure CLI: `az` would
|
|
# mean pulling a ~700 MB image into every CI run to issue one PUT. This
|
|
# needs only curl and openssl, both already required.
|
|
#
|
|
# Two things make this fiddly enough to be worth commenting:
|
|
# * The key is base64 and HMAC needs raw bytes, so it is decoded and
|
|
# re-encoded as hex for `-macopt hexkey:`.
|
|
# * The canonicalized resource repeats the account name —
|
|
# `/{account}/{account}/{container}` — because the emulator puts the
|
|
# account in the URL path where real Azure puts it in the host. This
|
|
# is the classic Azurite signing trap; getting it wrong yields 403,
|
|
# not a hint.
|
|
#
|
|
# Idempotent by result: a second run gets 409 ContainerAlreadyExists,
|
|
# which is success for our purposes.
|
|
create_azurite_container() {
|
|
local acc=devstoreaccount1
|
|
local cont=oxicloud-test
|
|
# Azurite's fixed development key — hardcoded in the image, published
|
|
# by Microsoft, and valid against nothing but a local emulator.
|
|
local key='Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=='
|
|
local ver=2021-08-06
|
|
local date_hdr hexkey sts sig code
|
|
date_hdr=$(LC_ALL=C TZ=GMT date '+%a, %d %b %Y %H:%M:%S GMT')
|
|
hexkey=$(printf '%s' "$key" | base64 -d | od -An -tx1 | tr -d ' \n')
|
|
# Twelve leading empty lines are the unused standard headers
|
|
# (Content-*, Date, If-*, Range) the SharedKey scheme requires in
|
|
# fixed positions.
|
|
sts="PUT\n\n\n\n\n\n\n\n\n\n\n\nx-ms-date:${date_hdr}\nx-ms-version:${ver}\n/${acc}/${acc}/${cont}\nrestype:container"
|
|
sig=$(printf '%b' "$sts" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${hexkey}" -binary | base64)
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
|
|
-H "x-ms-date: ${date_hdr}" \
|
|
-H "x-ms-version: ${ver}" \
|
|
-H "Authorization: SharedKey ${acc}:${sig}" \
|
|
"http://127.0.0.1:10000/${acc}/${cont}?restype=container" 2>/dev/null)
|
|
case "$code" in
|
|
201) echo "[setup] Azurite container '${cont}' created." ;;
|
|
409) echo "[setup] Azurite container '${cont}' already exists." ;;
|
|
*) echo "[setup] WARNING: could not create Azurite container '${cont}' (HTTP ${code}) — Azure-backed tests will fail." >&2 ;;
|
|
esac
|
|
}
|
|
|
|
echo "[setup] Starting test postgres + azurite..."
|
|
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
|
|
docker compose -f "$COMPOSE_FILE" up -d
|
|
echo "[setup] Waiting for postgres on port 5433..."
|
|
wait_for_port 127.0.0.1 5433
|
|
echo "[setup] Waiting for postgres to accept queries..."
|
|
wait_for_postgres_ready
|
|
echo "[setup] Postgres is ready."
|
|
|
|
# Azurite backs the `azurite` storage entry, which `backend_consistency`
|
|
# audits via `?storage=azurite`. Only a port wait: the entry is declared
|
|
# but never activated, so nothing in the suite touches it until a test
|
|
# names it, and by then the listener has had the whole server boot to
|
|
# settle. A failure here should not take down a run that mostly does not
|
|
# use it — so this warns rather than exits, and the Azure test fails on
|
|
# its own terms with a clearer message than "setup timed out".
|
|
#
|
|
# Its own loop rather than `wait_for_port`: that helper calls `exit 1` on
|
|
# timeout, and `exit` inside a function ends the script whatever context
|
|
# it was called from — so wrapping it in an `if` would not degrade, it
|
|
# would just fail later and less clearly.
|
|
echo "[setup] Waiting for azurite on port 10000..."
|
|
azurite_deadline=$(( $(date +%s) + 30 ))
|
|
until nc -z 127.0.0.1 10000 2>/dev/null; do
|
|
if [[ $(date +%s) -ge $azurite_deadline ]]; then
|
|
echo "[setup] WARNING: azurite did not come up — Azure-backed tests will fail." >&2
|
|
break
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
if nc -z 127.0.0.1 10000 2>/dev/null; then
|
|
# Port open only means the listener is up — NOT that the tests can use
|
|
# it. Saying "ready" here was misleading: the container was still
|
|
# missing, so the first Azure job failed with a 404 while setup had
|
|
# already reported success. `create_azurite_container` is what makes
|
|
# the claim true, so it is the one that gets to announce it.
|
|
echo "[setup] Azurite listening; provisioning container..."
|
|
create_azurite_container
|
|
fi
|