ADR 0035: The AddOn pane model — independently built tools in sandboxed panes on lab names, a generic site page, a status proxy and a generic host role¶
Status: Accepted 2026-10-10 (proposed 2026-10-09 from the approved App-to-AddOn program plan; decisions 8 and 9 confirmed by the owner on 2026-10-10 in #1044; the Caddy redirect in Consequences carried [VERIFY], answered on 2026-10-10 by caddy adapt in CI)
Decision date: 2026-10-09
Owners: Workload owner and architecture owner
Related requirements: the App-to-AddOn program (HCW-AzMigrateOrchestrator_Addon,
HCW-CloudAssessor_AddOn, HCW-NetworkEyes_AddOn); ADR 0032
decisions 4 and 6 and its amendments of 2026-09-26 and 2026-09-28; the
AddOn integration standard.
Context¶
The App-to-AddOn program splits each HCW product into an upstream App (the
engine, rules and UI, with an Azure appliance edition that runs on Container
Apps under a managed identity on a client's subscription) and a downstream AddOn
(the same image in a lab edition on the lab host, where a visitor uploads an
Azure Resource Graph export and gets an assessment that is held in memory for
two hours). The site needs to show three such AddOns (migration,
cloud-assessment, network-assessment) and any later one, without taking on
their code, their secrets or their release cadence.
Owner decisions 2026-10-09 that constrain the answer:
- The AddOn is the same container image as the App, in a
labedition, hosted on the lab host. It holds no cloud credential and must be technically unable to construct one. - The integration is the website's pane model, generalised: each AddOn is an
independently deployed web application framed in a sandboxed iframe on
/tools/<id>, with a catalogue row, aframe-srcentry and a Function App status proxy. The npm island (installing@hybridcloudworks/migration-uiinto the site bundle, as the AddOn's integration guide proposed) is rejected. - The website changes once, in one reviewable PR, from a written specification.
- All AddOn images publish to Docker Hub (
docker.io/hybridcloudworks/<image>) and are pinned by digest, matching the lab-image decision of 2026-10-08.
The precedent is ADR 0032. Its decision 4 put a server-side status proxy with a
minute cache in front of Coder so the browser never calls the lab; its amendment
of 2026-09-28 made the site's panes the only way into a lab, with Caddy adding
site-only frame-ancestors and redirecting top-level visits, and
LabPanePage.jsx holding the sandbox, the message filter and the watchdog. The
site already has every part the AddOns need, written for one tenant.
Three things stop the labs pattern being reused as it is:
- ADR 0032's host record says Docker Compose on the host is for Coder and its PostgreSQL only, and the lab host's README and the caddy role's README repeat it. AddOn containers are a new kind of workload on that host.
- The labs pane sandbox is
allow-scripts allow-same-origin allow-forms allow-popupswith noallow-downloads. Every AddOn produces a report bundle and a sample file; Chromium blocks a download from a sandboxed frame without that flag, and a popup inherits the sandbox, soallow-popupsdoes not help. - The labs code is tied to Coder: GitHub sign-in in a new tab, a launcher page, workspace states, wildcard sub-origins, a template. None of it applies to an anonymous tool that answers an upload.
The question is therefore how AddOns reach the site: in the bundle, behind the site's own origin, or as framed origins of their own, and under what contract.
Purpose and decision drivers¶
- Keep the site's content security policy closed:
connect-srcnames the Function App and sign-in only,frame-ancestorsstays'none', and each AddOn adds one exact origin toframe-srcand nothing else. - Keep every AddOn secret on the lab host, whose Ansible vault already holds the lab's secrets and unseals through the Arc identity; the site's Key Vault gains nothing.
- Let each AddOn ship on its own cadence, with a release reaching the site as a one-line digest change and an owner run, and roll back the same way.
- Pay for nothing new: the AddOns run on the host already paid for (ADR 0015).
- Keep a lab edition technically unable to construct a cloud credential, so the host that runs it holds nothing an AddOn could use against a tenant.
Decision¶
Chosen: sandboxed panes on lab names, because it is the one model that keeps the site's CSP closed, keeps the AddOn's secret boundary on the host that already holds one, and lets each AddOn ship on its own cadence with a one-digest change on the site. The decision has nine parts.
- One origin per AddOn.
https://<id>.lab.hybridcloudworks.com, one label, no wildcard, served by Caddy on the lab host. The*.labDNS record and the wildcard certificate already cover it. The AddOn's own HTML carriesframe-ancestorsequal to the host'scaddy_frame_ancestors, from env, so the two policies the browser enforces agree. - A generic site page.
frontend/src/pages/tools/AddOnPanePage.jsxat/tools/<id>, structured asLabPanePage.jsxwithout the sign-in step: one opening sentence, a frame, or one unavailable sentence (This tool isn't available right now.), a 30-second watchdog, and the pane protocol. No provider-scoped variants. - The pane protocol. Sandbox
allow-scripts allow-same-origin allow-formsplus capability flags from the row;allowfor clipboard and fullscreen by capability only; messages{ type: 'hcw-addon', id, state, navigate? }with statesloading,ready,working,unavailable, accepted only from the AddOn's origin, the frame's own window and the row's id;navigatefrom an allow-list, handled by the page; no message from the site to the AddOn. - A status proxy.
GET /api/public/addons/{id}/statuson the Function App, anonymous, a closed server-side registry of ids, a 5-second read of the AddOn's/api/healthover https only, a one-minute cache intool_service_cachewith failures cached, and a six-field projection (configured,reachable,version,edition,capabilities,asOf). The frame mounts only onconfiguredandreachable. The AddOn's address is a plain app setting (ADDON_<ID>_URL), not a vault reference, because it is a public value already in the CSP. - A catalogue.
frontend/src/data/addons/catalogue.js, frozen rows withid,title,menuLabel,summary,origin,panePath,healthPath,providers,technology,status,capabilities,docsUrl,articleSlugs. The Tools menu, the pre-render list, the CSPframe-srcentries and the registry parity test derive from it. Acomingrow renders an explainer and no frame. - A generic Ansible role.
lab-host/ansible/roles/addonsruns one hardened single container per row (hcw-addon-<id>, loopback port, read-only root, all capabilities dropped, no new privileges, memory and pids limits, no volumes or mounts, no socket, on the add-ons' own bridge network with inter-container communication off and egress limited in theDOCKER-USERchain to the verification endpoint's published ranges on 443, under the daemon's user-namespace remap), waits for its health, renders a Caddy route with the unavailable sentence at 503 (a direct 503 for a row with no digest yet), removes the container of any row not deployed this run, and refuses to start a container whose vault key (vault_addon_<id>_<name>) is unset. The end-of-run privilege checks cover each container, its network and its one port binding. - Docker Hub digests. Images are
docker.io/hybridcloudworks/<image>, pinned by digest ingroup_vars/all.yml. A release is a website PR that moves one digest and the owner'sbootstrap.shrun; rollback is the previous digest. The website never bumps an AddOn on its own. - Amendment to ADR 0032. The line "Docker Compose on this host is for Coder
and its PostgreSQL only" stands for Compose. This record adds: single
containers run directly by the
addonsrole are a second permitted workload on the host, each loopback-only behind Caddy, each listed ingroup_vars, each without Compose. ADR 0032'sdocker psvalidation names gainhcw-addon-<id>by reference to this record. Confirmed by the owner on 2026-10-10 (#1044). - The
downloadscapability. A row that namesdownloadsgetsallow-downloadson its sandbox, so the AddOn's report bundle and sample file can be saved from the pane. Every AddOn pane also offers "Copy report", so it works without the flag. Confirmed by the owner on 2026-10-10 with decision 8 in #1044: the migration row grantsnavigateanddownloads, and the accepted-risk row inTODO.mdapplies.
The AddOn integration standard
carries the contracts in full, with each requirement marked Mandatory or
Optional, and the compliance checklist a reviewer ticks before a row becomes
available.
Consequences and accepted risks¶
- Positive: the site's
connect-srcstays closed and itsframe-ancestors 'none'stays; each AddOn adds one exact origin toframe-srcand nothing else. - Positive: the AddOn's secret boundary is the lab host's vault, already auto-unsealed through the Arc identity (ADR 0032, amendment of 2026-09-29); the site's Key Vault gains nothing.
- Positive: an AddOn release reaches the site as a one-line digest change and an owner run; the site's own build and deploy are untouched by it.
- Positive: three AddOns share one page, one proxy, one role and one test
harness; the fourth costs a row, a setting, a
group_varsentry and a CSP line. - Positive: the lab editions are technically unable to construct a cloud credential, enforced by tests and an image-level check, so the host that runs them holds nothing an AddOn could use against a tenant.
- Negative: the site cannot see inside a pane; it knows only what the AddOn
posts and what the proxy reads. A broken AddOn page that still answers its
health read shows as available until the watchdog or the AddOn's own
unavailablemessage says otherwise. - Negative:
allow-same-originwithallow-scriptson a cross-origin frame gives the AddOn its own origin's full powers inside the pane. Accepted because the frame is always another origin (the sandbox cannot be lifted), the AddOn holds no site credential,frame-srcnames each origin exactly, and the labs pane already runs this way. - Negative:
allow-downloadslets a framed AddOn start a file download in the visitor's browser. Accepted because the AddOn is the site's own image by digest, the download is the visitor's own report, and the row grants it per AddOn. Recorded as an accepted risk inTODO.md. - Negative: the lab host gains a workload kind beyond ADR 0032's record, and its memory ceiling now counts AddOn containers (512 MiB each) beside Coder's capacity assertion.
- Negative: the Caddyfile's direct-visit redirect becomes per-host through a
varsoverride, which depends on directive ordering. [VERIFY] was answered on 2026-10-10:lab-host/ansible/roles/caddy/tests/caddy-adapt.test.shhas the pinned Caddy (v2.11.4) adapt the rendered configuration in thelab-host role testsjob (theansible-lint (lab-host)job until 2026-11-04) and fails unless the snippet'svarscomes first, the route's after it and theredirafter both; the fallback (the global redirect to the labs page) was not needed. - Negative: a visitor's upload leaves the site's trust boundary for the lab host; the pane says so before an upload, and the AddOn holds it in memory for at most two hours with a delete button.
- Follow-ups: the website PR specified in
docs/architecture/app-to-addon/; the migration AddOn'sv0.3.0release with the pane app, configurableframe-ancestors, rate limiting and the standard health shape; the owner creates one human-verification widget per AddOn and seeds its secret withhcw-vault-set; the two Python AddOns flip fromcomingtoavailablewith their own origins, settings,group_varsentries and CSP lines in follow-up PRs; the AddOn's standalone Terraform and tunnel compose are retired in its own repository.
Alternatives considered¶
- npm island in the site bundle. The site installs the AddOn's React
package and renders it on
/tools/migration, calling the AddOn's API from the browser. Fails four site rules at once.connect-srcis closed to everything but the Function App and sign-in, so the island could not call the AddOn's API without widening it to a third-party origin for every visitor. The AddOn's UI package renders a partner list naming the host, the edge and the workspace platform, whichpublic-copy.test.jsforbids on public pages. The site would rebuild and redeploy for every AddOn release, which defeats the independent update the program requires. And the package publish is paused by the owner, so the option is blocked anyway. Rejected. - Reverse proxy under the site's origin. The site's edge forwards
/tools/migration/*to the lab host, so the AddOn runs same-origin with the site: its scripts would run with the site's origin, its cookies and storage would be the site's, and the site's ownframe-ancestors 'none'and CSP would have to admit whatever the AddOn serves. The secret boundary (the AddOn's verification secret on the lab host) would sit behind the site's name. It also needs an edge rule per AddOn at the site's CDN, whichinfra/manages and which is outside the lab host's Ansible. Rejected. - Sandboxed pane on a lab name (chosen). Reuses what ADR 0032 built, keeps the AddOn's secrets on the lab host where the vault already is, keeps every AddOn one exact origin in the CSP, and lets a release reach the host as a one-line digest change.
- Defer. Keep
/tools/migrationas a placeholder until the npm packages are published. Leaves the Migration Hub a placeholder for an unknown time and does not answer how the two Python AddOns arrive. Rejected.
Validation and revisit triggers¶
Validation that this record is in force:
frontend/src/lib/csp.test.jslists every available AddOn origin inframe-srcand nowhere else, and no*.labwildcard.frontend/src/pages/tools/AddOnPanePage.test.jsxproves the sandbox per capability, the message filter, the watchdog and the status gate.functions/src/lib/addons/status.test.jsproves the three answer shapes, the https rule, the cache and the projection.scripts/lab-host-addons-route.test.mjsandlab-host-visitor-copy.test.mjsprove the Caddy route and the one sentence;lab-host/ansible/roles/caddy/tests/caddy-adapt.test.shproves the per-name redirect's ordering with the pinned Caddy.- On the host,
sudo docker psshowshcw-addon-migrationby name andcurl -s http://127.0.0.1:18081/api/healthanswers"ok":true.
Revisit this record when any of the following happens:
- A hub Tools page needs a canonical per-provider URL for an AddOn (the "no provider-scoped variants" rule in the standard, section 5).
- An AddOn needs a message from the site, a site credential, or a second origin; any of these breaks the protocol as versioned by the standard and needs a new revision and a new record.
- The lab host's memory ceiling is reached with the three AddOns and Coder on; the host or the AddOn footprint changes under ADR 0015's cost ceiling.
- The
caddy adaptcheck shows the per-host redirect no longer holds on a newer Caddy, and the fallback is adopted: amend decision 6 here rather than leave the role'svarsline dead. - The npm packages are published and a case for an in-bundle island returns; the four site rules in Alternatives considered must be answered first.
allow-downloadsis withdrawn by the owner: decision 9 is struck, the row dropsdownloads, and "Copy report" is the only path.
Superseded by: none. This record amends ADR 0032's host workload line as decision 8 says and does not supersede it.
Related decisions and references¶
- ADR 0032: the lab host, the status proxy pattern (decision 4), panes only and Coder in panes (amendments of 2026-09-28), Vault auto-unseal (2026-09-29), privilege separation (2026-10-07); amended here on the host's workload kinds.
- ADR 0006: admin surfaces (the Integrations card and Health probe for each AddOn) stay behind Entra.
- ADR 0007: the pane page is pre-rendered per row and reads nothing in its first render.
- ADR 0015: the AddOn containers run on the host already paid for; nothing new is provisioned in Azure.
- ADR 0019: the status proxy is a route on the one Function App.
- AddOn integration standard: the contracts and the compliance checklist.
- Labs host: the estate record, updated with the AddOn containers and port.
- App-to-AddOn program: the program package and each AddOn's integration record.
- Upstream records:
HCW-AzMigrateOrchestrator_AppADR-0028 (upstream and downstream editions) and the planned ADR-0030 (web-front edition runs as a site pane);HCW-AzMigrateOrchestrator_Addondocs/website-integration/. frontend/src/pages/shared/LabPanePage.jsx,functions/src/lib/labs/coder-status.js,lab-host/ansible/roles/caddy/templates/Caddyfile.j2,lab-host/ansible/roles/portainer/(the single-container role pattern).