Files
claudetools/.claude/skills/b2/scripts/selftest.py
Mike Swanson 96fb4110ea Add b2 skill: Backblaze B2 management CLI (storage cost, prefix purge)
B2 Native API v3 client for the ACG B2 account: status, buckets, keys,
files, bucket-size, usage/cost ($0.00695/GB), gated create/delete bucket+key,
and gated lifecycle-based delete-prefix/lifecycle-remove for prefix purges.
Read-only by default; destructive ops require --confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:31:09 -07:00

201 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Read-only self-test harness for the b2 skill.
Runs each CLI command as an isolated subprocess and checks exit code + output
markers. Makes ZERO write calls: create/delete are only exercised in their
--confirm-absent refusal path (rc 3), and `raw` write-method gating is checked
without confirm. Sizes the SMALLEST bucket only to stay cheap.
Asserts the known accountId is present and both known application keys
("cloudberrykey", "ClaudeTools") are listed. Prints a PASS/FAIL report.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
B2 = os.path.join(HERE, "b2.py")
EXPECTED_ACCOUNT_ID = "46f69bc61163"
EXPECTED_KEY_NAMES = {"cloudberrykey", "ClaudeTools"}
results = []
def run(args):
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
p = subprocess.run([sys.executable, B2] + args, capture_output=True,
text=True, env=env, timeout=300)
return p.returncode, p.stdout, p.stderr
def record(name, ok, detail, sample=""):
status = "PASS" if ok else "FAIL"
results.append((status, name, detail, sample.replace("\n", " ")[:120]))
def check(name, args, *, want_rc=None, out_has=None, err_has=None,
out_json_ok=False):
rc, out, err = run(args)
problems = []
if want_rc is not None and rc != want_rc:
problems.append(f"rc={rc} want {want_rc}")
if out_has and out_has not in out:
problems.append(f"stdout missing {out_has!r}")
if err_has and err_has not in err:
problems.append(f"stderr missing {err_has!r}")
if out_json_ok:
try:
json.loads(out)
except Exception as e:
problems.append(f"stdout not valid JSON: {e}")
record(name, not problems, "; ".join(problems), out[:120])
return rc, out, err
# --- auth / status ---
rc, out, err = check("status table", ["status"], want_rc=0, out_has="accountId")
if rc == 0 and EXPECTED_ACCOUNT_ID not in out:
record("status accountId match", False,
f"expected accountId {EXPECTED_ACCOUNT_ID} not in status output")
else:
record("status accountId match", True, "")
check("status json", ["status", "--json"], want_rc=0, out_json_ok=True)
# --- buckets ---
check("buckets table", ["buckets"], want_rc=0, out_has="Buckets:")
rc, out, err = check("buckets json", ["buckets", "--json"], want_rc=0,
out_json_ok=True)
buckets = []
if rc == 0:
try:
buckets = json.loads(out)
except Exception:
buckets = []
record("buckets non-empty", bool(buckets),
"" if buckets else "no buckets returned")
# --- keys: assert both known keys present ---
rc, out, err = check("keys json", ["keys", "--json"], want_rc=0, out_json_ok=True)
if rc == 0:
try:
keys = json.loads(out)
names = {k.get("keyName") for k in keys}
missing = EXPECTED_KEY_NAMES - names
record("keys include known names", not missing,
f"missing {missing}" if missing else "")
except Exception as e:
record("keys include known names", False, f"parse error: {e}")
else:
record("keys include known names", False, "keys json call failed")
# --- bucket-size on a known-small bucket only (cheap) ---
# Probing all 12 buckets with `files --limit 1000` to discover the smallest
# burns one (paginating) list pass per bucket. The size/cost path is what we
# actually want to smoke-test, so target the known-small ACG-IX bucket directly
# and size only that one. This cuts the read-transaction cost from ~12 list
# passes to ~1 while keeping the bucket-size / usage / cost assertions just as
# meaningful. Fall back to the first listed bucket if ACG-IX is ever removed.
KNOWN_SMALL_BUCKET = "ACG-IX"
smallest = None
if buckets:
names = {b.get("bucketName") for b in buckets}
if KNOWN_SMALL_BUCKET in names:
smallest = KNOWN_SMALL_BUCKET
else:
smallest = buckets[0].get("bucketName")
record("found small bucket to size", smallest is not None,
"" if smallest else "no buckets to size")
if smallest:
rc, out, err = check(f"bucket-size {smallest}", ["bucket-size", smallest],
want_rc=0, out_has="stored bytes:")
check(f"bucket-size json {smallest}", ["bucket-size", smallest, "--json"],
want_rc=0, out_json_ok=True)
# --- usage scoped to the smallest bucket (cheap headline-feature smoke test) ---
if smallest:
check("usage scoped json", ["usage", "--bucket", smallest, "--json"],
want_rc=0, out_json_ok=True)
check("usage scoped table", ["usage", "--bucket", smallest],
want_rc=0, out_has="TOTAL")
check("cost alias scoped", ["cost", "--bucket", smallest, "--json"],
want_rc=0, out_json_ok=True)
# --- error handling ---
check("files bogus bucket -> rc1", ["files", "no-such-bucket-xyz"],
want_rc=1, err_has="[ERROR]")
check("usage bogus bucket -> rc1", ["usage", "--bucket", "no-such-bucket-xyz"],
want_rc=1, err_has="[ERROR]")
# --- argparse: missing required arg -> rc2 ---
check("files missing positional -> rc2", ["files"], want_rc=2)
check("create-key missing --name -> rc2",
["create-key", "--capabilities", "listFiles"], want_rc=2)
# --- gating: destructive without --confirm -> rc3, NO write call ---
check("create-bucket no confirm -> rc3", ["create-bucket", "X"], want_rc=3,
out_has="Would")
check("create-key no confirm -> rc3",
["create-key", "--name", "X", "--capabilities", "listFiles"],
want_rc=3, out_has="Would")
check("delete-key no confirm -> rc3", ["delete-key", "000bogus"], want_rc=3)
# --- lifecycle: read-only listing on the small bucket ---
if smallest:
check(f"lifecycle {smallest} table", ["lifecycle", smallest],
want_rc=0, out_has="Lifecycle rules:")
check(f"lifecycle {smallest} json", ["lifecycle", smallest, "--json"],
want_rc=0, out_json_ok=True)
# --- delete-prefix: REFUSAL paths only (no --confirm, never writes) ---
if smallest:
# Valid-looking machine prefix, no --confirm -> rc3, shows the WOULD-add line
# and the irreversible-deletion warning. This does NOT add a rule.
check("delete-prefix no confirm -> rc3 (would add + warning)",
["delete-prefix", smallest, "MBS-00000000/CBB_SELFTEST/"],
want_rc=3, out_has="Would add purge rule")
# Too-broad prefixes are HARD-FAIL (rc2) even though --confirm is absent here;
# the validation runs first. None of these write anything.
check("delete-prefix empty -> rc2 (too broad)",
["delete-prefix", smallest, ""], want_rc=2, err_has="too broad")
check("delete-prefix no-slash -> rc2 (too broad)",
["delete-prefix", smallest, "MBS-noslash"],
want_rc=2, err_has="no '/'")
check("delete-prefix account-root -> rc2 (needs --allow-account-root)",
["delete-prefix", smallest, "MBS-00000000/"],
want_rc=2, err_has="account root")
# --- lifecycle-remove: REFUSAL path only (no --confirm, never writes) ---
if smallest:
check("lifecycle-remove no confirm -> rc3",
["lifecycle-remove", smallest, "MBS-00000000/CBB_SELFTEST/"],
want_rc=3, out_has="Refusing")
# --- raw gating ---
check("raw write method no confirm -> rc3",
["raw", "--method", "b2_delete_bucket", "--body", "{}"], want_rc=3)
check("raw update_bucket gated -> rc3",
["raw", "--method", "b2_update_bucket", "--body", "{}"], want_rc=3)
check("raw bad json body -> rc2",
["raw", "--method", "b2_list_buckets", "--body", "{bad"], want_rc=2)
check("raw read ok",
["raw", "--method", "b2_list_buckets",
"--body", json.dumps({"accountId": EXPECTED_ACCOUNT_ID})],
want_rc=0, out_json_ok=True)
# --- report ---
print("\n==== b2 skill self-test ====")
npass = sum(1 for r in results if r[0] == "PASS")
for status, name, prob, sample in results:
line = f"[{status}] {name}"
if prob:
line += f" -> {prob}"
print(line)
print(f"\n{npass}/{len(results)} passed, {len(results)-npass} failed")
sys.exit(0 if npass == len(results) else 1)