Skip to content

Harden the broker API and add server-side pagination - #25

Merged
Paul Lizer (paullizer) merged 1 commit into
mainfrom
paullizer-api-server-side-hardening
Aug 20, 2026
Merged

Harden the broker API and add server-side pagination#25
Paul Lizer (paullizer) merged 1 commit into
mainfrom
paullizer-api-server-side-hardening

Conversation

@paullizer

Copy link
Copy Markdown
Collaborator

Summary

Server-side counterpart to the portal modernization (#21). Reviewing api/app.py against the stored procedures turned up a bug that has never worked in production, several systemic reliability and disclosure problems, and two places where the API forced the new UI into the wrong shape.

No changes to the AVD host, Linux host agents, or the scheduled task — backward compatibility for those was the hard constraint throughout.

Bugs fixed

Bug Impact
"No Limit" has never worked. The portal sends limit as the string "null"; the procedures declare @Limit INT, so SQL Server failed the conversion All three history endpoints returned 500 whenever the box was ticked
Connections leaked on every exception path — 27 get_db_connection() calls, only 7 finally: blocks Pool exhaustion over time
18 handlers returned raw str(e) Driver errors, server names and schema detail disclosed to callers
14 print() calls instead of logger, including inside get_db_connection() and Key Vault retrieval Application Insights is wired to the linuxbroker.api logger, so database and secret failures never reached telemetry
/api/scaling/rules returned 404 when empty The portal flashed an error instead of rendering its empty state
/api/scaling/log and /api/scaling/rules/history returned a dict when empty, a list otherwise Callers needed an isinstance workaround
TriggerScalingLogic never committed. pymssql does not autocommit Power-state updates and the activity-log insert were rolled back while the Azure power operations still went ahead — Azure and the broker drifting apart, and a permanently empty scaling activity log
is_member_of_group_cached was defined but never calledtoken_required used the uncached path Every authenticated request from the AVD and Linux hosts hit Microsoft Graph
Scaling procedures relied on implicit MM/DD/YYYY conversion, which depends on session DATEFORMAT Locale-fragile; GetVmHistory already did it correctly

Added

  • GET /api/vms/summary — the dashboard no longer fetches every VM row to compute eight counters. Ready uses the same condition the checkout path uses to select a host.
  • Opt-in page/per_page on the three history endpoints, backed by new paged procedures returning TotalCount via COUNT(*) OVER (). With neither parameter the response stays a bare arraytask/function_app.py iterates these as plain lists, so that default is load-bearing.
  • api/tests/ — 44 tests with pymssql and the Azure SDKs mocked, so they run with no database and no network. Plus CI wiring.
  • api/README.md — endpoint surface, auth model, consumer map, error envelope, pagination contract.

Front end

The dashboard uses the summary endpoint and the history pages use server-side pagination, so whole result sets are no longer cached in the Flask session — that was the deliberate deferral from #21, and it grew without bound while letting two browser tabs clobber each other. Both paths have fallbacks for an API deployed behind the portal.

Three bugs found by review of this change itself

Worth calling out, since two were introduced here:

  1. I wired up the group-membership cache and introduced a regression. is_member_of_group returns False on Graph failure as well as on genuine non-membership. Memoizing that meant one throttled Graph call would deny a principal for the full 5-minute window — and for /vms/checkout and /vms/<hostname>/release the group check is the only authorization path, so it would have blocked every checkout and session release. It now raises GroupCheckUnavailable and surfaces as 503.
  2. Paged responses reported total: 0 on an out-of-range page, making "page 40 of 4" indistinguishable from "no matches" and collapsing the pager with no way back. Now re-probes for the count.
  3. The dashboard's rolling-deploy fallback could never fire. An older API doesn't 404 on /api/vms/summary — Werkzeug matches it against /api/vms/<vmid>, which fails converting 'summary' to an int and returns 500. Keying the fallback on 404 guarded a status the old build cannot produce.

Verification

  • 44 API tests + 78 front-end tests, stable across repeated runs.
  • Mutation tested: each fix was reverted in turn and the suite failed every time. One test initially missed its mutation (it patched is_member_of_group rather than exercising it), which is exactly the kind of false confidence mutation testing exists to catch — fixed by adding tests against the real function.
  • Every write path verified to still commit (15, up from 14 — the new one is the TriggerScalingLogic fix).
  • Success shapes and status codes for the scheduled-task, Linux-host and AVD-host endpoints confirmed byte-for-byte unchanged.

Caveat — please read before merging

The stored procedures in sql_queries/034039 were only statically checked. There is no SQL Server in this environment, so they have never been executed. Column names were verified against the CREATE TABLE scripts and the later ALTER scripts, and the syntax was reviewed, but they need a real deployment run before this is trusted in production.

Removed

  • The unused pyodbc dependency.
  • GET /api/vms/available — no callers anywhere in the repo. It is a published endpoint, so worth checking for external automation before deploying.

Not addressed

The 43 Dependabot alerts on the default branch — that deserves its own focused PR.

Server-side counterpart to the portal modernization. Fixes bugs found while
reviewing api/app.py against the stored procedures, and adds the two endpoints
the new UI needed.

Bugs fixed:
- The "No Limit" option never worked. The portal sends limit as the string
  "null"; the procedures declare @limit INT, so SQL Server failed converting
  it and all three history endpoints returned 500. Limits are now coerced,
  and the portal no longer sends the sentinel.
- Database connections leaked on every exception path: 27 get_db_connection()
  calls had only 7 finally blocks. All handlers now use a db_connection()
  context manager that always closes.
- 18 handlers returned the raw str(e) to the caller, exposing driver errors,
  server names and schema detail. Failures now return {"error": ...} and the
  detail goes to logger.exception.
- 14 print() calls became logger calls, including inside get_db_connection()
  and Key Vault retrieval, so database and secret failures actually reach
  Application Insights instead of being swallowed.
- /api/scaling/rules returned 404 when no rules existed, which made the portal
  flash an error rather than render its empty state. /api/scaling/log and
  /api/scaling/rules/history returned a dict when empty and a list otherwise.
  All three now return a JSON array with 200.
- TriggerScalingLogic ran without committing. pymssql does not autocommit, so
  the power-state updates and the activity-log insert were rolled back while
  the Azure power operations still went ahead, leaving Azure and the broker
  out of step and the scaling activity log permanently empty.
- is_member_of_group_cached was defined but never called; token_required used
  the uncached path, so every authenticated request from the AVD and Linux
  hosts hit Microsoft Graph. Now wired up, and a Graph failure raises rather
  than returning False so a throttled call is never cached as a denial.
- The scaling procedures relied on implicit MM/DD/YYYY date conversion, which
  depends on the session DATEFORMAT. They now convert explicitly with style
  101, matching GetVmHistory, via TRY_CONVERT.

Added:
- GET /api/vms/summary, so the dashboard no longer fetches every VM row to
  compute eight counters.
- Opt-in page/per_page pagination on the three history endpoints, backed by
  new paged procedures that return TotalCount via COUNT(*) OVER (). With
  neither parameter the response stays a bare array, because the scheduled
  task and older portal builds consume these as plain lists.
- api/tests/ (44 tests) with pymssql and the Azure SDKs mocked, plus CI wiring.
  Verified by mutation testing: each fix was reverted in turn and the suite
  failed every time.
- api/README.md covering the endpoint surface, auth model, consumer map, error
  envelope and pagination contract.

Front end:
- The dashboard uses the summary endpoint, and the history pages use
  server-side pagination, so whole result sets are no longer cached in the
  Flask session. That caching grew without bound and let two browser tabs
  clobber each other.
- Both have fallbacks for an API deployed behind the portal.

Removed: the unused pyodbc dependency and the /api/vms/available endpoint,
which had no callers in the repo. External callers of that endpoint, if any,
would need checking before deploying.

Not addressed: the 54 Dependabot alerts on the default branch.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit e95bde6 into main Aug 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant