Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
d422e66
chore: untrack committed .pyc bytecode files
Amantux Sep 26, 2026
ea6457e
ci(mhvtl): load st/ch, fix the sg_inq permission trap, fail loudly
Amantux Sep 26, 2026
4256e9f
fix: replace mailslot export --force with typed --confirm-barcode
Amantux Sep 26, 2026
c3a1615
feat(frontend): own the native API bearer token in one module
Amantux Sep 26, 2026
ad4a247
docs(mhvtl): record the GitHub-runner bring-up, with the evidence
Amantux Sep 26, 2026
a1561de
feat(frontend): send the bearer token from every native request path
Amantux Sep 26, 2026
8911c34
feat(frontend): token-entry surface shown only after a real 401
Amantux Sep 26, 2026
e86aad2
docs: describe the web UI half of native API authentication
Amantux Sep 26, 2026
bb53779
ci(mhvtl): count scsi_tape nodes with a glob, not ls|grep
Amantux Sep 26, 2026
2d56ee5
fix: make connect-i3 transport-aware for the webservices robotics bac…
Amantux Sep 26, 2026
ba83e82
docs(mhvtl): replace the cost estimate with measured step timings
Amantux Sep 26, 2026
a0d79f9
feat(web_flask): land the Flask NAS operator UI
Amantux Sep 26, 2026
e8cdbad
build(web_flask): package the Flask NAS UI as an additive, hardened s…
Amantux Sep 26, 2026
9e70476
ci: add web-flask-smoke, and document the Flask NAS UI
Amantux Sep 26, 2026
be7f715
docs: record the final disposition of the parked Flask NAS / parity work
Amantux Sep 26, 2026
c4caeaf
refactor: narrow six blind except clauses to their real failure modes
Amantux Sep 26, 2026
d0ed7d2
fix: stop raw exception text reaching unauthenticated NAS and proxy e…
Amantux Sep 26, 2026
51f75c6
fix: log the swallowed cause in seven silent exception handlers
Amantux Sep 26, 2026
2933bf4
docs: record why 22 blind handlers stay broad (# noqa: BLE001 with re…
Amantux Sep 26, 2026
cd53169
chore: select BLE in ruff, retire the five inert S noqa markers
Amantux Sep 26, 2026
08d2720
types: type the PathMapping model, seeder specs, and coordinate parsing
Amantux Sep 26, 2026
ddc4665
types: annotate bare generics, untyped defs, and FastAPI dependency p…
Amantux Sep 26, 2026
dc934ba
types: narrow the backend protocols, mount handles and catalog-row reads
Amantux Sep 26, 2026
edf3111
chore(web_flask): drop the unreferenced nas_placeholder template
Amantux Sep 26, 2026
8e1b4c1
types: clear the remaining route, hardware and NAS-leaf errors
Amantux Sep 26, 2026
23c6777
fix: archive-job completion log crashed whenever INFO was enabled
Amantux Sep 26, 2026
53a35bc
simulator: honour the i3 profile's declared ieSlotCount
Amantux Sep 26, 2026
1e9d5b6
api: expose the mailslot (import/export) flows over HTTP
Amantux Sep 26, 2026
ef121ca
api: add a read-only drive-health (inquiry + TapeAlert) route
Amantux Sep 26, 2026
2d468f7
api: add POST /restore/tree (spanning tree restore, with dry run)
Amantux Sep 26, 2026
9b7d338
docs: regenerate the wiki API reference for the six new native operat…
Amantux Sep 26, 2026
bf1a03c
frontend: Mailslot page — I/E listing, import, and the guarded export
Amantux Sep 26, 2026
29eecdb
frontend: Drive Health page — inquiry plus TapeAlert severity badges
Amantux Sep 26, 2026
cc30de7
frontend: Tree Restore page — prefix, destination, dry run, then run
Amantux Sep 26, 2026
811a091
frontend: Assistant page over POST /assist (read-only, no streaming)
Amantux Sep 26, 2026
9c88c50
frontend: route and nav entries for the four new pages
Amantux Sep 26, 2026
437e233
fix: log the ingest per-file swallow BLE001 exempts, restore the hard…
Amantux Sep 26, 2026
f48e679
ci: make backend-typecheck blocking on the zero-error baseline
Amantux Sep 26, 2026
88ddf06
frontend: drop the export assessment when the barcode field changes
Amantux Sep 26, 2026
b22d45f
feat(catalog): add openblade catalog backup / restore-backup CLI
Amantux Sep 26, 2026
76b54fe
feat(release): expose openblade --version and add tagged-release CI
Amantux Sep 26, 2026
b5d5fc3
fix(web_flask): close the findings from the adversarial review round
Amantux Sep 26, 2026
6df3306
docs: record the adversarial review round in the disposition note
Amantux Sep 26, 2026
ef471d6
fix: run the tree restore off the event loop, with its own session
Amantux Sep 26, 2026
fbcaf9e
fix: keep raw sg3_utils output off the drive-health response
Amantux Sep 26, 2026
7b430f1
fix: 404 an export preview for a barcode this library never had
Amantux Sep 26, 2026
f7c1682
fix: force-export can only ever target the cartridge the server refused
Amantux Sep 26, 2026
5280b24
fix: a tree-restore result never outlives the selection it answered for
Amantux Sep 26, 2026
20b6ca1
fix: hand the assistant question back on any failure, not just 503
Amantux Sep 26, 2026
525f02a
Merge branch 'feat/release-and-backup' into integrate/gap-closure
Amantux Sep 26, 2026
964f5fe
Merge branch 'ci/mhvtl-canary-iterate' into integrate/gap-closure
Amantux Sep 26, 2026
1cff60b
Merge feat/flask-nas-disposition (keep both compose additions)
Amantux Sep 26, 2026
d635c85
Merge branch 'feat/frontend-api-auth' into integrate/gap-closure
Amantux Sep 26, 2026
16c703e
Merge branch 'feat/frontend-parity-pages' into integrate/gap-closure
Amantux Sep 26, 2026
efb621b
Merge chore/ble001-policy — reject stash-contaminated workflow/README…
Amantux Sep 26, 2026
4289fe2
Merge chore/mypy-zero (combine BLE narrowings with mypy typings at 7 …
Amantux Sep 26, 2026
7c9a330
fix: anchor the sg_logs reason sanitizer so a nested ')' cannot defea…
Amantux Sep 26, 2026
5da1121
fix: converge export on the typed confirmBarcode contract; curate Tap…
Amantux Sep 26, 2026
e42e48d
Merge parity tip 7c9a330 — source-level curation supersedes the ancho…
Amantux Sep 26, 2026
6c84c7a
fix: tree-restore under the media lock; mailslot known-check covers d…
Amantux Sep 26, 2026
e48c03d
chore: lint sweep on the merged tree (import order, format, one reaso…
Amantux Sep 26, 2026
6a500c3
fix: mailslot known-check reads inventory via InventoryService (SAFET…
Amantux Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -270,6 +311,7 @@ jobs:
- api-aml-integration
- i3-smoke
- frontend-build-test
- web-flask-smoke
- cicd-workflow-validate
- wiki-reference
if: ${{ always() }}
Expand All @@ -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
Expand Down
74 changes: 69 additions & 5 deletions .github/workflows/mhvtl-weekly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
92 changes: 92 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions Dockerfile.web
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading