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>
This commit is contained in:
236
.claude/skills/b2/references/api-reference.md
Normal file
236
.claude/skills/b2/references/api-reference.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Backblaze B2 Native API v3 Reference
|
||||
|
||||
Verified spec for the methods used by this skill, against the live ACG B2
|
||||
account (accountId `46f69bc61163`, region `us-west-001`). All facts below were
|
||||
confirmed against the live API.
|
||||
|
||||
---
|
||||
|
||||
## Authorization (v3)
|
||||
|
||||
- **Authorize URL (fixed, global):**
|
||||
`https://api.backblazeb2.com/b2api/v3/b2_authorize_account`
|
||||
- **Auth:** HTTP Basic. Username = key id, password = application key.
|
||||
- **Method:** GET in the B2 docs; this skill issues it as an HTTPS POST with the
|
||||
Basic header, which the endpoint accepts.
|
||||
|
||||
### Authorize response (v3 shape)
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": "46f69bc61163",
|
||||
"authorizationToken": "<token, valid 24h>",
|
||||
"apiInfo": {
|
||||
"storageApi": {
|
||||
"apiUrl": "https://api001.backblazeb2.com",
|
||||
"s3ApiUrl": "https://s3.us-west-001.backblazeb2.com",
|
||||
"downloadUrl": "https://f001.backblazeb2.com",
|
||||
"recommendedPartSize": 100000000,
|
||||
"absoluteMinimumPartSize": 5000000,
|
||||
"capabilities": ["listBuckets", "listFiles", "..."],
|
||||
"bucketId": null,
|
||||
"namePrefix": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **v3 nesting:** `apiUrl` / `s3ApiUrl` / `downloadUrl` / `capabilities` /
|
||||
`bucketId` / `namePrefix` live under `apiInfo.storageApi`. (v2 returned
|
||||
`apiUrl` / `downloadUrl` at the top level — do NOT parse them there for v3.)
|
||||
- `bucketId == null` and `namePrefix == null` means an **account-wide** key;
|
||||
non-null means the key is scoped to one bucket / name prefix.
|
||||
- The per-account `apiUrl` is dynamic and comes ONLY from this response — never
|
||||
from a config file.
|
||||
|
||||
### Cache model
|
||||
|
||||
The skill caches `authorizationToken` + `apiUrl` + `accountId` (plus the other
|
||||
fields) in `.claude/skills/b2/.cache/auth.json` with an `authorized_at`
|
||||
timestamp. It is treated as valid for ~23h (B2 tokens last 24h). A `401` with
|
||||
code `expired_auth_token` or `bad_auth_token` triggers exactly one re-authorize
|
||||
+ retry. **The cached token is a secret; the cache dir is gitignored.**
|
||||
|
||||
---
|
||||
|
||||
## Subsequent calls
|
||||
|
||||
- **URL:** `POST <apiUrl>/b2api/v3/<method>`
|
||||
- **Header:** `Authorization: <authorizationToken>` (raw token, not Basic).
|
||||
- **Body:** a JSON object.
|
||||
- **Content-Type:** `application/json`.
|
||||
|
||||
### Error shape
|
||||
|
||||
B2 returns HTTP 4xx with a JSON body:
|
||||
|
||||
```json
|
||||
{ "status": 400, "code": "<string>", "message": "<text>" }
|
||||
```
|
||||
|
||||
The skill surfaces `status` / `code` / `message` verbatim. On `401` with
|
||||
`expired_auth_token` / `bad_auth_token` it re-authorizes once and retries; all
|
||||
other errors raise. Destructive calls are never retried automatically.
|
||||
|
||||
---
|
||||
|
||||
## Methods used
|
||||
|
||||
All POST to `<apiUrl>/b2api/v3/<method>`.
|
||||
|
||||
### b2_list_buckets
|
||||
|
||||
- **Body:** `{"accountId": "<acct>", "bucketId": <opt>}`
|
||||
- **Returns:** `{"buckets": [{bucketName, bucketId, bucketType, revision,
|
||||
fileLockConfiguration:{isFileLockEnabled}, lifecycleRules, options}]}`
|
||||
- Pass `bucketId` to scope the result to a single bucket.
|
||||
- **`revision`** (int) is the bucket's optimistic-concurrency version. NOTE:
|
||||
this account's `b2_update_bucket` does NOT accept `ifRevisionMatch`, so this
|
||||
value is informational only — it is read but never sent back on a write.
|
||||
- **`lifecycleRules`** (array) is the bucket's current lifecycle rule set (see
|
||||
the rule shape under `b2_update_bucket`). An empty array means no rules.
|
||||
|
||||
### b2_list_keys
|
||||
|
||||
- **Body:** `{"accountId": "<acct>", "maxKeyCount": 1000,
|
||||
"startApplicationKeyId": <opt>}`
|
||||
- **Returns:** `{"keys": [{keyName, applicationKeyId, capabilities:[...],
|
||||
bucketId, namePrefix, expirationTimestamp}], "nextApplicationKeyId": <null
|
||||
when done>}`
|
||||
- Paginate on `nextApplicationKeyId`.
|
||||
|
||||
### b2_list_file_names
|
||||
|
||||
- **Body:** `{"bucketId": "<id>", "maxFileCount": 10000, "prefix": <opt>,
|
||||
"startFileName": <opt>}`
|
||||
- **Returns:** `{"files": [{fileName, contentLength, action, uploadTimestamp,
|
||||
...}], "nextFileName": <null when done>}`
|
||||
- Lists the **latest** name for each file. Use for quick listings; NOT for
|
||||
size/cost.
|
||||
|
||||
### b2_list_file_versions
|
||||
|
||||
- **Body:** `{"bucketId": "<id>", "maxFileCount": 10000, "prefix": <opt>,
|
||||
"startFileName": <opt>, "startFileId": <opt>}`
|
||||
- **Returns:** `{"files": [{fileName, fileId, contentLength, action, ...}],
|
||||
"nextFileName": <null when done>, "nextFileId": ...}`
|
||||
- **Pagination:** carry BOTH `nextFileName` AND `nextFileId` into the next
|
||||
request; stop when `nextFileName` is null.
|
||||
|
||||
#### action types
|
||||
|
||||
| action | meaning | billed? |
|
||||
|---|---|---|
|
||||
| `upload` | a real stored object | YES |
|
||||
| `hide` | a hide marker (`contentLength` 0) | no |
|
||||
| `start` | an unfinished large file | no |
|
||||
| `folder` | a virtual folder placeholder | no |
|
||||
|
||||
**For size/cost, sum `contentLength` over ALL versions where
|
||||
`action == "upload"`.** B2 bills every stored version, not just the latest, so
|
||||
size MUST use `b2_list_file_versions`, never `b2_list_file_names`.
|
||||
|
||||
### b2_create_bucket (destructive — gated)
|
||||
|
||||
- **Body:** `{"accountId": "<acct>", "bucketName": "<name>", "bucketType":
|
||||
"allPrivate"}`
|
||||
- `bucketType` defaults to `allPrivate`; the CLI allows `allPublic` via `--type`.
|
||||
|
||||
### b2_create_key (destructive — gated)
|
||||
|
||||
- **Body:** `{"accountId": "<acct>", "keyName": "<name>",
|
||||
"capabilities": [...], "bucketId": <opt>, "namePrefix": <opt>,
|
||||
"validDurationInSeconds": <opt>}`
|
||||
- **Returns:** `{applicationKeyId, applicationKey, keyName, capabilities,
|
||||
bucketId, namePrefix}`.
|
||||
- **`applicationKey` is the SECRET and is shown ONCE on creation** — it cannot be
|
||||
retrieved later. The CLI prints it with a prominent warning to store it in the
|
||||
vault immediately.
|
||||
- `bucketId` scopes the key to one bucket (the CLI resolves a bucket name to its
|
||||
id via `b2_list_buckets`).
|
||||
|
||||
### b2_delete_bucket (destructive — gated)
|
||||
|
||||
- **Body:** `{"accountId": "<acct>", "bucketId": "<id>"}`
|
||||
- B2 **refuses to delete a non-empty bucket**; that error is surfaced verbatim.
|
||||
|
||||
### b2_delete_key (destructive — gated)
|
||||
|
||||
- **Body:** `{"applicationKeyId": "<id>"}`
|
||||
|
||||
### b2_update_bucket (destructive — gated)
|
||||
|
||||
Used for the prefix-purge feature: it writes the bucket's lifecycle rules.
|
||||
|
||||
- **Body:** `{"accountId": "<acct>", "bucketId": "<id>",
|
||||
"lifecycleRules": [...]}`
|
||||
- **Returns:** the updated bucket object (same shape as a `b2_list_buckets`
|
||||
entry, including the NEW `revision` and the written `lifecycleRules`).
|
||||
- **`lifecycleRules` REPLACES the entire array — it is NOT additive (caveat).**
|
||||
To add/remove a single rule you MUST first read the current rules via
|
||||
`b2_list_buckets` (scoped to the bucketId), merge your change into the full
|
||||
array, then write the complete set back. Writing just the one rule you want
|
||||
silently drops every other rule.
|
||||
- **No `ifRevisionMatch` on this account/endpoint.** This B2 account's
|
||||
`b2_update_bucket` REJECTS `ifRevisionMatch` with **HTTP 400** `bad_request`
|
||||
("unknown field ... ifRevisionMatch"), so no optimistic-lock token is sent.
|
||||
Writes are therefore **last-write-wins** — the read-merge-write of the full
|
||||
rules array on every change is what keeps pre-existing rules intact. The skill
|
||||
does not implement a 409-conflict retry (there is no revision guard); it makes
|
||||
at most one defensive retry on a transient 5xx, never loops.
|
||||
- B2 allows up to **100** lifecycle rules per bucket.
|
||||
|
||||
#### Lifecycle rule shape
|
||||
|
||||
```json
|
||||
{ "fileNamePrefix": "MBS-<guid>/CBB_<machine>/",
|
||||
"daysFromUploadingToHiding": 1,
|
||||
"daysFromHidingToDeleting": 1 }
|
||||
```
|
||||
|
||||
- `fileNamePrefix` — files whose name starts with this string are governed by the
|
||||
rule. `""` means the WHOLE BUCKET.
|
||||
- `daysFromUploadingToHiding` — after this many days, B2's daily lifecycle pass
|
||||
HIDES the file (creates a hide marker).
|
||||
- `daysFromHidingToDeleting` — after this many days hidden, B2 DELETES the file
|
||||
version permanently.
|
||||
- With both `= 1`, every version older than ~1 day is hidden then deleted on the
|
||||
next daily passes -> full server-side purge of the prefix within ~24-48h. This
|
||||
is the mechanism the skill uses to purge prefixes with 1.2M+ versions, where
|
||||
per-file `b2_delete_file_version` would be impractical. **Irreversible — no
|
||||
recycle bin.**
|
||||
|
||||
### b2_get_file_info / b2_delete_file_version
|
||||
|
||||
- `b2_get_file_info` body: `{"fileId": "<id>"}` (read).
|
||||
- `b2_delete_file_version` body: `{"fileName": "<name>", "fileId": "<id>"}`
|
||||
(destructive; exposed only via `raw --confirm` or the client helper).
|
||||
|
||||
---
|
||||
|
||||
## Cost formula
|
||||
|
||||
```
|
||||
GB = bytes / 1_000_000_000 # decimal GB (billing unit, NOT 2^30)
|
||||
cost = GB * RATE_PER_GB_USD # RATE_PER_GB_USD = 0.00695 (ACG cost basis)
|
||||
```
|
||||
|
||||
`RATE_PER_GB_USD` is ACG's cost basis, recorded in
|
||||
`.claude/memory/reference_backblaze_storage_rate.md`, and used by the GuruRMM
|
||||
mspbackups storage-cost calc. Override with `--rate`. The `usage` report sums
|
||||
upload-version bytes per bucket, converts to decimal GB, multiplies by the rate,
|
||||
sorts by cost desc, and prints a TOTAL row plus grand totals. For `ACG-*`
|
||||
buckets it derives a client label by stripping the `ACG-` prefix.
|
||||
|
||||
---
|
||||
|
||||
## Account structure (live)
|
||||
|
||||
- accountId `46f69bc61163`, region `us-west-001`.
|
||||
- 12 buckets (all `allPrivate`): ACG-BST, ACG-Brett, ACG-Dataforth, ACG-GLAZTECH,
|
||||
ACG-IX, ACG-Internal, ACG-Lens, ACG-PST, ACG-REDNOUR, Horseshoe,
|
||||
MSPBackups20200311, VWP-Backup. Most hold MSP360/CloudBerry data
|
||||
(`MBS-<guid>/CBB_<CLIENT>/...`).
|
||||
- 2 application keys: `cloudberrykey` (`00146f69bc611630000000003`, the
|
||||
MSP360/CloudBerry key) and `ClaudeTools` (`00146f69bc611630000000009`, used by
|
||||
this skill).
|
||||
Reference in New Issue
Block a user