From 4a093e7fa515cf221cbebdbf353a914e53c49c70 Mon Sep 17 00:00:00 2001 From: ppenguin Date: Thu, 29 Oct 2026 12:02:51 +0100 Subject: [PATCH 1/2] docs(api): proposal: a backend-neutral storage layer API (/v2/storage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - one pool over the drives; layouts none / mirror(copies) / parity(p); backend chosen per layout - drives with roles and verbs; pool with state (as /v1/disks/raid), protection, members, verbs - plan → preview → apply: intent → plan (steps, commands, effects, disks erased, text to type); apply re-plans from a fresh look, runs only when unchanged, as a job - spec only, not wired into codegen; implemented by ReCasaNix's recasanix-storage (btrfs, md) --- api/storage/openapi.yaml | 335 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 api/storage/openapi.yaml diff --git a/api/storage/openapi.yaml b/api/storage/openapi.yaml new file mode 100644 index 0000000..814c0e7 --- /dev/null +++ b/api/storage/openapi.yaml @@ -0,0 +1,335 @@ +openapi: 3.0.3 + +info: + title: CasaOS storage layer API (proposal) + version: v2 + description: |- + A backend-neutral storage layer: one pool over the drives, made, grown, repaired and checked + from the dashboard. The words are the person's (layout, protection, disk), not the backend's + (btrfs profile, md level): the service picks the backend per layout and says which it used. + + - **Layouts**: `none` (capacity adds up), `mirror` with `copies`, `parity` with `parity` disks. + - **Plan → preview → apply**: an intent gives a plan (steps, exact commands, effects, disks erased, + text to type); applying makes the plan again from a fresh look and runs it only when nothing + changed; it then runs as a job. Nothing from a request reaches a command line other than a disk + the fresh listing offers for that action. + - **Verbs**: each pool and drive lists what can be asked of it now; a client shows only those. + + Implemented by ReCasaNix's recasanix-storage (btrfs; parity layouts on md with btrfs on top). + CasaOS-UI's Storage manager shows its Pool tab when `GET /v2/storage` answers. + +servers: + - url: /v2/storage + +security: + - access_token: [] + +paths: + /: + get: + summary: The pool, the drives, the layouts on the free drives, recent jobs + operationId: getStorage + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + message: { type: string } + data: { $ref: "#/components/schemas/Overview" } + + /layouts: + get: + summary: What each layout gives on these free drives + operationId: getLayouts + parameters: + - name: disks + in: query + description: drive names, comma-separated; unknown or non-free names are ignored + schema: { type: string, example: "sdb,sdc" } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + message: { type: string } + data: + type: array + items: { $ref: "#/components/schemas/LayoutOption" } + + /plans: + post: + summary: Plan an action (preview); nothing changes + operationId: plan + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Intent" } + responses: + "200": + description: The plan + content: + application/json: + schema: + type: object + properties: + message: { type: string } + data: { $ref: "#/components/schemas/Plan" } + "400": { $ref: "#/components/responses/Message" } + "409": + description: Refused; `message` says why, for the person + content: + application/json: + schema: { $ref: "#/components/schemas/Message" } + + /plans/{id}/apply: + post: + summary: Apply a plan made before + operationId: apply + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + confirm: + type: string + description: the plan's `confirm`, typed by the person; empty when it asks none + responses: + "202": + description: Started + content: + application/json: + schema: + type: object + properties: + message: { type: string } + data: { $ref: "#/components/schemas/Job" } + "400": + description: The confirmation does not match + content: + application/json: + schema: { $ref: "#/components/schemas/Message" } + "404": + description: No such plan (expired after 10 minutes, or applied) + content: + application/json: + schema: { $ref: "#/components/schemas/Message" } + "409": + description: The storage changed since the plan was made, or another job runs + content: + application/json: + schema: { $ref: "#/components/schemas/Message" } + + /jobs: + get: + summary: Recent jobs, newest last + operationId: getJobs + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + message: { type: string } + data: + type: array + items: { $ref: "#/components/schemas/Job" } + +components: + securitySchemes: + access_token: + type: apiKey + in: header + name: Authorization + description: the user's access token, bare or `Bearer ` + + responses: + Message: + description: Message + content: + application/json: + schema: { $ref: "#/components/schemas/Message" } + + schemas: + Message: + type: object + properties: + message: { type: string } + + Verb: + type: string + enum: [create-pool, add-disk, replace-disk, remove-disk, change-layout, check, cancel-check, mount-degraded, wipe-disk] + + Layout: + type: object + required: [kind] + properties: + kind: { type: string, enum: [none, mirror, parity] } + copies: { type: integer, description: "mirror: 2 or 3" } + parity: { type: integer, description: "parity: 1 (RAID 5) or 2 (RAID 6)" } + + LayoutOption: + type: object + properties: + layout: { $ref: "#/components/schemas/Layout" } + backend: { type: string, example: btrfs } + profile: { type: string, description: "the backend's word", example: raid1 } + description: { type: string } + capacity: { type: integer, format: int64, description: "bytes for data on the chosen drives, about" } + tolerates: { type: integer, description: "disks it survives failing" } + min_disks: { type: integer } + possible: { type: boolean } + reason: { type: string, description: "why not possible" } + + Overview: + type: object + properties: + pool: + nullable: true + allOf: [{ $ref: "#/components/schemas/Pool" }] + drives: + type: array + items: { $ref: "#/components/schemas/Drive" } + layouts: + type: array + items: { $ref: "#/components/schemas/LayoutOption" } + jobs: + type: array + items: { $ref: "#/components/schemas/Job" } + busy: { type: boolean, description: "a job runs" } + + Pool: + type: object + properties: + name: { type: string } + backend: { type: string, enum: [btrfs, md] } + layout: { $ref: "#/components/schemas/Layout" } + layout_known: { type: boolean, description: "false: a profile not offered (made by hand), or not readable (not mounted)" } + profile: { type: string } + description: { type: string } + state: + type: string + enum: [ok, degraded, rebuilding, checking, resyncing, reshaping, inactive] + description: as GET /v1/disks/raid + mounted: { type: boolean } + tolerates: { type: integer, description: "disks it survives failing now" } + disks: { type: integer } + missing: { type: integer } + size: { type: integer, format: int64 } + used: { type: integer, format: int64 } + avail: { type: integer, format: int64 } + action: { type: string, enum: [idle, recover, check, reshape, resync, repair] } + progress: { type: number, description: "percent; -1 unknown" } + members: + type: array + items: { $ref: "#/components/schemas/PoolMember" } + verbs: + type: array + items: { $ref: "#/components/schemas/Verb" } + + PoolMember: + type: object + properties: + name: { type: string, description: "device name; `devid N` for a btrfs member without a device" } + disk: { type: string, description: "the drive it is on; empty when gone" } + state: { type: string, enum: [active, rebuilding, spare, faulty] } + size: { type: integer, format: int64 } + errors: { type: integer, format: int64, description: "the device's error counters, summed" } + + Drive: + type: object + properties: + name: { type: string } + path: { type: string } + model: { type: string } + serial: { type: string } + type: { type: string, enum: [HDD, SSD, NVMe, USB, MMC] } + size: { type: integer, format: int64 } + smart_status: { type: string, enum: [passed, failed, unavailable] } + temperature: { type: integer } + role: + type: string + enum: [system, member, free, small, foreign, busy] + description: |- + system: carries the OS · member: of the pool · free: blank, usable · small: blank, under the + minimum size · foreign: holds something else, not in use (can be wiped) · busy: in use elsewhere + pool: { type: string } + content: { type: string, description: "what is on it: btrfs, ext4, linux_raid_member, partitions…" } + verbs: + type: array + items: { $ref: "#/components/schemas/Verb" } + + Intent: + type: object + required: [verb] + properties: + verb: { $ref: "#/components/schemas/Verb" } + layout: { $ref: "#/components/schemas/Layout" } + disks: + type: array + items: { type: string } + description: drive names (free drives; wipe-disk a foreign one) + member: { type: string, description: "a pool member's name (replace-disk, remove-disk)" } + + Effects: + type: object + properties: + capacity_before: { type: integer, format: int64 } + capacity_after: { type: integer, format: int64 } + tolerates_before: { type: integer } + tolerates_after: { type: integer } + erases: + type: array + items: { type: string } + description: drives whose content is destroyed + background: { type: boolean, description: "the work goes on after the steps (rebuild, conversion, check)" } + notes: + type: array + items: { type: string } + + Plan: + type: object + properties: + id: { type: string } + intent: { $ref: "#/components/schemas/Intent" } + summary: { type: string } + steps: + type: array + items: { type: string } + commands: + type: array + items: + type: array + items: { type: string } + description: the exact argument vectors, for an advanced view + effects: { $ref: "#/components/schemas/Effects" } + confirm: { type: string, description: "to be typed to apply; empty: none" } + expires: { type: string, format: date-time } + + Job: + type: object + properties: + id: { type: string } + verb: { $ref: "#/components/schemas/Verb" } + summary: { type: string } + state: { type: string, enum: [running, done, failed] } + step: { type: integer, description: "steps finished" } + steps: { type: integer } + error: { type: string } + started: { type: string, format: date-time } + ended: { type: string, format: date-time } From 3286aa876f3c84872afb15c4e3b6fe6d70b3ba41 Mon Sep 17 00:00:00 2001 From: ppenguin Date: Thu, 12 Nov 2026 10:58:22 +0100 Subject: [PATCH 2/2] docs(api): storage layer: pools[], stripe, delete-pool, contract for several backends - pools[] with id; intents name their pool; top-level verbs (create-pool) - layout.stripe: speed only (RAID 10 / RAID 0), kind stays the protection - delete-pool, restore-layout verbs; pool.problem - the spec is a contract: LocalStorage = reference implementation --- api/storage/openapi.yaml | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/api/storage/openapi.yaml b/api/storage/openapi.yaml index 814c0e7..a5b6c55 100644 --- a/api/storage/openapi.yaml +++ b/api/storage/openapi.yaml @@ -4,18 +4,26 @@ info: title: CasaOS storage layer API (proposal) version: v2 description: |- - A backend-neutral storage layer: one pool over the drives, made, grown, repaired and checked + A backend-neutral storage layer: pools over the drives, made, grown, repaired and checked from the dashboard. The words are the person's (layout, protection, disk), not the backend's (btrfs profile, md level): the service picks the backend per layout and says which it used. - - **Layouts**: `none` (capacity adds up), `mirror` with `copies`, `parity` with `parity` disks. + **A contract, not one service's API**: several backends implement it; a client targets only this. + + - **Pools**: a list; each has an `id`, which every intent but `create-pool` and `wipe-disk` names. + How many a box may hold is the backend's policy, shown by its verbs (`create-pool` offered or not). + - **Layouts**: `kind` is the protection: `none` (capacity adds up), `mirror` with `copies`, `parity` + with `parity` disks. `stripe` is speed only (data spread over all disks), it changes no `tolerates`: + `mirror{copies:2, stripe}` = RAID 10, `none{stripe}` = RAID 0. A backend lists what it offers in + `GET /layouts`. - **Plan → preview → apply**: an intent gives a plan (steps, exact commands, effects, disks erased, text to type); applying makes the plan again from a fresh look and runs it only when nothing changed; it then runs as a job. Nothing from a request reaches a command line other than a disk the fresh listing offers for that action. - **Verbs**: each pool and drive lists what can be asked of it now; a client shows only those. - Implemented by ReCasaNix's recasanix-storage (btrfs; parity layouts on md with btrfs on top). + Implementations: LocalStorage (reference; md), ReCasaNix's recasanix-storage (one pool at most; + btrfs, parity layouts on md with btrfs on top). CasaOS-UI's Storage manager shows its Pool tab when `GET /v2/storage` answers. servers: @@ -27,7 +35,7 @@ security: paths: /: get: - summary: The pool, the drives, the layouts on the free drives, recent jobs + summary: The pools, the drives, the layouts on the free drives, recent jobs operationId: getStorage responses: "200": @@ -173,15 +181,19 @@ components: Verb: type: string - enum: [create-pool, add-disk, replace-disk, remove-disk, change-layout, check, cancel-check, mount-degraded, wipe-disk] + enum: [create-pool, delete-pool, add-disk, replace-disk, remove-disk, change-layout, restore-layout, check, cancel-check, mount-degraded, wipe-disk] + description: |- + delete-pool: the pool taken apart, its disks blank (erases; typed confirmation) · + restore-layout: data not in the pool's layout (written while a disk was missing) put back into it Layout: type: object required: [kind] properties: - kind: { type: string, enum: [none, mirror, parity] } + kind: { type: string, enum: [none, mirror, parity], description: "the protection" } copies: { type: integer, description: "mirror: 2 or 3" } parity: { type: integer, description: "parity: 1 (RAID 5) or 2 (RAID 6)" } + stripe: { type: boolean, description: "speed only: none → RAID 0, mirror{copies:2} → RAID 10; not with parity" } LayoutOption: type: object @@ -199,9 +211,13 @@ components: Overview: type: object properties: - pool: - nullable: true - allOf: [{ $ref: "#/components/schemas/Pool" }] + pools: + type: array + items: { $ref: "#/components/schemas/Pool" } + verbs: + type: array + items: { $ref: "#/components/schemas/Verb" } + description: what can be asked of the box outside any pool (create-pool) drives: type: array items: { $ref: "#/components/schemas/Drive" } @@ -216,6 +232,7 @@ components: Pool: type: object properties: + id: { type: string, description: "what an intent names the pool by" } name: { type: string } backend: { type: string, enum: [btrfs, md] } layout: { $ref: "#/components/schemas/Layout" } @@ -241,6 +258,7 @@ components: verbs: type: array items: { $ref: "#/components/schemas/Verb" } + problem: { type: string, description: "what is wrong beyond the state, for the person" } PoolMember: type: object @@ -268,7 +286,7 @@ components: description: |- system: carries the OS · member: of the pool · free: blank, usable · small: blank, under the minimum size · foreign: holds something else, not in use (can be wiped) · busy: in use elsewhere - pool: { type: string } + pool: { type: string, description: "member: the pool's id" } content: { type: string, description: "what is on it: btrfs, ext4, linux_raid_member, partitions…" } verbs: type: array @@ -279,6 +297,7 @@ components: required: [verb] properties: verb: { $ref: "#/components/schemas/Verb" } + pool: { type: string, description: "the pool's id; every verb but create-pool and wipe-disk ('' = the only pool)" } layout: { $ref: "#/components/schemas/Layout" } disks: type: array