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>
201 lines
7.9 KiB
Python
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)
|