Skip to content

Phase 1: portal roles, clean hosts before reuse, working scaling - #35

Merged
Paul Lizer (paullizer) merged 7 commits into
mainfrom
paullizer-admin-ui-platform-review
Sep 24, 2026
Merged

Paul Lizer (paullizer) merged 7 commits into
mainfrom
paullizer-admin-ui-platform-review

Conversation

@paullizer

Copy link
Copy Markdown
Collaborator

A review of the admin side found several places where the solution did not do what it promised. It also found that the portal's access model made every signed-in tenant user an administrator:

  • Scaling never started or stopped a VM. TriggerScalingLogic returned PoweredOn/PoweredOff, but the API matched PowerOn/PowerOff, so decisions were only recorded in SQL.
  • Reconnect and cleanup timing was wrong.
    • The released-VM sweep used a fixed 30 minutes instead of the configured grace period.
    • A returned host could go to the next user before the previous user's account was removed, because userdel failures were masked.
    • The host agent killed the desktop as soon as a user disconnected, so "resume within the grace period" only meant getting the same VM and profile back.
  • Anyone who could sign in to the portal had full control. Holding the delegated access_as_user scope was enough to delete VMs or change settings, and the portal's checkout sent the Linux password to the browser.
  • Readiness and throughput problems. Started hosts could stay "unreachable" in the broker for up to an hour. The API ran one sync gunicorn worker and fetched the signing keys and the SSH key from Key Vault on every call.

This is Phase 1: correctness, access control and performance. Phases 2-4 (admin console, OS and desktop support, strategic scale) and a security-hardening backlog are designed in docs/ROADMAP.md.

What changed

Access control

  • Every endpoint enforces the Reader, Operator or FullAccess app role, or its service role. The delegated scope grants nothing on its own.
  • ALLOW_LEGACY_SCOPE_ACCESS (Bicep allowLegacyScopeAccess, default false) bridges an upgrade while roles are assigned.
  • GET /api/me drives a role-aware portal: a No access page, a read-only banner and gated actions. A unit test pins every route to its roles.
  • The portal no longer receives the Linux password or lease from checkout.

Release lifecycle

  • New CleanupPending state. The sweep, a manual return and a failed checkout all claim cleanup, and the VM cannot be checked out until the user is actually gone from the host.
  • Cleanup is retried automatically about every 2 minutes, or on demand with "Retry cleanup".
  • manage-lease.sh keeps the lease while the user is signed in. Otherwise it ends leftover processes such as tmux or nohup jobs.
  • The API only treats userdel exit codes 0, 6 and 12 as success.
  • The sweep threshold is grace + reconcile interval + 60 s, measured from a new ReleasedDate.
  • New opt-in host setting: "Keep sessions alive during the grace period".
    • It is off by default and cannot be combined with the screen lock.
    • It is left out of documents sent to hosts while off, so hosts that have not been migrated keep converging.
  • Checkout and scaling now require an unassigned host to have Username IS NULL. Add VM now sends blank fields as null, and migration 040 repairs existing rows that have blank or leftover usernames.

Scaling

  • TriggerScalingLogic is rewritten:
    • A single active rule (lowest RuleID); creating a second rule returns 409.
    • MinVMs >= 1, including recovery from zero running hosts.
    • Released and cleanup-pending hosts count as in use.
    • Booting hosts count as capacity and are never stopped. Maintenance, assigned and pending hosts are left alone.
    • Runs are serialized with sp_getapplock, and the activity log records the actual counts and the reason for each decision.
  • The API reads power state from Azure before deciding, using instance_view per VM.
  • It then acts by the rule's stop mode: Power off (default) or Deallocate. The portal warns about deallocation's cost, start-latency and capacity risks and asks for confirmation.
  • If Azure refuses an operation, the API restores the recorded state.

Performance and readiness

  • gunicorn gthread workers (API 2x8, portal 2x4).
  • A per-process SQL connection cap (DB_MAX_CONCURRENCY, default 6, answers 503 when saturated). New sqlDatabaseSkuName parameter, default Basic.
  • The signing keys, Graph token and SSH key are cached. Every outbound call has a timeout, and the Graph group check only runs when no role already authorizes the call.
  • Checkout provisions the user in one SSH session (create-user.sh --password-stdin) instead of 6-8. Hosts running the older script fall back to the old sequence.
  • Apply Now and sweep cleanups run in parallel with deadlines.
  • The task function probes every 2 minutes, in parallel, and only writes changes, through the new /network-status endpoint.
  • UIDs come from a SQL sequence instead of MAX(uid)+1.

Rollout

  • Post-provision applies the SQL scripts before building images, and restarts the apps in the order API, task, front end.
  • SQL changes are additive (040-066) and stay compatible with the previous API build.

Found along the way

  • When no host was free, CheckoutVm rolled back pymssql's own transaction. SQL Server raised error 266, so a full pool returned 500 instead of 409. This bug predates this PR and is fixed in 042.

Upgrade notes

  • Scaling will start and stop VMs for the first time. Review the scaling rule first; idle hosts above the minimum will be powered off.
  • Assign portal roles to administrators before upgrading, or deploy once with allowLegacyScopeAccess=true and turn it off afterwards.
  • Run deploy/Migrate-ExistingEnvironment.ps1 so the Linux hosts get the new scripts. Hosts that are not migrated keep their previous behavior.
  • The full procedure is in deploy/DEPLOYMENT.md, under "Upgrading To Role-Based Access And Working Scaling".

Testing

  • Run locally, all passing:
    • API: 155 unit tests, plus 21 integration tests against SQL Server 2022 through the real pymssql driver.
    • SQL contract: 6 tests; every script is applied twice with READ_COMMITTED_SNAPSHOT on.
    • Host scripts: 4 of 4 test files, in an ubuntu:24.04 container.
    • Task function: 8. Portal BFF: 153. Vitest: 110. Typecheck and production build pass.
    • main.json was regenerated with Bicep 0.44.1.
  • New CI jobs: SQL (with a SQL Server service container), host scripts, and the task function.
  • Not yet validated:
    • a real Azure deployment;
    • real Entra tokens carrying the new roles;
    • running the PowerShell deploy scripts;
    • keep-sessions-alive and create-user.sh on a real RHEL 9 host with GNOME and xrdp. docs/ROADMAP.md item 3.4 has a checklist.

Review focus

  • sql_queries/059 (scaling), 043/044/045 (claiming and completing cleanup), and transaction handling under pymssql.
  • api/app.py: token_required and the role mapping against the real callers, cleanup_remote_user, and trigger_scaling_logic.
  • linux_host/create-user.sh and manage-lease.sh, which run as root through sudo.

- Track the release lifecycle on dbo.VirtualMachines. ReleasedDate drives the sweep,
  and CleanupPending holds a returned VM out of the pool until the previous user has
  been removed from the host. The migration also repairs Available and Maintenance
  rows that were left with a username.
- ReturnReleasedVms follows GracePeriodSeconds plus a reconcile buffer instead of a
  fixed 30 minutes, and retries pending cleanups at most every two minutes.
- New procedures back the cleanup, maintenance, and network-status operations.
  UpdateVmAttributes keeps the lifecycle invariants and skips no-op writes.
- Rewrite TriggerScalingLogic. It holds an application lock and applies a single rule
  with a minimum of at least 1, counts released and pending hosts as in use, never
  stops booting or assigned hosts, and returns PowerOn/PowerOff. The old procedure
  returned PoweredOn/PoweredOff, which the API never matched, so no scaling decision
  ever reached Azure.
- Add StopMode to scaling rules, SyncVmPowerStates, sequence-based uid allocation,
  and the PreserveSessionsOnDisconnect host setting.
- CheckoutVm commits instead of rolling back when no host is free. The rollback
  unwound pymssql's own transaction, so SQL Server raised error 266 and the API
  answered 500 instead of 409.
- Add a contract test harness that applies every script twice to SQL Server 2022
  with READ_COMMITTED_SNAPSHOT on.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- create-user.sh --password-stdin creates the account, mounts the home, writes the
  lease, adds tsusers/appusers, and sets the password read from stdin in one SSH
  session. Inputs are validated before any privileged step and every step is error
  checked. The legacy argument form is unchanged, and the previous script rejects
  the new form with its usage text before changing anything, which the API relies on
  to fall back.
- manage-lease.sh keeps the lease of a user who is still signed in and reports
  __LEASE_ACTION=in-use__, so the broker keeps the host CleanupPending and retries.
  It used to delete the lease, which made a retry believe the host was clean. When
  the user is signed out, it ends leftover processes such as tmux or nohup jobs
  before unmounting, because they would keep the account usable and make userdel
  fail.
- apply-host-settings.sh accepts PreserveSessionsOnDisconnect. The setting is off
  unless present, and it is forced off if the screen lock is on.
- release-session.sh (RHEL and Ubuntu): with sessions preserved, a disconnect no
  longer kills Xorg. The agent ends the session at grace expiry and keeps retrying
  until the processes are gone. Session status is aggregated per user, temporary
  files stay in the root-only state directory so renames are atomic, and main only
  runs when the script is executed, so tests can source it.
- Add a bash test suite that runs as root in an ubuntu:24.04 container, with shims
  for mount, loginctl, and the network calls.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…zure

Authorization
- Every endpoint requires Reader, Operator, or FullAccess, or its service role. The
  delegated access_as_user scope that every portal user holds no longer grants access,
  except through the temporary ALLOW_LEGACY_SCOPE_ACCESS upgrade toggle. GET /api/me
  reports the caller's roles and permissions. A test pins every route to its roles.
- The Graph group check only runs when no role already authorizes the call.

Release lifecycle
- The sweep, manual return, and failed-checkout rollback all claim the VM as
  CleanupPending, remove the user from the host, and only then call CompleteVmCleanup.
  A signed-in user, an unreachable host, or a failed userdel keeps the VM pending and
  the sweep retries it. userdel failures used to be masked, which counted a host with
  the previous account still on it as clean.
- New endpoints: /vms/<id>/cleanup (retry now), /maintenance, and /network-status.
- Checkout provisions the user in one SSH call, falling back to the old sequence on
  hosts whose create-user.sh predates it. Procedure error rows no longer reach
  responses.

Scaling
- Read each host's power state from Azure before deciding, execute PowerOn/PowerOff by
  the rule's StopMode (power off or deallocate), restore the recorded state when Azure
  refuses an operation, and accept the legacy PoweredOn/PoweredOff spelling.
- Validate the rule that results from an update, and answer 409 for a second rule.

Throughput
- Run gunicorn with gthread workers, and cap SQL connections per process with a 503
  when saturated.
- Cache the signing keys, Graph token, and SSH key. Every outbound call has a timeout.
- Push host settings and sweep cleanups in parallel with deadlines.

Host settings
- PreserveSessionsOnDisconnect, which cannot be combined with the screen lock, is left
  out of documents sent to hosts while it is off, so hosts that have not been migrated
  keep converging.

Tests
- Add unit tests, and an integration suite that runs the handlers against a real SQL
  Server through pymssql.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- TestVMConnectivity runs every two minutes instead of hourly, so a host that scaling
  starts becomes checkout-ready within minutes. Probes run in parallel and are tunable
  with PROBE_CONCURRENCY, PROBE_TIMEOUT_SECONDS, and PROBE_PORTS.
- Powered-off hosts are marked Unreachable without being probed, and a status is only
  posted when it changes, through the new /network-status endpoint. The task falls
  back to update-attributes on an API that returns 404 for it.
- Every request has a timeout.
- Add pytest coverage with fake Azure Functions and identity modules.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… settings

- The BFF reads the signed-in user's roles from the API's /me at sign-in, and again
  later if that fails, and passes them to the SPA with the session. Users without a
  role see a No access page. Readers get a read-only banner, and a banner warns
  while the legacy scope toggle is on. Actions and routes are shown only to the roles
  the API allows.
- Checkout responses no longer carry the Linux password or lease to the browser.
- Hosts pending cleanup are badged, can be retried, and cannot be released or returned
  again. Unassigned hosts can be put into and out of maintenance. Update attributes is
  an admin-only repair tool with a warning.
- The scaling rule form chooses the stop mode and explains the cost, start-latency,
  and capacity risks of deallocation, with a confirmation step. The rule list marks
  the active rule and warns when legacy extra rules exist.
- Host settings add Keep sessions alive during the grace period, which is mutually
  exclusive with the screen lock, and correct the idle timeout help.
- Apply Now waits up to APPLY_TIMEOUT_SECONDS and reports hosts it did not reach.
- Add VM sends blank optional fields as null. An empty username made the new host
  look assigned.
- gunicorn runs gthread workers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… every layer in CI

- preprovision creates the Reader and Operator app roles. brokerReaderGroupId,
  brokerOperatorGroupId, and brokerAdminGroupId optionally assign them to groups.
- New parameters allowLegacyScopeAccess and sqlDatabaseSkuName are added to the
  generated parameters file and to Bicep, which maps the SKU name to its tier.
  main.json is regenerated.
- Post-provision applies the SQL scripts before building and restarting images, and
  apps restart in the order API, task, frontend, so nothing starts ahead of the
  procedures or endpoints it calls.
- DEPLOYMENT.md documents the portal roles, the new values, the upgrade procedure, and
  troubleshooting for No access and pending cleanup.
- CI adds a SQL Server 2022 service container that runs the SQL contract tests and
  the Broker API integration suite, the host script suite in ubuntu:24.04, and the
  task function tests.
- Test suites are excluded from container images.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- README: portal roles, scaling semantics and stop mode, release timing and cleanup
  before reuse, the keep-sessions-alive setting, what to plan for when upgrading,
  and a link to the roadmap. It also corrects what the LinuxHost role can do.
- docs/ROADMAP.md: the analysis and design for the admin console (Phase 2), operating
  system and desktop support (Phase 3), strategic scale (Phase 4), and the security
  hardening backlog. Each item states why it matters, its design across the database,
  API, host agents, and portal, its dependencies, open questions, and what done means.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit 47565c4 into main Sep 24, 2026
17 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