diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a4c1130..100067d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,7 @@ jobs:
api: ${{ steps.filter.outputs.api }}
simulator: ${{ steps.filter.outputs.simulator }}
frontend: ${{ steps.filter.outputs.frontend }}
+ webflask: ${{ steps.filter.outputs.webflask }}
cicd: ${{ steps.filter.outputs.cicd }}
wiki: ${{ steps.filter.outputs.wiki }}
steps:
@@ -65,6 +66,17 @@ jobs:
- 'tools/emulator_spec/**'
frontend:
- 'frontend/**'
+ # The Flask NAS UI. Deliberately NOT including 'pyproject.toml' here:
+ # the `backend` filter already carries it, so a dependency bump
+ # (including every Dependabot PR) still gets ruff + mypy + the whole
+ # tests/unit run, which imports this package. Adding it here would
+ # only tack a docker build onto that traffic. 'docker-compose.yml' IS
+ # included because it defines the web-flask service this job builds.
+ webflask:
+ - 'openblade/web_flask/**'
+ - 'tests/unit/test_web_flask_app.py'
+ - 'Dockerfile.web'
+ - 'docker-compose.yml'
cicd:
- '.github/workflows/**'
# docs/wiki/reference/{cli,api}.md are GENERATED from the Typer CLI
@@ -101,11 +113,14 @@ jobs:
needs: changes
if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.api == 'true' || needs.changes.outputs.simulator == 'true' }}
runs-on: ubuntu-latest
- # Advisory: ~300 pre-existing mypy errors across the god-file modules. Fixing
- # them safely is a large, staged effort (some involve API param names that are
- # part of the wire contract). This job still runs and reports, but does not
- # fail CI. Not a required check. Tracked for incremental cleanup.
- continue-on-error: true
+ # BLOCKING since the toolchain was pinned (mypy==2.3.0 via the dev extra) and
+ # the baseline cleared to zero errors under `strict = true`. If this job goes
+ # red, the fix is an annotation or a narrowing at the reported site with the
+ # PINNED version -- never a version bump in the same change, and never a
+ # widen-to-Any. `[tool.mypy]` carries no per-module overrides -- the whole
+ # package is checked at the same strictness -- and each of the four
+ # `# type: ignore` comments left in openblade/ (down from 18) carries its
+ # justification at the site.
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
@@ -213,6 +228,32 @@ jobs:
- run: npm run build
- run: npm run test -- --run
+ web-flask-smoke:
+ name: web-flask-smoke
+ needs: changes
+ if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.webflask == 'true' }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: "pip"
+ - run: pip install -e '.[dev]'
+ # BLOCKING mypy, scoped to this package alone. `mypy openblade` in
+ # backend-typecheck has to stay advisory because of ~300 pre-existing errors
+ # in the god-file modules; openblade/web_flask is clean under strict mode
+ # today, and this job is what keeps it that way instead of letting it join
+ # the backlog.
+ - run: mypy openblade/web_flask
+ # Redundant with backend-tests (tests/unit covers this file) but kept so the
+ # job is a complete gate on its own for a web_flask-only change. Seconds.
+ - run: pytest tests/unit/test_web_flask_app.py -q --tb=short
+ # The only thing in CI that builds this image. web-flask sits behind a
+ # compose profile, so name it explicitly — a bare `docker compose build`
+ # skips profiled services.
+ - run: docker compose build web-flask
+
wiki-reference:
name: wiki-reference
needs: changes
@@ -260,7 +301,7 @@ jobs:
# branch-protection check (plus operability-gate). It understands a valid
# `skipped` result from path-filtered jobs and fails on any real failure or
# cancellation, so branch protection no longer has to reason about skips.
- # backend-typecheck is advisory (reported, never blocking); backend-lint blocks.
+ # backend-lint and backend-typecheck both block through this gate now.
ci-gate:
name: ci-gate
needs:
@@ -270,6 +311,7 @@ jobs:
- api-aml-integration
- i3-smoke
- frontend-build-test
+ - web-flask-smoke
- cicd-workflow-validate
- wiki-reference
if: ${{ always() }}
@@ -288,12 +330,20 @@ jobs:
check api-aml-integration "${{ needs.api-aml-integration.result }}"
check i3-smoke "${{ needs.i3-smoke.result }}"
check frontend-build-test "${{ needs.frontend-build-test.result }}"
+ check web-flask-smoke "${{ needs.web-flask-smoke.result }}"
check cicd-workflow-validate "${{ needs.cicd-workflow-validate.result }}"
# Blocking on purpose: the fix is one command
# (`python3 tools/gen_wiki_reference.py`) and a stale generated page
# is indistinguishable from a correct one to a reader.
check wiki-reference "${{ needs.wiki-reference.result }}"
- echo "advisory (non-blocking): backend-lint=${{ needs.backend-lint.result }} backend-typecheck=${{ needs.backend-typecheck.result }}"
+ # Blocking since the mypy baseline was cleared to zero under
+ # `strict = true` and the toolchain pinned. A red typecheck means a new
+ # annotation is missing, which is cheap to fix at the reported site.
+ check backend-typecheck "${{ needs.backend-typecheck.result }}"
+ # Blocking: the pinned-toolchain baseline is zero findings, and a
+ # lint red outside this gate never actually gated when ci-gate is
+ # the only required check (the earlier flip missed this row).
+ check backend-lint "${{ needs.backend-lint.result }}"
if [ "$fail" -ne 0 ]; then
echo "::error::ci-gate: a required job failed or was cancelled"
exit 1
diff --git a/.github/workflows/mhvtl-weekly.yml b/.github/workflows/mhvtl-weekly.yml
index b6d4e6c..571e088 100644
--- a/.github/workflows/mhvtl-weekly.yml
+++ b/.github/workflows/mhvtl-weekly.yml
@@ -126,6 +126,18 @@ jobs:
echo "--- systemd (assumption 3):"
systemctl is-system-running 2>&1 || true
ps -p 1 -o comm= || true
+ # Assumption 4, added after run 35688121678: mhvtl only registers SCSI
+ # targets; /dev/nstN comes from the kernel's `st` upper-level driver,
+ # which an Azure kernel keeps in linux-modules-extra rather than the
+ # base linux-modules package the generic kernel uses.
+ echo "--- upper-level SCSI drivers st/ch (assumption 4):"
+ modinfo st 2>&1 | head -3 || echo "st: NOT in this kernel's module set"
+ modinfo ch 2>&1 | head -3 || echo "ch: NOT in this kernel's module set"
+ apt-cache policy "linux-modules-extra-$(uname -r)" || true
+ # Assumption 5: /dev/sgN is 0660 root:tape, and env.sh runs sg_inq as
+ # the unprivileged runner user.
+ echo "--- identity of the user that will run env.sh (assumption 5):"
+ id || true
echo "--- disk / cpu budget:"
df -h / || true
nproc || true
@@ -160,9 +172,17 @@ jobs:
timeout-minutes: 20
run: |
set -euxo pipefail
+ # libsnmp-dev added after run 35688121678: ltfs configure looks for
+ # net-snmp >= 5.3 and there is no --disable-snmp, so without it the
+ # build dies at
+ # ./configure: line 14786: net-snmp-config: command not found
+ # configure: error: Package requirements (net-snmp >= 5.3) were not met
+ # libsnmp-dev is what provides net-snmp-config and the pkg-config file.
+ # The rehearsal host already had it installed for unrelated reasons,
+ # which is why docs/runbooks/mhvtl-rehearsal.md §1 never mentions it.
sudo apt-get install -y --no-install-recommends \
build-essential autoconf automake libtool pkg-config git \
- libfuse-dev libicu-dev libxml2-dev uuid-dev
+ libfuse-dev libicu-dev libxml2-dev uuid-dev libsnmp-dev
# ./configure probes `icu-config`, removed in ICU 62+. The documented
# ICU_MODULE_CFLAGS / ICU_MODULE_LIBS escape hatch does NOT work —
@@ -203,9 +223,24 @@ jobs:
continue-on-error: true
run: |
set -euxo pipefail
+ # No tarball means the build step failed (or was skipped). Say so and
+ # stop, rather than emitting a confusing `tar: Cannot open` on top of
+ # the real error further up the log.
+ if [ ! -f /tmp/ltfs-stage.tar ]; then
+ echo "No /tmp/ltfs-stage.tar — the LTFS build did not produce one. See the build step above."
+ exit 1
+ fi
# The cached tarball only carries the build products; the shared
# libraries it links against still have to be present at runtime.
- sudo apt-get install -y --no-install-recommends libfuse2 libicu-dev libxml2 uuid-runtime || true
+ # Installed ONE AT A TIME on purpose: these names are release-specific
+ # (noble renamed libfuse2 -> libfuse2t64 and bumps libsnmpNN), and a
+ # single apt-get with one unknown name installs NOTHING while the
+ # trailing `|| true` hides it. Per-package means a wrong guess costs
+ # only that guess.
+ for pkg in libfuse2t64 libfuse2 libicu-dev libxml2 uuid-runtime \
+ libsnmp40 libsnmp-dev; do
+ sudo apt-get install -y --no-install-recommends "$pkg" || true
+ done
sudo tar -C / -xf /tmp/ltfs-stage.tar
sudo ldconfig
command -v mkltfs
@@ -246,6 +281,31 @@ jobs:
sudo dmesg | tail -80 || true
cat /etc/mhvtl/device.conf || true
+ # Resolving the rig is its own step so that "the rig cannot be addressed"
+ # fails HERE, loudly, instead of leaking into the pytest step.
+ #
+ # Run 35688121678 is exactly why. The old code was
+ # `eval "$(scripts/mhvtl/env.sh)"` inside the pytest step: a failing
+ # command substitution does NOT fail the enclosing command even under
+ # `set -e`, so env.sh's refusal became `eval ""`, the step carried on, and
+ # the first visible symptom was `OPENBLADE_BACKEND: unbound variable` 20
+ # lines later — with the real diagnosis scrolled off as mere stderr.
+ - name: Resolve the rig's device paths
+ id: rig-env
+ run: |
+ set -euo pipefail
+ # env.sh DISCOVERS the device paths (they move between boots) and
+ # identifies the changer by unit serial, never by "first mediumx".
+ if ! block=$(scripts/mhvtl/env.sh); then
+ echo "::error::scripts/mhvtl/env.sh could not resolve the rig; the hardware suite cannot run. The diagnosis is in this step's stderr above."
+ exit 1
+ fi
+ printf '%s\n' "$block"
+ # Only the `export K=V` lines — env.sh also emits comments, which
+ # $GITHUB_ENV would reject.
+ printf '%s\n' "$block" \
+ | sed -n 's/^export \([A-Z_][A-Z0-9_]*\)=\(.*\)$/\1=\2/p' >> "$GITHUB_ENV"
+
- name: Run the hardware suite against the rig
timeout-minutes: 20
env:
@@ -255,9 +315,13 @@ jobs:
OPENBLADE_FAULT_TESTS: ${{ inputs.fault_tests == true && 'enabled' || '' }}
run: |
set -euxo pipefail
- # env.sh DISCOVERS the device paths (they move between boots) and
- # identifies the changer by unit serial, never by "first mediumx".
- eval "$(scripts/mhvtl/env.sh)"
+ # Belt and braces: the previous step is what guarantees these exist,
+ # and if it somehow did not, say which one is missing.
+ : "${OPENBLADE_BACKEND:?not set — the rig-env step did not run}"
+ : "${OPENBLADE_REAL_HARDWARE_ENABLED:?not set — the rig-env step did not run}"
+ : "${OPENBLADE_CHANGER_DEVICE:?not set — the rig-env step did not run}"
+ : "${OPENBLADE_DRIVE_DEVICES:?not set — the rig-env step did not run}"
+ : "${OPENBLADE_SCRATCH_BARCODES:?not set — the rig-env step did not run}"
env | grep '^OPENBLADE_' | sort
if [ "${{ steps.ltfs.outputs.available }}" = "true" ]; then
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..da266b7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,92 @@
+name: Release
+
+# Tag-triggered release: builds the controller image, pushes it to GHCR, and
+# cuts a GitHub Release from the tag. Runs on `v*` tag pushes only -- it never
+# runs on a branch push, so it cannot race the version-bump automation in
+# other workflows.
+#
+# NOTE: this workflow has never been exercised by an actual tag push (no `v*`
+# tag exists on this repo yet). The first real run is what proves the GHCR
+# login/push and release-notes generation actually work end to end; until
+# then, treat it as reviewed-but-unverified. See docs/wiki/guides/releasing.md
+# for what to check if that first run fails, and keep changes to this file
+# small and easy to bisect for exactly that reason.
+
+on:
+ push:
+ tags:
+ - "v*"
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+# Workflow-wide default is read-only, matching every other workflow in this
+# repo (ci.yml, operability.yml, emulator-change-gates.yml, ...). The one job
+# that needs to push images/create a release grants exactly those extra
+# scopes itself, rather than widening this default.
+permissions:
+ contents: read
+
+jobs:
+ release:
+ name: build-and-release
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # to create the GitHub Release from the tag
+ packages: write # to push images to ghcr.io
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Derive version from the tag
+ id: version
+ run: |
+ tag="${GITHUB_REF#refs/tags/}"
+ version="${tag#v}"
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
+
+ - name: Cross-check tag against pyproject [project] version
+ run: |
+ pyproject_version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
+ if [ "$pyproject_version" != "${{ steps.version.outputs.version }}" ]; then
+ echo "::error::tag v${{ steps.version.outputs.version }} does not match pyproject version ${pyproject_version} -- bump pyproject.toml and re-tag"
+ exit 1
+ fi
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Controller image: the root Dockerfile (FastAPI/Typer app). This is the
+ # only image this workflow builds -- see the Dockerfile.web note below.
+ - name: Build and push controller image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile
+ push: true
+ tags: |
+ ghcr.io/amantux/openblade:${{ steps.version.outputs.version }}
+ ghcr.io/amantux/openblade:latest
+
+ # `Dockerfile.web` is not tracked on master as of this workflow's
+ # authoring (only `frontend/Dockerfile` exists, consumed by
+ # docker-compose's `web` service, not published standalone to GHCR).
+ # If/when a top-level `Dockerfile.web` is added for a standalone web
+ # image, add a matching build-push step here tagged
+ # `ghcr.io/amantux/openblade-web:{version,latest}` -- do not invent one
+ # now.
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.version.outputs.tag }}
+ name: ${{ steps.version.outputs.tag }}
+ generate_release_notes: true
diff --git a/Dockerfile.web b/Dockerfile.web
new file mode 100644
index 0000000..eb80963
--- /dev/null
+++ b/Dockerfile.web
@@ -0,0 +1,34 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY pyproject.toml README.md ./
+COPY openblade/ ./openblade/
+
+RUN pip install --no-cache-dir -e . \
+ && pip install --no-cache-dir gunicorn gevent packaging
+
+# packaging is explicit on purpose: gunicorn.workers.ggevent imports it, but
+# neither gunicorn nor gevent declares it, so `-k gevent` died at container start
+# with ModuleNotFoundError while `docker compose build` stayed green. Verified by
+# running the image, which is the only way this class of bug shows up.
+
+# Non-root, mirroring Dockerfile. The Flask UI holds no state, so it needs no
+# /data volume and therefore no chown entrypoint -- a plain USER switch suffices.
+RUN groupadd -r openblade && useradd -r -g openblade -d /app openblade
+
+# production, NOT development. This image is the deployable artifact, and
+# OPENBLADE_ENV gates two hardening checks in openblade.web_flask.app: _secret_key()
+# refuses to start without FLASK_SECRET_KEY, and SESSION_COOKIE_SECURE defaults on.
+# Baking "development" here silently disabled both for anyone deploying the image
+# as-is. docker-compose.yml overrides it back to development for the local dev
+# stack, where a per-boot random key and plain-HTTP cookies are what you want.
+ENV OPENBLADE_ENV=production
+ENV OPENBLADE_WEB_BACKEND_URL=http://api:8000
+ENV PYTHONUNBUFFERED=1
+
+USER openblade
+
+EXPOSE 8080
+
+CMD ["gunicorn", "-k", "gevent", "-w", "1", "-b", "0.0.0.0:8080", "openblade.web_flask.app:app"]
diff --git a/README.md b/README.md
index 1f09304..5755345 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ OpenBlade CI/CD is intentionally split by layer so each change runs only relevan
| --- | --- | --- |
| API + backend domain | `CI`: `backend-lint`, `backend-typecheck`, `backend-tests`, `api-aml-integration` | `openblade/**/*.py`, AML integration tests, backend config |
| Simulator/emulator parity | `CI`: `i3-smoke`; `i3-emulator-compliance`; `emulator-change-gates` | simulator, AML routes, emulator contract/tools, i3 tests, compose/runtime wiring |
-| Frontend/UI | `CI`: `frontend-build-test` | `frontend/**` |
+| Frontend/UI | `CI`: `frontend-build-test` (React SPA), `web-flask-smoke` (Flask NAS UI) | `frontend/**`; `openblade/web_flask/**`, `Dockerfile.web`, `docker-compose.yml` |
| CI/CD policy layer | `CI`: `cicd-workflow-validate` | `.github/workflows/**` |
This keeps checks up to date and targeted while preserving full coverage on workflow dispatch and on emulator-specific workflows.
@@ -39,6 +39,8 @@ openblade inventory
uvicorn openblade.api.main:app --reload
# Flask-style WSGI deployment option (same API behavior):
gunicorn openblade.api.wsgi:application
+# Flask NAS operator UI (a separate app that talks to the API over HTTP):
+FLASK_SECRET_KEY=dev-only gunicorn -k gevent -w 1 -b 0.0.0.0:5175 openblade.web_flask.app:app
```
## Multi-Library Setup
@@ -60,6 +62,41 @@ gunicorn openblade.api.wsgi:application
- Override UI proxy targets with `EMULATOR_UI_TARGET_LIBRARY{1,2,3}_URL` in the standalone env file
- Override OpenBlade controller routing with `OPENBLADE_EMULATOR_URLS` when targeting the standalone emulator endpoints
+## Flask NAS operator UI
+`openblade/web_flask/` is a NAS-first operator console. It is a **separate WSGI
+app**, not a second implementation: it owns no domain logic and reaches the
+FastAPI control plane over HTTP through `web_flask.client.BackendClient`. It sits
+alongside the React SPA in `frontend/` rather than replacing it — both surfaces
+are named in `CLAUDE.md`, and each has its own CI job.
+
+Run it with `docker compose --profile web-flask up` (http://localhost:5175); it is
+profile-gated so the default stack is unchanged.
+
+Surfaces, each backed by existing API endpoints:
+- **Storage → Write Path** — replication/sharding/cache-drive/ingest policy
+ (`/nas/policies`, `/nas/cache-drives`, `/nas/source-stream`), and folder-to-pool
+ share mappings with per-folder access modes (`/nas/shares`)
+- **Storage → Catalog** — dataset table with per-dataset checksum verification
+ (`/nas/datasets`, `/nas/datasets/{dataset_id}/verify`)
+- **Storage → Archive / Restore** — archive and restore submission (`/archive/`,
+ `/restore/`)
+- **Devices** — register/probe libraries and drive inventory, mount/unmount, move,
+ import/export, and magazine eject/insert against `/api/libraries` and `/aml/*`
+- **System** — AML users and role assignments (`/aml/users`)
+
+### Configuration
+| Variable | Purpose |
+| --- | --- |
+| `FLASK_SECRET_KEY` | Session signing key. **Required** when `OPENBLADE_ENV=production` (the `Dockerfile.web` default) — the app refuses to start without it rather than minting a throwaway key that would invalidate every session on restart. |
+| `OPENBLADE_WEB_BACKEND_URL` | Base URL of the FastAPI control plane. |
+| `OPENBLADE_API_TOKEN` | Must match the backend's value. The backend gates its native surface (`/api`, `/jobs`, `/nas`, `/catalog`, `/status`, `/system`, `/volume-groups`, `/archive`, `/restore`) on this bearer, while `/aml/*` uses the per-user session this UI obtains at login. Omit it only where the backend also leaves native auth off. |
+| `OPENBLADE_SERVICE_TOKEN` | Must match the backend's value; `/aml/mount` and `/aml/unmount` require it. |
+| `OPENBLADE_WEB_SECURE_COOKIES` | Forces `Secure` session cookies; defaults on under `OPENBLADE_ENV=production`. |
+| `OPENBLADE_WEB_ALLOW_UNSAFE_DEVICE_TARGETS` | Development escape hatch: permits loopback, link-local (including cloud metadata) and multicast device connection URLs. By default these are refused based on the host's **resolved addresses**, so respellings like `127.1` or `2130706433` are refused too. RFC1918 LAN addresses are always allowed — that is where real libraries live. Leave unset in production. |
+| `OPENBLADE_WEB_ALLOWED_PATH_ROOTS` | Comma-separated absolute roots that operator-supplied archive/restore/cache paths must sit under. Defaults to `/openblade,/var/lib/openblade,/pools,/data,/share,/shares,/datasets,/mnt,/srv`. This is a confinement boundary, not just a traversal check: the backend's `/archive` and `/restore` routes have no per-user authorization, so without it a logged-in operator could archive `/etc` or `/proc/self/environ` to tape. |
+| `OPENBLADE_CONNECT_HOST` | Pins the hostname printed in the copy-paste `net use` / `mount -t cifs` / `sftp` hints. Otherwise the (character-validated) request `Host` header is used — pin this wherever that header is not trustworthy. |
+| `OPENBLADE_WEB_TIMEOUT_SECONDS` | Backend HTTP timeout, default 8. |
+
## Safety defaults
- Mock backend is the default
- Real hardware requires `OPENBLADE_BACKEND=real` and `OPENBLADE_REAL_HARDWARE_ENABLED=true`
diff --git a/docker-compose.yml b/docker-compose.yml
index 00bc9eb..7e97d47 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,6 +7,12 @@ services:
OPENBLADE_BACKEND: mock
OPENBLADE_DB_URL: sqlite:////data/openblade.db
OPENBLADE_EMULATOR_URLS: ${OPENBLADE_EMULATOR_URLS:-http://host.docker.internal:8010,http://host.docker.internal:8011,http://host.docker.internal:8012}
+ # Declared so `api` and `web-flask` cannot drift apart on either credential.
+ # Empty is the documented "unset" for both (api_auth: empty => native auth
+ # disabled; service_auth: empty => dev fallback token), so the default stack
+ # behaves exactly as before. Export either var and BOTH services pick it up.
+ OPENBLADE_API_TOKEN: ${OPENBLADE_API_TOKEN:-}
+ OPENBLADE_SERVICE_TOKEN: ${OPENBLADE_SERVICE_TOKEN:-}
volumes:
- openblade-data:/data
ports:
@@ -40,5 +46,66 @@ services:
api:
condition: service_healthy
+ # Nightly catalog backup sidecar (off by default -- uncomment to enable).
+ # Mirrors `openblade catalog backup --dest /backups --keep 14` on a loop;
+ # gzip snapshots land on the `openblade-backups` volume, pruned to the 14
+ # most recent. See docs/wiki/guides/the-catalog.md#openblade-catalog-backup--restore-backup.
+ # backup:
+ # build:
+ # context: .
+ # dockerfile: Dockerfile
+ # environment:
+ # OPENBLADE_BACKEND: mock
+ # OPENBLADE_DB_URL: sqlite:////data/openblade.db
+ # volumes:
+ # - openblade-data:/data
+ # - openblade-backups:/backups
+ # entrypoint: ["/bin/sh", "-c"]
+ # command:
+ # - |
+ # while true; do
+ # openblade catalog backup --dest /backups --keep 14
+ # sleep 86400
+ # done
+ # depends_on:
+ # api:
+ # condition: service_healthy
+
+ # The Flask NAS operator UI (openblade/web_flask). ADDITIVE, not a replacement
+ # for `web` above: CLAUDE.md describes "React frontend + Flask NAS UI" as two
+ # surfaces, and frontend-build-test still gates the React one. Off by default
+ # via a profile, following `emulator`: `docker compose --profile web-flask up`
+ # runs it, and the default stack is byte-for-byte unchanged. Host port 5175
+ # deliberately: 5173 is the React service and 5174 is the standalone emulator UI
+ # (deploy/emulator/docker-compose.standalone.yml), so both would clash under
+ # `make fleet-up`.
+ web-flask:
+ build:
+ context: .
+ dockerfile: Dockerfile.web
+ profiles: ["web-flask"]
+ environment:
+ # Relaxes Dockerfile.web's production default: the local stack is plain
+ # HTTP, so a per-boot random SECRET_KEY and non-Secure cookies are correct
+ # here. A real deployment keeps production and supplies FLASK_SECRET_KEY.
+ OPENBLADE_ENV: development
+ OPENBLADE_WEB_BACKEND_URL: http://api:8000
+ # Both halves of the backend's auth model; both MUST match `api`.
+ # OPENBLADE_API_TOKEN is the native-surface bearer (empty => api has native
+ # auth off, so an empty bearer here is equally correct).
+ OPENBLADE_API_TOKEN: ${OPENBLADE_API_TOKEN:-}
+ # The controller header, required by /aml/mount and /aml/unmount, which this
+ # UI calls. Unlike the bearer it has no "off" state: when the var is unset
+ # api falls back to service_auth._DEFAULT_SERVICE_TOKEN, so the UI has to
+ # send that exact value or mount/unmount 403s in the dev stack. Naming the
+ # literal here keeps the two in step; exporting the var overrides both.
+ OPENBLADE_SERVICE_TOKEN: ${OPENBLADE_SERVICE_TOKEN:-openblade-controller-dev-token-do-not-expose}
+ ports:
+ - "5175:8080"
+ depends_on:
+ api:
+ condition: service_healthy
+
volumes:
openblade-data:
+ # openblade-backups:
diff --git a/docs/decisions/2026-09-11-parked-flask-nas-ci-readme.md b/docs/decisions/2026-09-11-parked-flask-nas-ci-readme.md
index 0db7393..77cfae8 100644
--- a/docs/decisions/2026-09-11-parked-flask-nas-ci-readme.md
+++ b/docs/decisions/2026-09-11-parked-flask-nas-ci-readme.md
@@ -1,7 +1,13 @@
# Parked: CI + README tail of the Flask NAS UI and iBlade parity-coverage work
**Date:** 2026-09-11
-**Status:** parked in `git stash` (not lost, not landed)
+**Status:** ⚠️ SUPERSEDED 2026-09-26 — the parked state has been resolved. See
+[`2026-09-26-flask-nas-disposition.md`](2026-09-26-flask-nas-disposition.md) for
+the final per-component disposition and the stash cleanup commands. The "How to
+recover" section below is **obsolete**: the Flask UI, `Dockerfile.web` and the
+`web-flask-smoke` job were landed (with corrections), and the parity generator
+plus its artifacts turned out to have already landed on master in `ce89749`.
+Read the rest of this file as the historical record of *why* it was parked.
**Origin:** abandoned session of 2026-08-22, checkpoint `caca9241` (see `.claude/RESUME.md`)
## Why this note exists
diff --git a/docs/decisions/2026-09-26-flask-nas-disposition.md b/docs/decisions/2026-09-26-flask-nas-disposition.md
new file mode 100644
index 0000000..28fd043
--- /dev/null
+++ b/docs/decisions/2026-09-26-flask-nas-disposition.md
@@ -0,0 +1,328 @@
+# Disposition: the parked Flask NAS UI and iBlade parity-coverage work
+
+**Date:** 2026-09-26
+**Status:** decided — the parked state is ENDED
+**Supersedes:** [`2026-09-11-parked-flask-nas-ci-readme.md`](2026-09-11-parked-flask-nas-ci-readme.md)
+**Branch:** `feat/flask-nas-disposition`
+
+The 2026-09-11 note parked four files in `git stash` plus a set of untracked
+prerequisites, because they "could be neither safely landed nor honestly
+discarded". This note closes that out. Each component was evaluated against
+current `master` and decided on its own.
+
+## Summary
+
+| Component | Disposition | Why |
+| --- | --- | --- |
+| `openblade/web_flask/` (+ `tests/unit/test_web_flask_app.py`) | **LANDED** | All 46 endpoints it consumes now exist on master; it needed no functional port |
+| `Dockerfile.web` | **LANDED, corrected** | Shipped a dead gevent worker, a root process, and dev-mode hardening |
+| `docker-compose.yml` `web` rewire | **RETIRED**, replaced | The parked version retired the React SPA as a side effect |
+| `ci.yml` `web-flask-smoke` job | **LANDED, filter corrected** | Its blocker (untracked inputs) is gone; the filter was over-broad |
+| `tools/emulator_spec/generate_iblade_parity_coverage.py` | **RETIRED — already on master** | Landed independently in `ce89749`; master's copy is the formatted one |
+| `emulator_contract/openblade_iblade_*parity*.{json,md}` | **RETIRED — already on master** | Byte-identical to the tracked files |
+| `emulator-change-gates.yml` / `i3-emulator-compliance.yml` parity steps | **RETIRED (deferred)** | Out of scope here; see "Deferred" below |
+| README parity/NAS hunks | **PARTIALLY LANDED** | Two hunks were wrong and were dropped |
+
+## What changed since the work was parked
+
+The parked note's central blocker was that the Flask UI called NAS/AML endpoints
+that existed only in the abandoned session's dirty tree. **That is no longer
+true.** Extracting every path from `BackendClient` and diffing it against
+`app.openapi()` (862 paths on master) gives **46 present, 0 missing**, with
+matching methods. The backend surface landed independently in the intervening
+commits, chiefly `bd4f3b1`.
+
+Consequently the package needed no functional port at all:
+
+- `pytest tests/unit/test_web_flask_app.py` — **31/31 passed unchanged**
+- `mypy openblade/web_flask` (strict) — clean, 4 files
+- `ruff check` — clean
+
+The only mechanical work was `ruff format` drift, because the repo's formatter
+baseline moved after the work was parked (`2026-09-11-pin-ruff-toolchain.md`).
+The parity generator had drifted the *same* way, which is the tell that master's
+copy is the descendant of the parked one and not a divergent reimplementation.
+
+## Per-component rationale
+
+### LANDED: `openblade/web_flask/`
+
+Kept because it is not a duplicate of the React SPA. It is a separate WSGI app
+that owns **no** domain logic and reaches the FastAPI control plane over HTTP
+through `web_flask.client.BackendClient`, which is exactly the surface
+relationship `CLAUDE.md` describes with "React frontend + Flask NAS UI". Its
+content is NAS-appliance-shaped (write-path policy, cache-drive staging,
+source-stream profiles, folder-to-pool share mappings, dataset checksum
+verification) rather than a second rendering of the SPA's pages.
+
+Two real auth defects were found and fixed, both by probing the running backend
+rather than by reading it:
+
+1. **The native surface was entirely broken under native auth.**
+ `openblade/api/api_auth.py` gates the OpenBlade-native surface on the static
+ `OPENBLADE_API_TOKEN` bearer, while `/aml/*` authenticates per-user through
+ `routes_aml_auth.require_auth`. The client sent its AML *session* token as the
+ only bearer, so with `OPENBLADE_API_TOKEN` set, **all 21 native endpoints it
+ uses returned 401** — the whole Storage/Jobs/Reports/System surface. Verified
+ against a live uvicorn instance: `/jobs/`, `/nas/policies`, `/api/libraries`
+ and `/volume-groups/` all 401, while `/aml/*` worked.
+
+ Fixed in one place. `require_auth` checks the `sessionID` cookie *before* the
+ bearer, so the session token now travels as that cookie and `Authorization`
+ is free for the native token. With no native token configured it falls back to
+ the previous behaviour, so nothing changes for deployments that leave native
+ auth off.
+
+2. **The login lockout was bypassable.** `_client_ip()` honoured the
+ caller-controlled `X-Forwarded-For`, so rotating the header minted a fresh
+ rate-limit bucket per attempt and defeated `_is_login_blocked` entirely. Now
+ peer-address only, matching the rule already documented on
+ `openblade.api.routes_assist.client_key`.
+
+Both fixes carry **mutation-checked** regression tests: restoring either
+vulnerable line makes the new test fail (verified by doing it). Note that the
+pre-existing `test_login_rate_limit_blocks_after_repeated_failures` passed
+against the vulnerable code — it was vacuous for the forged-header attack, which
+is precisely the failure mode the mutation rule exists to catch.
+
+`flask` moved into the runtime dependencies: `Dockerfile.web` serves this under
+gunicorn, so `pip install -e .` has to be enough to import it.
+
+### LANDED, corrected: `Dockerfile.web`
+
+Three defects, all found by *running* the built image. The parked CI job's only
+image check was `docker compose build`, which passes on all three:
+
+- **`-k gevent` was dead.** `gunicorn.workers.ggevent` imports `packaging`, which
+ neither gunicorn nor gevent declares. The container died at start with
+ `ModuleNotFoundError: No module named 'packaging'`.
+- **`ENV OPENBLADE_ENV=development` was baked into the deployable artifact**,
+ silently disabling both hardening checks the code offers: `_secret_key()`'s
+ refusal to start without `FLASK_SECRET_KEY`, and `SESSION_COOKIE_SECURE`. Now
+ `production`, with compose overriding it back for the local plain-HTTP stack.
+- **gunicorn ran as root**, against the repo's own convention in `Dockerfile`.
+
+Verified on the rebuilt image: it exits with "FLASK_SECRET_KEY is required in
+production" when the key is absent; with the key it serves `/login` 200 with
+`X-Frame-Options: DENY`, and every process reports `uid=999`.
+
+### RETIRED and replaced: the `docker-compose.yml` `web` rewire
+
+The parked hunk pointed the existing `web` service at `Dockerfile.web`, i.e. it
+**replaced the React SPA with the Flask UI**. That is a bigger decision than
+"land the parked tail", it contradicts `CLAUDE.md`'s two-surface description, and
+it would have left `frontend-build-test` gating a service nothing deployed. The
+parked note had already flagged the matching README deletion as suspect.
+
+Replaced with an **additive** `web-flask` service behind a compose profile,
+following the `emulator` precedent in the same file, so `docker compose up` is
+byte-for-byte unchanged. Host port **5175**: 5173 is the React service and 5174
+is the standalone emulator UI, so the parked choice of 5173 *and* an obvious
+alternative of 5174 would both have clashed under `make fleet-up`.
+
+Both services now take `OPENBLADE_API_TOKEN` and `OPENBLADE_SERVICE_TOKEN` from
+one variable each so they cannot drift apart on either credential. Empty is the
+documented "unset" for both, so default behaviour is unchanged. The service
+token's dev default must be repeated as a literal in compose (it cannot import
+Python) because `/aml/mount` and `/aml/unmount` require it — a test asserts the
+two copies agree.
+
+### LANDED, filter corrected: `web-flask-smoke`
+
+The parked filter included `pyproject.toml`, so it fired on every dependency
+bump. The `backend` filter already carries that file, so a pyproject change still
+gets ruff + mypy + the whole `tests/unit` run (which imports this package);
+adding it here only tacked a docker build onto Dependabot traffic.
+`docker-compose.yml` is kept, because it defines the service the job builds.
+
+Given the existing layering, the job's unique value is narrow but real:
+`backend-lint` already runs ruff tree-wide and `backend-tests` already runs
+`tests/unit`, but `mypy openblade` in `backend-typecheck` must stay
+`continue-on-error` because of ~300 pre-existing errors in the god-file modules.
+`openblade/web_flask` is strict-clean today, so this job type-checks it
+**blocking** and keeps it out of that backlog. It is also the only thing in CI
+that builds `Dockerfile.web`. Registered in `ci-gate`'s `needs` *and* its `check`
+list — without the latter a failure would not have blocked anything.
+
+### RETIRED (zero loss): the parity generator and artifacts
+
+**These already landed on master** in `ce89749` ("Add iBlade Rev A parity
+tracker"). The untracked copies are redundant:
+
+- `openblade_iblade_rev_a_parity.json`, `openblade_iblade_parity_coverage.json`
+ and `.md` are **byte-identical** to the tracked files.
+- `generate_iblade_parity_coverage.py` differs only in line wrapping; master has
+ the `ruff format`ed version, i.e. master's copy is strictly the better one.
+
+Confirmed live: `python3 tools/emulator_spec/generate_iblade_parity_coverage.py
+--allow-partial` exits 0 (`implemented=12, partial=4, missing=1`) and regenerates
+the tracked artifacts with **no** diff. The parked note's recorded
+`FileNotFoundError` is therefore stale, and the T3 "commits new
+`emulator_contract/**` artifacts" concern no longer applies — that already
+happened through the normal path.
+
+### Deferred, not landed: the parity steps in the two emulator workflows
+
+The generator step and artifact uploads for `emulator-change-gates.yml` and
+`i3-emulator-compliance.yml` are **not** present on master — only the generator
+itself landed. They are deliberately out of scope for this change, which was
+scoped to touch `.github/workflows/` only for `web-flask-smoke`.
+
+Their original blocker is gone (the generator runs green and its inputs are
+tracked), so this is now an ordinary small change rather than a parked one: add
+`generate_iblade_parity_coverage.py --allow-partial` plus the two artifact paths
+to those workflows. Worth doing; it just is not this change. **Nothing needs to
+be recovered from the stash to do it** — the hunks are reproduced in the parked
+note's own diff table and are four lines of YAML.
+
+### Partially landed: README
+
+Taken: the `web-flask-smoke` row in the layered-CI table, and a Flask NAS UI
+section — rewritten rather than copied, with a configuration table that documents
+the `OPENBLADE_API_TOKEN` lockstep requirement the parked version did not know
+about. Every endpoint and Flask route named in it was verified to exist.
+
+**Not taken**, both deliberately:
+
+- The deletion of the `cd frontend && npm install && npm run test && npm run
+ build` quickstart line. The React frontend is not being retired and CI still
+ gates it. (The parked note flagged this itself.)
+- The reword of goal 4 from "fleet and library workflows without out-of-scope
+ feature leakage" to "NAS-first operations with device-scoped deep controls".
+ Landing a UI is no reason to restate the project's goals, and the reword drops
+ an explicit scope-discipline clause.
+
+## Adversarial review round (T3)
+
+The reviewer returned **BLOCK** with four MUST-FIX items, two carrying working
+repros against live sockets. All are fixed; the suite went 37 → 73 tests.
+
+| # | Finding | Fix |
+| --- | --- | --- |
+| 1 | **SSRF.** The device-URL deny-list compared host *spellings*, so `127.1`, `2130706433` and `localhost.` all reached a loopback listener (proven), and any attacker-controlled DNS name could resolve into private space. Gave a logged-in user an internal port scanner and a cloud-metadata reach test. | Resolve the host and judge every returned address |
+| 2 | **Redirect bypass + credential replay.** The probe followed redirects, so a host answering `302 Location: http://127.0.0.1/…` turned a blocked target into a reachable one; httpx replays the body on 307/308, so the submitted device password could be harvested. | `follow_redirects=False`; 3xx no longer counts as reachable |
+| 3 | **No path confinement.** The guard blocked `..` but not *which* absolute roots were reachable, and the values become real filesystem sinks in a backend whose `/archive` and `/restore` have no per-user authz — so `/proc/self/environ`, which holds `OPENBLADE_API_TOKEN`, was archivable to tape. | `_is_allowed_storage_root` on the four genuine filesystem paths |
+| 4 | **Confused deputy.** Because the client presents the instance-wide `OPENBLADE_API_TOKEN` as the native bearer, and the native config surface has no `require_auth`, **any** session could rewrite storage policy regardless of AML role. Fix (2) in the landing commit turned "broken for everyone" into "authorized for everyone", so this was a widening introduced by that change. | Role gate on the six config-write routes, resolved from `/aml/users/me`, failing closed on an unknown role |
+
+Ten SHOULD-FIX/NIT items were also closed: dot-only identifiers (`_POOL_ID_ALLOWED`
+permitted `.`, so `..` matched and collapsed a URL segment), unvalidated
+`volume_group`, non-JSON upstream bodies echoed into the browser, 3xx crashing
+`.json()`, an unbounded pre-auth login bucket plus a missing IP-only counter, no
+logout revocation, backslash accepted in redirect targets, the reflected `Host` in
+copy-paste mount commands, and bare `int()` on backend values.
+
+**One reviewer recommendation was deliberately not taken.** It proposed adding
+`is_private` to the blocked-address set. That would block `10.x`, `172.16.x` and
+`192.168.x` — which is exactly where a real Scalar i3 lives — and would refuse
+every legitimate device. The check blocks loopback, link-local (which covers
+169.254.169.254), unspecified, multicast and reserved, and allows RFC1918. This
+matches the original intent of `_DANGEROUS_DEVICE_HOSTS`, which never listed
+private ranges; the defect was that it compared spellings, not that its *scope* was
+wrong.
+
+**Areas the reviewer attacked and found clean:** session/CSRF/privilege plumbing
+(independently re-verified here — all 36 non-exempt endpoints 302 to login when
+anonymous, and all 24 mutating routes return 400 without a CSRF token, with no GET
+mutating state); template/XSS (no `|safe`, `Markup`, `render_template_string`,
+inline `
+
+
+
+
+
+