Files
claudetools/.claude/skills/b2/SKILL.md
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

262 lines
11 KiB
Markdown

---
name: b2
description: >-
Manage Arizona Computer Guru's (ACG) Backblaze B2 storage account via the B2
Native API v3. Talks to the LIVE production B2 account (accountId 46f69bc61163,
region us-west-001) that holds the per-client MSP360/CloudBerry backup
destinations. List buckets and application keys, list files / file versions,
compute per-bucket stored size, and produce the headline storage-cost report
(the mspbackups storage-cost calc). Provision buckets and scoped backup keys
and delete buckets/keys (all destructive ops are gated behind --confirm).
Read-only by default. Invoke for: "backblaze", "b2", "b2 storage", "bucket",
"storage cost", "backup storage", "mspbackups storage", "list buckets b2".
---
# Backblaze B2 Skill
Standalone CLI client for the Backblaze B2 Native API v3. Talks to the live ACG
B2 account. Read-only by default; destructive operations are gated behind
`--confirm`.
## Running the CLI
This machine's Python launcher is `py` (per identity.json). The scripts also
work with `python`/`python3`.
```bash
# from the scripts dir, or pass full paths
py "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" status
py "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" buckets
py "$CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py" usage --json
```
Transport auto-selects: uses `httpx` if installed, otherwise stdlib `urllib`
(no third-party dependency required).
## Credentials
Credentials are NEVER hardcoded. At runtime the client loads them from the SOPS
vault:
```
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
get-field projects/claudetools/backblaze-b2.sops.yaml key_id
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
get-field projects/claudetools/backblaze-b2.sops.yaml credentials.application_key
```
`CLAUDETOOLS_ROOT` resolves from the env var, else `claudetools_root` in
`.claude/identity.json`, else the repo root derived from the script's own
location (no hardcoded drive letters). For testing you can override with the
`B2_KEY_ID` and `B2_APPLICATION_KEY` env vars (both must be set to use the
override).
Authorization is HTTP Basic against `b2_authorize_account` (username = key id,
password = application key). The skill uses the "ClaudeTools" application key
(`00146f69bc611630000000009`).
## Cache model (important — the token is a SECRET)
After a successful authorize, the CLI caches the auth result at
`.claude/skills/b2/.cache/auth.json`:
- **Cached:** `authorizationToken` (the bearer token, valid ~24h), the
per-account `apiUrl` / `s3ApiUrl` / `downloadUrl`, `accountId`, key
capabilities, and key scope (`bucketId` / `namePrefix`).
- **TTL:** treated as valid for ~23h. The client re-authorizes when the cache is
stale, or automatically on a `401` with `expired_auth_token` / `bad_auth_token`
(exactly one re-authorize + retry per call).
- **This cache holds a live secret.** It is gitignored twice (root `.gitignore`
plus a local `.gitignore` in the skill dir) and must never be committed.
The per-account `apiUrl` always comes from the live authorize response — there
is no config file for endpoints (project rule).
## Safety gating
Destructive subcommands refuse to run without `--confirm`; without it they print
what they would do and exit non-zero (3):
- `create-bucket <name> [--type allPrivate|allPublic] --confirm`
- `create-key --name <n> --capabilities <c1,c2,...> [--bucket <name>] [--prefix <p>] [--duration-seconds N] --confirm`
- `delete-bucket <name> --confirm` (B2 refuses to delete a non-empty bucket — the error is surfaced verbatim)
- `delete-key <applicationKeyId> --confirm`
- `delete-prefix <name> <prefix> [<prefix> ...] --confirm` (schedules an IRREVERSIBLE lifecycle purge — see "Prefix purge / lifecycle" below)
- `lifecycle-remove <name> <prefix> [<prefix> ...] --confirm` (removes lifecycle rules)
`create-key` prints the returned `applicationKey` exactly ONCE with a warning —
B2 never shows it again, so store it in the vault immediately.
`raw` refuses any method name containing `create` / `delete` / `update` / `hide`
/ `cancel` unless `--confirm` is passed. `raw` prints the upstream response
verbatim, which may carry sensitive data (keys, tokens) — review before pasting
into tickets/logs. Destructive calls are NEVER retried automatically.
## Common commands
```bash
B2="py $CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py"
# Status / inventory
$B2 status
$B2 buckets
$B2 keys
# Files
$B2 files ACG-Internal # latest names
$B2 files ACG-Internal --prefix MBS- --limit 50
$B2 files ACG-Internal --versions # all versions
# Size + cost
$B2 bucket-size ACG-Internal
$B2 usage # headline cost report, all buckets
$B2 usage --bucket ACG-Dataforth # one bucket
$B2 cost --json # alias for usage
# Provisioning (gated)
$B2 create-bucket ACG-NewClient --confirm
$B2 create-key --name acg-newclient-backup \
--capabilities listBuckets,listFiles,readFiles,writeFiles,deleteFiles \
--bucket ACG-NewClient --confirm
$B2 delete-bucket ACG-OldClient --confirm
$B2 delete-key 00146f69bc611630000000abc --confirm
# Lifecycle / prefix purge (see next section)
$B2 lifecycle ACG-Internal # read-only: list current rules
$B2 delete-prefix ACG-Internal "MBS-<guid>/CBB_<machine>/" --confirm
$B2 lifecycle-remove ACG-Internal "MBS-<guid>/CBB_<machine>/" --confirm
# Power use — any v3 method directly
$B2 raw --method b2_list_buckets --body '{"accountId":"46f69bc61163"}'
```
## Prefix purge / lifecycle
Some backup destinations hold **1.2M+ file versions** under a single machine
prefix. Per-file deletion (`b2_delete_file_version` per version) is impractical
at that scale, so this skill purges a prefix via a **B2 bucket lifecycle rule**
instead. B2's own daily lifecycle pass does the deletion server-side.
### How the purge mechanism works
A purge rule is:
```json
{ "fileNamePrefix": "MBS-<guid>/CBB_<machine>/",
"daysFromUploadingToHiding": 1,
"daysFromHidingToDeleting": 1 }
```
With both day-counts at `1`, B2's daily lifecycle pass **hides** any file more
than 1 day old, then **deletes** hidden files more than 1 day old. Every version
under the prefix (all are years old) becomes eligible immediately, so the prefix
is fully purged within **~24-48h** (two daily passes). This is **irreversible**:
there is no recycle bin and no undo once the pass deletes a version.
`fileNamePrefix: ""` means the WHOLE BUCKET — the skill refuses that and any
other too-broad prefix (see safety below).
### Lifecycle rules REPLACE, they don't append
`b2_update_bucket`'s `lifecycleRules` **replaces the entire array** — it is not
additive. The skill therefore always reads the current rules (`b2_list_buckets`
scoped to the bucketId), merges the change, and writes the full set back. This
read-merge-write of the complete array is what preserves pre-existing rules.
**This account's `b2_update_bucket` does NOT support `ifRevisionMatch`** (it
returns HTTP 400 `bad_request: unknown field ... ifRevisionMatch`), so the skill
sends no optimistic-lock token and writes are **last-write-wins**. There is no
409-conflict retry (no revision guard to violate); the skill makes at most one
defensive retry on a transient 5xx, then fails — it never loops. The bucket
`revision` is still read and displayed for reference, but is never sent back.
B2 allows up to **100** lifecycle rules per bucket; the skill refuses an update
that would exceed that.
### Commands
```bash
B2="py $CLAUDETOOLS_ROOT/.claude/skills/b2/scripts/b2.py"
# 1. READ-ONLY: see the bucket's current lifecycle rules + revision
$B2 lifecycle ACG-Internal
$B2 lifecycle ACG-Internal --json
# 2. DRY-RUN: what WOULD be scheduled (no --confirm -> exits 3, writes nothing)
$B2 delete-prefix ACG-Internal "MBS-<guid>/CBB_<machine>/"
# 3. COMMIT the purge (gated). Idempotent: an identical rule already present
# is skipped. You can pass several prefixes at once.
$B2 delete-prefix ACG-Internal \
"MBS-<guid>/CBB_OLDPC1/" \
"MBS-<guid>/CBB_OLDPC2/" --confirm
# 4. After ~24-48h, verify the data is gone (size should have dropped)
$B2 bucket-size ACG-Internal
# 5. Clean up the now-spent purge rule(s) so they don't sit on the bucket
$B2 lifecycle-remove ACG-Internal \
"MBS-<guid>/CBB_OLDPC1/" \
"MBS-<guid>/CBB_OLDPC2/" --confirm
```
### Safety validations (hard-fail even with --confirm)
`delete-prefix` refuses (exit 2) any prefix that is:
- empty, `"/"`, `"*"`, or **contains no `/`** — too broad (would risk a
whole-bucket or whole-account-tree purge);
- exactly an **account root** `MBS-<guid>/` (one path segment + trailing slash) —
this would purge ALL machines under that account. Override only with the extra
`--allow-account-root` flag (NOT used by default — purge machine-level `CBB_`
prefixes instead).
A valid machine target looks like `MBS-<guid>/CBB_<machine>/`. The command also
**warns** (does not fail) when a prefix lacks a trailing `/`, since a slash-less
prefix can match a sibling whose name merely starts with the same characters.
Without `--confirm`, `delete-prefix` prints the exact rule(s) it WOULD add and a
prominent `[WARNING]` about irreversible deletion, then exits non-zero (3) —
matching the other gated commands. `lifecycle-remove` is similarly gated
(removing a rule is far less dangerous than adding one, but gated for
consistency); without `--confirm` it lists which existing rules it would remove
and exits 3.
### Cleanup workflow
`delete-prefix` -> wait ~24-48h for B2's lifecycle pass -> `bucket-size` to
verify the stored size dropped -> `lifecycle-remove` to strip the spent purge
rule. Leaving the rule in place is harmless (everything under the prefix is
already gone) but tidy buckets are easier to reason about.
## Storage cost
ACG's Backblaze B2 cost basis is **$0.00695 per GB** stored, defined as
`RATE_PER_GB_USD` in `scripts/b2_client.py` (recorded in
`.claude/memory/reference_backblaze_storage_rate.md`) and used by the GuruRMM
mspbackups storage-cost calc. Override at the CLI with `--rate`.
GB is **decimal** (`bytes / 1e9`), matching how storage providers bill — NOT
2^30. Cost = `GB * rate`.
Size MUST be summed over **all file versions** (`b2_list_file_versions`,
`action == "upload"`), not just the latest names, because B2 bills every stored
version. `bucket-size` and `usage` do this automatically. For very large buckets
this issues many list transactions — `usage` prints a `[WARNING]` to that effect.
## Account structure (for context)
- accountId `46f69bc61163`, region `us-west-001`.
- 12 buckets (all `allPrivate`), mostly per-client MSP360/CloudBerry backup
destinations with keys like `MBS-<guid>/CBB_<CLIENT>/...`: ACG-BST, ACG-Brett,
ACG-Dataforth, ACG-GLAZTECH, ACG-IX, ACG-Internal, ACG-Lens, ACG-PST,
ACG-REDNOUR, Horseshoe, MSPBackups20200311, VWP-Backup. `usage` derives a
client label by stripping the `ACG-` prefix.
- 2 application keys: `cloudberrykey` (the MSP360/CloudBerry key) and
`ClaudeTools` (the key this skill uses).
## Reference
Full verified v3 auth flow, every method used with request/response shape, the
file-version billing note, pagination, the error shape, and the cost formula:
`references/api-reference.md`.