patches: add v1.11.0 atomic Vault IAM update patch and its leil-rig proof

0004-vault-atomic-update-user.patch makes the Vault IAM backend's
UpdateUserAccount -- what admin update-user, and so every key rotation,
runs -- one KV v2 write with cas set to the version it read, retried
from a fresh read on a check-and-set conflict (up to 5 attempts).
Upstream deleted the entry and created it again: in between, another
instance on the same KV path whose IAM cache missed answered
InvalidAccessKeyId, a failure between the two calls lost the account,
and two updates at once lost one of them. Touches only
auth/iam_vault.go and a new auth/iam_vault_update_test.go.

tests/vault-update/proof.sh proves it on leil-rig against an OpenBao
dev server, with two gateways on one KV path and gateway A's OpenBao
calls held 3 s by a proxy so upstream's gap can be observed. On
leil-test-1: patched 20 passed, 0 failed; 0001-0003 control 13 passed,
7 failed (entry missing 3.00 s, 28 InvalidAccessKeyId on the second
gateway, concurrent rotation lost, account gone after a refused
write). go test on the node: 35 of 35 packages ok.

docs/vault-atomic-update.md has the problem, the change, the proof and
its result; README and the multi-version pipeline doc point at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
57_WolveandClaude Opus 5.5 committed 2026-11-11 02:04:31 -06:00
1 parent 152ea01107
commit a52f92c100
7 files changed
+1204 -22

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
#!/bin/bash
#
# Proof test for patches/v1.11.0/0004-vault-atomic-update-user.patch.
#
# sudo bash proof.sh /path/to/versitygw [/path/to/bao]
#
# Starts a throwaway OpenBao dev server (in memory, 127.0.0.1:${PROOF_BAO_PORT:-18200}) and two
# gateways from the given binary on one KV path, as several instances of one namespace run:
#
# A 127.0.0.1:${PROOF_PORT:-7391} reaches OpenBao through vaultproxy.py, which holds every request
# ${PROOF_DELAY:-3} s, as from a distant OpenBao, and can refuse KV writes
# B next port reaches OpenBao directly
#
# Both use AppRole with the policy leil-cluster gives its gateways and the default 120 s IAM cache
# (PROOF_CACHE_TTL changes it, for a quick try only). Then it rotates an account through A's admin API
# while B signs requests with the old secret past its cache, updates the account from A and B at once,
# and has A's KV write refused. Everything lives under ${PROOF_DIR:-/var/tmp/s3gw-vault-update} and is
# removed at the end; nothing installed is touched. Prints one line per check and exits non-zero if
# any check fails. KEEP=1 leaves the directory (logs and samples) behind. About four minutes.
#
# The account, made by root through A's admin API, on leil-cluster's reserved test band:
#
# vu-rot uid 74992 gid 74993 user
set -uo pipefail
VGW=${1:?usage: proof.sh /path/to/versitygw [/path/to/bao]}
BAO=${2:-$(command -v bao)}
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
W=${PROOF_DIR:-/var/tmp/s3gw-vault-update}
PA=${PROOF_PORT:-7391}
PB=$((PA + 1))
BAO_PORT=${PROOF_BAO_PORT:-18200}
PROXY_PORT=$((BAO_PORT + 2)) # the dev server's cluster listener takes BAO_PORT + 1
TTL=${PROOF_CACHE_TTL:-120}
D=${PROOF_DELAY:-3}
BAO_URL=http://127.0.0.1:$BAO_PORT
KV=s3gw-iam
ACCOUNTS=proof/0x0/accounts
ENTRY=$KV/data/$ACCOUNTS/vu-rot
export S3_REGION=us-east-1
[ -x "$VGW" ] || { echo "FATAL: $VGW is not an executable" >&2; exit 2; }
[ -n "$BAO" ] && [ -x "$BAO" ] || { echo "FATAL: no bao binary (give its path as the second argument)" >&2; exit 2; }
for c in python3 curl; do
command -v $c >/dev/null || { echo "FATAL: $c is required" >&2; exit 2; }
done
listening() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; }
for p in $PA $PB $BAO_PORT $((BAO_PORT + 1)) $PROXY_PORT; do
if listening $p; then
echo "FATAL: something already listens on 127.0.0.1:$p (set PROOF_PORT / PROOF_BAO_PORT)" >&2
exit 2
fi
done
rm -rf "$W" && mkdir -p "$W/tmp" "$W/proxy" "$W/home" "$W/a-data" "$W/b-data" || exit 2
chmod 0700 "$W"
secret() { python3 -c 'import secrets; print(secrets.token_hex(16))'; }
export BAO_TOKEN ROOT_KEY=vuroot ROOT_SECRET S1 S2 S3 S4
BAO_TOKEN=$(secret) ROOT_SECRET=$(secret) S1=$(secret) S2=$(secret) S3=$(secret) S4=$(secret)
PIDS=()
cleanup() {
kill "${PIDS[@]}" 2>/dev/null
wait "${PIDS[@]}" 2>/dev/null
[ "${KEEP:-0}" = 1 ] && echo "kept $W" || rm -rf "$W"
}
trap cleanup EXIT
now() { date +%s.%N; }
calc() { awk "BEGIN { printf \"%.3f\", $* }"; }
sleep_until() {
local s
s=$(calc "$1 - $(now)")
awk "BEGIN { exit !($s > 0) }" && sleep "$s"
}
wait_port() { # wait_port <port> <pid> <log>
for _ in $(seq 1 100); do
listening "$1" && return 0
kill -0 "$2" 2>/dev/null || { cat "$3"; echo "FATAL: $3: the process exited" >&2; exit 2; }
sleep 0.2
done
echo "FATAL: nothing listens on $1" >&2
exit 2
}
printf 'X-Vault-Token: %s\n' "$BAO_TOKEN" >"$W/tmp/bao.hdr"
bao_api() { # bao_api <method> <path> [json]
curl -sS -f -X "$1" -H "@$W/tmp/bao.hdr" ${3:+-d "$3"} "$BAO_URL/v1/$2"
}
jget() { python3 -c 'import json, sys
d = json.load(sys.stdin)
for k in sys.argv[1].split("."): d = d[k]
print(d)' "$1"; }
PASS=0 FAIL=0
result() { # result <id> <ok?> <got> <what> [detail]
if [ "$2" = 1 ]; then
PASS=$((PASS + 1)); printf 'PASS %-3s %s %s\n' "$1" "$3" "$4"
else
FAIL=$((FAIL + 1)); printf 'FAIL %-3s %s %s [%s]\n' "$1" "$3" "$4" "${5:-}"
fi
}
# check <id> <want status> <what> <A|B> <key> <secret var> <s3req.py args...>
check() {
local id=$1 want=$2 what=$3 gw=$4 key=$5 sec=$6 got port
shift 6
[ "$gw" = A ] && port=$PA || port=$PB
got=$(S3_ENDPOINT=http://127.0.0.1:$port S3_KEY=$key S3_SECRET=${!sec} \
python3 "$HERE/../owner-scope/s3req.py" "$@" 2>"$W/tmp/err")
[ "$got" = "$want" ]; result "$id" $((! $?)) "${got:-none}" "$what" "want $want: $(cat "$W/tmp/err")"
}
# check_cmd <id> <want: ok|refused> <what> <command...>
check_cmd() {
local id=$1 want=$2 what=$3 got=ok
shift 3
"$@" >"$W/tmp/err" 2>&1 || got=refused
[ "$got" = "$want" ]; result "$id" $((! $?)) "$got" "$what" "want $want: $(tr '\n' ' ' <"$W/tmp/err")"
}
# verdict <id> <what> <command...> -- the command's one line is the evidence, PASS or FAIL
verdict() {
local id=$1 what=$2 out ok=1
shift 2
out=$("$@" 2>&1) || ok=0
if [ $ok = 1 ]; then
PASS=$((PASS + 1)); printf 'PASS %-3s %s: %s\n' "$id" "$what" "$out"
else
FAIL=$((FAIL + 1)); printf 'FAIL %-3s %s: %s\n' "$id" "$what" "$out"
fi
}
watch() { python3 "$HERE/watch.py" "$@"; }
admin() { # admin <A|B> <admin args...>
local port
[ "$1" = A ] && port=$PA || port=$PB
shift
ADMIN_ACCESS_KEY_ID=$ROOT_KEY ADMIN_SECRET_ACCESS_KEY=$ROOT_SECRET \
"$VGW" admin --region "$S3_REGION" --endpoint-url "http://127.0.0.1:$port" "$@"
}
listed() { admin B list-users | awk '$1 == "vu-rot" { print $2, $3, $4, $5 }' | grep -qx 'user 74992 74993 41'; }
echo "--- setup"
HOME=$W/home BAO_DEV_ROOT_TOKEN_ID=$BAO_TOKEN VAULT_DEV_ROOT_TOKEN_ID=$BAO_TOKEN \
"$BAO" server -dev -dev-listen-address="127.0.0.1:$BAO_PORT" >"$W/bao.log" 2>&1 &
PIDS+=($!)
wait_port "$BAO_PORT" "${PIDS[-1]}" "$W/bao.log"
for _ in $(seq 1 50); do curl -sf "$BAO_URL/v1/sys/health" >/dev/null && break; sleep 0.2; done
echo "openbao $("$BAO" version | head -1), dev server on 127.0.0.1:$BAO_PORT"
echo "gateway $("$VGW" --version 2>&1 | head -1), sha256 $(sha256sum "$VGW" | cut -c1-16)"
POLICY="path \"$KV/data/$ACCOUNTS/*\" { capabilities = [\"create\", \"read\", \"update\"] }
path \"$KV/metadata/$ACCOUNTS/*\" { capabilities = [\"read\", \"delete\", \"list\"] }
path \"$KV/metadata/$ACCOUNTS\" { capabilities = [\"list\"] }"
setup_bao() {
bao_api POST "sys/mounts/$KV" '{"type": "kv", "options": {"version": "2"}}' &&
bao_api POST sys/auth/approle '{"type": "approle"}' &&
bao_api PUT sys/policies/acl/s3gw-proof "$(printf '%s' "$POLICY" | python3 -c 'import json, sys; print(json.dumps({"policy": sys.stdin.read()}))')" &&
bao_api POST auth/approle/role/s3gw-proof '{"token_policies": ["s3gw-proof"], "token_ttl": "1h"}' &&
ROLE_ID=$(bao_api GET auth/approle/role/s3gw-proof/role-id | jget data.role_id) &&
SECRET_ID=$(bao_api POST auth/approle/role/s3gw-proof/secret-id | jget data.secret_id)
}
setup_bao >"$W/tmp/err" 2>&1
[ -n "${SECRET_ID:-}" ]; result S1 $((! $?)) ok "KV v2 mount $KV, AppRole s3gw-proof with the gateways' policy" "$(tr '\n' ' ' <"$W/tmp/err")"
[ -n "${SECRET_ID:-}" ] || exit 1
python3 "$HERE/vaultproxy.py" "$PROXY_PORT" "$BAO_PORT" "$W/proxy" >"$W/proxy.log" 2>&1 &
PIDS+=($!)
wait_port "$PROXY_PORT" "${PIDS[-1]}" "$W/proxy.log"
gateway() { # gateway <name> <port> <vault url>
ROOT_ACCESS_KEY_ID=$ROOT_KEY ROOT_SECRET_ACCESS_KEY=$ROOT_SECRET VGW_IAM_VAULT_ROLE_SECRET=$SECRET_ID \
"$VGW" --region "$S3_REGION" --port "127.0.0.1:$2" \
--iam-vault-endpoint-url "$3" --iam-vault-mount-path "$KV" --iam-vault-secret-storage-path "$ACCOUNTS" \
--iam-vault-auth-method approle --iam-vault-role-id "$ROLE_ID" --iam-cache-ttl "$TTL" \
posix "$W/$1-data" >"$W/$1.log" 2>&1 &
PIDS+=($!)
wait_port "$2" "${PIDS[-1]}" "$W/$1.log"
}
gateway a "$PA" "http://127.0.0.1:$PROXY_PORT"
gateway b "$PB" "$BAO_URL"
echo "gateway A on 127.0.0.1:$PA (OpenBao through the proxy), B on 127.0.0.1:$PB, IAM cache ${TTL} s"
check_cmd S2 ok "create-user vu-rot (uid 74992, gid 74993) through A" \
admin A create-user --access vu-rot --secret "$S1" --role user --user-id 74992 --group-id 74993
read -r V0 CREATED0 < <(watch meta "$BAO_URL" "$KV/metadata/$ACCOUNTS/vu-rot")
t0=$(now)
check S3 200 "vu-rot signs on B with its secret (B caches it now)" B vu-rot S1 GET /
t1=$(now)
EXPIRY=$(calc "($t0 + $t1) / 2 + $TTL")
check S4 200 "vu-rot signs on A with its secret" A vu-rot S1 GET /
echo "--- rotation: update-user through A while B signs with the old secret past its cache"
END=$(calc "$EXPIRY + 2 * $D + 3")
python3 "$HERE/watch.py" kv "$BAO_URL" "$ENTRY" "$END" "$W/kv-rotation.log" &
KVW=$!
S3_KEY=vu-rot S3_SECRET=$S1 python3 "$HERE/watch.py" s3 "http://127.0.0.1:$PB" "$END" "$W/b-rotation.log" &
S3W=$!
PIDS+=($KVW $S3W)
echo "$D" >"$W/proxy/delay"
# Upstream deletes the entry one delay into the update and makes it again a delay later: put
# that gap across B's cache expiry, so that B has to look the account up while it is open.
START=$(calc "$EXPIRY - 2.5 * $D")
awk "BEGIN { exit !($START < $(now)) }" && echo " (the cache time is too short to line the rotation up with B's expiry)"
sleep_until "$START"
t=$(now)
check_cmd R1 ok "update-user vu-rot --secret <new> through A" admin A update-user --access vu-rot --secret "$S2"
echo " took $(calc "$(now) - $t") s with OpenBao $D s away from A; B's cache expires $(calc "$EXPIRY - $(now)") s from now"
check R2 200 "A takes the new secret at once (the update drops A's own cache entry)" A vu-rot S2 GET /
wait $KVW $S3W
verdict R3 "the KV entry never went missing" watch never-missing "$W/kv-rotation.log"
verdict R4 "B never answered InvalidAccessKeyId to the old secret" watch never-invalid "$W/b-rotation.log"
verdict R5 "B took the old secret until its cache expired, and not after" watch cache-expiry "$W/b-rotation.log" "$EXPIRY"
check R6 200 "B takes the new secret after its cache time" B vu-rot S2 GET /
verdict R7 "UserID and GroupID survive" watch expect "$BAO_URL" "$ENTRY" secret=S2 role=user userID=74992 groupID=74993 projectID=0
read -r V1 CREATED1 < <(watch meta "$BAO_URL" "$KV/metadata/$ACCOUNTS/vu-rot")
[ "$V1" = $((V0 + 1)) ] && [ "$CREATED1" = "$CREATED0" ]
result R8 $((! $?)) "v$V0 -> v$V1" "one write: the entry's version went up by one and it kept its creation time" \
"created $CREATED0, now $CREATED1: the entry was deleted and made again"
echo "--- two updates at once: --project-id 41 through A, and B rotating the secret in between"
(admin A update-user --access vu-rot --project-id 41 >"$W/tmp/a-update" 2>&1; echo $? >"$W/tmp/a-update.rc") &
A_UPDATE=$!
sleep "$(calc "1.5 * $D")"
check_cmd C1 ok "update-user vu-rot --secret <newer> through B, between A's read and A's write" \
admin B update-user --access vu-rot --secret "$S3"
wait $A_UPDATE
check_cmd C2 ok "A's update-user --project-id 41 finished" test "$(cat "$W/tmp/a-update.rc")" = 0
verdict C3 "neither update was lost" watch expect "$BAO_URL" "$ENTRY" secret=S3 projectID=41 userID=74992 groupID=74993
check_cmd C4 ok "admin list-users on B shows vu-rot as user, 74992, 74993, project 41" listed
echo "--- a KV write that fails leaves the account as it was"
CUR=S3
watch expect "$BAO_URL" "$ENTRY" secret=S3 >/dev/null || CUR=S2
read -r V2 _ < <(watch meta "$BAO_URL" "$KV/metadata/$ACCOUNTS/vu-rot")
echo 0 >"$W/proxy/delay"
printf 'POST /v1/%s/data/' "$KV" >"$W/proxy/refuse"
check_cmd F1 refused "update-user vu-rot --secret <another> through A, its KV write refused on the way" \
admin A update-user --access vu-rot --secret "$S4"
rm -f "$W/proxy/refuse"
verdict F2 "the entry is unchanged" watch expect "$BAO_URL" "$ENTRY" version="$V2" secret=$CUR userID=74992 groupID=74993
check F3 200 "A still signs vu-rot with its current secret (A caches nothing for it)" A vu-rot $CUR GET /
check F4 200 "so does B" B vu-rot $CUR GET /
echo
echo "$PASS passed, $FAIL failed"
[ "$FAIL" = 0 ]
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""A TCP proxy in front of a plain-HTTP OpenBao that holds requests, and can refuse some.
vaultproxy.py LISTEN_PORT UPSTREAM_PORT CONTROL_DIR
Everything a client sends is held for the number of seconds in CONTROL_DIR/delay
(none while the file is missing) before it goes on to 127.0.0.1:UPSTREAM_PORT, so
each Vault call of the gateway behind it takes that much longer, as against a
distant OpenBao. While CONTROL_DIR/refuse exists, a request that starts with its
contents (say "POST /v1/s3gw-iam/data/") never reaches OpenBao: the proxy answers
it 503 and closes the connection. Both files are read at every request, so a test
can change them while the proxy runs. Python standard library only.
"""
import asyncio
import sys
LISTEN, UPSTREAM, CONTROL = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
def control(name, default):
try:
with open(f"{CONTROL}/{name}") as f:
return f.read().strip()
except FileNotFoundError:
return default
async def handle(client_r, client_w):
try:
server_r, server_w = await asyncio.open_connection("127.0.0.1", UPSTREAM)
except OSError:
client_w.close()
return
async def upstream():
try:
while data := await client_r.read(65536):
refuse = control("refuse", "")
if refuse and data.startswith(refuse.encode()):
body = b'{"errors":["refused by the proof proxy"]}'
client_w.write(b"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\n"
b"Connection: close\r\nContent-Length: %d\r\n\r\n%s" % (len(body), body))
await client_w.drain()
break
delay = float(control("delay", "0") or 0)
if delay:
await asyncio.sleep(delay)
server_w.write(data)
await server_w.drain()
finally:
server_w.close()
client_w.close()
async def downstream():
try:
while data := await server_r.read(65536):
client_w.write(data)
await client_w.drain()
finally:
client_w.close()
await asyncio.gather(upstream(), downstream(), return_exceptions=True)
async def main():
server = await asyncio.start_server(handle, "127.0.0.1", LISTEN)
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Samplers and record checks for proof.sh.
watch.py kv BAO_URL PATH UNTIL OUT read a KV v2 entry until epoch UNTIL,
logging "<epoch> <status> <version>"
watch.py s3 ENDPOINT UNTIL OUT sign ListBuckets with S3_KEY/S3_SECRET until
epoch UNTIL, logging "<epoch> <status> <code>"
watch.py never-missing OUT the entry was there at every kv sample
watch.py never-invalid OUT no s3 request was answered InvalidAccessKeyId
watch.py cache-expiry OUT EXPIRY the key worked until epoch EXPIRY, not after
watch.py expect BAO_URL PATH FIELD=VALUE... the account in the entry has these fields
(secret=NAME compares with $NAME, unprinted)
watch.py meta BAO_URL PATH "<current_version> <created_time>", or "missing"
The KV commands authenticate with $BAO_TOKEN. The samplers run as fast as a round trip
allows, about 5 ms apart for the entry and 50 ms for the key. Every check prints one
line and exits 0 when it holds, 1 when it does not. Python standard library only;
s3req.py comes from ../owner-scope.
"""
import http.client
import json
import os
import re
import sys
import time
import urllib.parse
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "owner-scope"))
from s3req import sign # noqa: E402
def bao(url):
u = urllib.parse.urlsplit(url)
return http.client.HTTPConnection(u.hostname, u.port, timeout=10)
def bao_get(conn, path):
conn.request("GET", f"/v1/{path}", headers={"X-Vault-Token": os.environ["BAO_TOKEN"]})
resp = conn.getresponse()
body = resp.read()
return resp.status, json.loads(body) if resp.status == 200 else None
def sample(connect, request, until, out, pause):
conn = None
with open(out, "w") as log:
while time.time() < until:
try:
conn = conn or connect()
status, detail = request(conn)
except (OSError, http.client.HTTPException) as e:
conn = None
status, detail = "err", type(e).__name__
log.write(f"{time.time():.3f} {status} {detail}\n")
log.flush()
time.sleep(pause)
def kv(url, path, until, out):
def request(conn):
status, body = bao_get(conn, path)
return status, body["data"]["metadata"]["version"] if body else "-"
sample(lambda: bao(url), request, until, out, 0.005)
def s3(endpoint, until, out):
u = urllib.parse.urlsplit(endpoint)
key, secret = os.environ["S3_KEY"], os.environ["S3_SECRET"]
def request(conn):
headers = {}
sign("GET", u.netloc, "/", "", headers, b"", key, secret, os.environ.get("S3_REGION", "us-east-1"))
conn.request("GET", "/", headers=headers)
resp = conn.getresponse()
code = re.search(rb"<Code>(.*?)</Code>", resp.read())
return resp.status, code.group(1).decode() if code else "-"
sample(lambda: http.client.HTTPConnection(u.hostname, u.port, timeout=30), request, until, out, 0.05)
def read(out):
with open(out) as f:
return [(float(t), s, d) for t, s, d in (line.split() for line in f)]
def never_missing(out):
rows = read(out)
if not rows:
print("no samples")
return False
span = f"{len(rows)} samples over {rows[-1][0] - rows[0][0]:.0f} s"
missing = [t for t, s, _ in rows if s != "200"]
if missing:
statuses = "/".join(sorted({s for _, s, _ in rows if s != "200"}))
print(f"missing ({statuses}) in {len(missing)} of {span}, "
f"for {missing[-1] - missing[0]:.2f} s from {missing[0] - rows[0][0]:.2f} s in")
return False
versions = sorted({d for _, _, d in rows}, key=int)
print(f"present in all {span}, at version {' then '.join(versions)}")
return True
def never_invalid(out):
rows = read(out)
counts = {}
for _, s, d in rows:
counts[f"{s} {d}"] = counts.get(f"{s} {d}", 0) + 1
print(f"{len(rows)} requests: " + ", ".join(f"{n} x {k}" for k, n in sorted(counts.items())))
return bool(rows) and set(counts) <= {"200 -", "403 SignatureDoesNotMatch"}
def cache_expiry(out, expiry):
rows = [(t - expiry, s, d) for t, s, d in read(out)]
ok = [t for t, s, _ in rows if s == "200"]
refused = [t for t, s, d in rows if s == "403" and d == "SignatureDoesNotMatch"]
if not ok or not refused:
print(f"{len(ok)} accepted, {len(refused)} refused as SignatureDoesNotMatch")
return False
print(f"accepted until {ok[-1]:+.2f} s from the cache expiry, "
f"refused as SignatureDoesNotMatch from {refused[0]:+.2f} s")
return -1 <= ok[-1] <= 1 and refused[0] > ok[-1]
def expect(url, path, wants):
status, body = bao_get(bao(url), path)
if status != 200:
print(f"no entry (HTTP {status})")
return False
acc = next(iter(body["data"]["data"].values()))
got, held = [f"version {body['data']['metadata']['version']}:"], True
for want in wants:
field, value = want.split("=", 1)
if field == "secret":
same = acc["secret"] == os.environ[value]
got.append(f"secret {'is' if same else 'is not'} {value}")
elif field == "version":
same = str(body["data"]["metadata"]["version"]) == value
got[0] = f"version {body['data']['metadata']['version']}{'' if same else f' (want {value})'}:"
else:
same = str(acc.get(field)) == value
got.append(f"{field}={acc.get(field)}" + ("" if same else f" (want {value})"))
held = held and same
print(" ".join(got))
return held
def meta(url, path):
status, body = bao_get(bao(url), path)
print(f"{body['data']['current_version']} {body['data']['created_time']}" if status == 200 else "missing")
def main(argv):
cmd, args = (argv[0], argv[1:]) if argv else ("", [])
if cmd == "kv":
kv(args[0], args[1], float(args[2]), args[3])
elif cmd == "s3":
s3(args[0], float(args[1]), args[2])
elif cmd == "never-missing":
sys.exit(0 if never_missing(args[0]) else 1)
elif cmd == "never-invalid":
sys.exit(0 if never_invalid(args[0]) else 1)
elif cmd == "cache-expiry":
sys.exit(0 if cache_expiry(args[0], float(args[1])) else 1)
elif cmd == "expect":
sys.exit(0 if expect(args[0], args[1], args[2:]) else 1)
elif cmd == "meta":
meta(args[0], args[1])
else:
sys.exit(__doc__)
if __name__ == "__main__":
main(sys.argv[1:])