Coverage for app/services/deployment_notifier.py: 84.57%

175 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-25 15:51 +0000

1"""Build and send post-deploy notification mails. 

2 

3Two flavours: 

4 

5* ``send_user_mails(...)`` — one mail per user with their own access 

6 details and the list of teammates. 

7* ``send_owner_mail(...)`` — one mail to the deployment owner with 

8 every team's VM data and every user's credentials in one place. 

9 

10Both consume the worker's terraform outputs (``team_vms``, 

11``user_accounts``, ``teams_summary``) plus the deployment's team/user 

12membership from the DB. The output shape is documented inline below 

13so a future template change doesn't have to re-discover it from a 

14real run. 

15 

16Recipient freshness: every recipient is pulled from Keycloak 

17(``refresh_user_from_keycloak``) right before the mail is composed. 

18A deploy can run for many minutes between the wizard pick and the 

19notification, and the team member's address may have changed in the 

20meantime — refusing to refresh would silently send credentials to a 

21stale address. The refresh is best-effort: when Keycloak is down or 

22the user was deleted upstream we fall back to the DB record so a 

23flaky identity provider can't tank the entire notification flow. 

24 

25Failures are logged at the call site and never bubble up — sending 

26mail is best-effort, the deploy itself is already done. 

27""" 

28 

29from __future__ import annotations 

30 

31import logging 

32from typing import Any 

33from uuid import UUID 

34 

35from sqlalchemy.orm import Session 

36 

37from app.config import settings 

38from app.crud import deployments as crud_deployments 

39from app.models import App, Deployment, Team, User 

40from app.services import email_service 

41from app.utils.keycloak_auth import refresh_user_from_keycloak 

42 

43logger = logging.getLogger(__name__) 

44 

45 

46def _display_name(user: User) -> str: 

47 """Render a user-friendly greeting name. 

48 

49 Order of preference: firstName + lastName → firstName → username. 

50 Keeps username as the technical identifier; the templates can 

51 still reference ``user.username`` directly when they need the 

52 raw login. Never returns an empty string. 

53 """ 

54 parts = [p for p in (getattr(user, "firstName", None), getattr(user, "lastName", None)) if p] 

55 if parts: 

56 return " ".join(parts) 

57 return user.username or user.email.split("@")[0] 

58 

59 

60# ---------------------------------------------------------------------------- 

61# Outputs parsing 

62# ---------------------------------------------------------------------------- 

63# 

64# Worker tasks return ``terraform_outputs`` as the raw JSON object 

65# Terraform's ``output -json`` produces, i.e. each top-level key is an 

66# output name with ``{value, type, sensitive}`` underneath. We only care 

67# about the ``value`` of three well-known outputs from the Online-IDE 

68# template (and any template that follows the same conventions): 

69# 

70# team_vms.value: 

71# { 

72# "Team-1": { 

73# "code_server_url": "http://1.2.3.4:8080", 

74# "floating_ip": "1.2.3.4", 

75# "fixed_ip": "10.100.x.y", 

76# "instance_id": "uuid", 

77# "instance_name": "online-ide-Team-1" 

78# }, 

79# ... 

80# } 

81# 

82# user_accounts.value: 

83# { 

84# "Team-1-luca": { 

85# "auth": "<password-or-key-or-login-url>", 

86# "ip": "1.2.3.4", 

87# "port": 8080, 

88# "type": "password" | "ssh_key" | "oauth" | "none", 

89# "username": "luca" 

90# }, 

91# ... 

92# } 

93# 

94# Auth-type contract: 

95# * ``password`` (default if omitted) — ``auth`` is the password 

96# string. The mail prints a "Password" line. 

97# * ``ssh_key`` — ``auth`` is the public key the user should use 

98# (or a one-line hint about which key was pre-provisioned). The 

99# mail prints an "SSH key" line in a monospace block. 

100# * ``oauth`` — ``auth`` is the login URL the user should click 

101# (e.g. an external IdP). The mail prints "Login via …" with 

102# the URL. 

103# * ``none`` — no credential is shipped (open dashboard, no 

104# auth wall). ``auth`` may be omitted; the mail leaves out the 

105# credentials block entirely and only ships URL/IP. 

106# An unknown ``type`` falls back to ``password`` rendering for 

107# safety (the mail still shows whatever ``auth`` value the app 

108# produced rather than silently dropping it). 

109# 

110# teams_summary.value: {"Team-1": 1, ...} — member counts; not used 

111# directly but useful as a 

112# sanity check. 

113# 

114# When a template doesn't expose one of these (e.g. an app without 

115# per-user credentials), the helpers return empty dicts and the mail 

116# silently omits those sections. 

117 

118 

119def _output_value(outputs: dict[str, Any] | None, key: str) -> Any: 

120 """Pluck ``outputs[key].value`` out, tolerating missing keys.""" 

121 if not outputs: 

122 return None 

123 bag = outputs.get(key) 

124 if isinstance(bag, dict): 

125 return bag.get("value") 

126 return None 

127 

128 

129def _team_vms(outputs: dict[str, Any] | None) -> dict[str, dict[str, Any]]: 

130 val = _output_value(outputs, "team_vms") 

131 return val if isinstance(val, dict) else {} 

132 

133 

134def _user_accounts(outputs: dict[str, Any] | None) -> dict[str, dict[str, Any]]: 

135 val = _output_value(outputs, "user_accounts") 

136 return val if isinstance(val, dict) else {} 

137 

138 

139def _vm_for_team(outputs: dict[str, Any] | None, team_name: str) -> dict[str, Any] | None: 

140 """Pick the VM block for one team, normalised to the keys the 

141 template expects (``url``, ``floating_ip``, ``fixed_ip``, 

142 ``instance_name``). Templates that emit different keys (e.g. 

143 ``url`` instead of ``code_server_url``) are tolerated because we 

144 only normalise what we recognise. 

145 """ 

146 raw = _team_vms(outputs).get(team_name) 

147 if not isinstance(raw, dict): 

148 return None 

149 return { 

150 "url": raw.get("code_server_url") or raw.get("url"), 

151 "floating_ip": raw.get("floating_ip"), 

152 "fixed_ip": raw.get("fixed_ip"), 

153 "instance_name": raw.get("instance_name"), 

154 } 

155 

156 

157def _normalise_account_key(value: str | None) -> str: 

158 """Normalise an account/username key for fuzzy matching. 

159 

160 Worker templates derive Linux-friendly account names from emails 

161 by replacing every non-``[a-z0-9]`` character with a single ``-``. 

162 A user with email ``luca.baeck@gmail.com`` becomes ``luca-baeck`` 

163 in the terraform output, while our DB has ``luca`` as the 

164 username and ``luca.baeck`` as the email's local-part. Comparing 

165 those literally misses every match where the template applied any 

166 transformation. We collapse ``.``/``-``/``_``/spaces to a single 

167 ``-`` and lowercase, so all four forms map to the same canonical 

168 string and the matcher succeeds without us having to know which 

169 transformation a given template applied. 

170 """ 

171 if not value: 

172 return "" 

173 out = [] 

174 for ch in value.strip().lower(): 

175 if ch.isalnum(): 

176 out.append(ch) 

177 elif ch in ".-_ ": 

178 out.append("-") 

179 # Other characters drop entirely. 

180 # Collapse runs of "-" into one so "a..b" and "a-b" both end up 

181 # as "a-b". 

182 canonical = "".join(out) 

183 while "--" in canonical: 

184 canonical = canonical.replace("--", "-") 

185 return canonical.strip("-") 

186 

187 

188def _find_account_for_user( 

189 outputs: dict[str, Any] | None, 

190 team_name: str, 

191 user: User, 

192) -> tuple[str, dict[str, Any]] | None: 

193 """Locate the user's raw account entry in the worker's outputs. 

194 

195 Templates key per-user accounts as ``"<team>-<account-name>"`` 

196 where ``<account-name>`` is some derivation of the user's email 

197 or username — the Online-IDE template, for example, takes the 

198 email's local-part and substitutes non-alphanumerics with ``-``. 

199 

200 To find the right entry without coupling to one specific naming 

201 scheme we build a set of candidate identifiers from everything 

202 we know about the user (username, email local-part, full name) 

203 plus a normalised form of each, then walk every account in the 

204 output and check for any overlap. ``_normalise_account_key`` 

205 makes ``luca.baeck``, ``luca-baeck`` and ``LUCA_BAECK`` all 

206 compare equal. 

207 

208 Returns ``(key, raw_entry)`` on a match — the ORIGINAL output key 

209 alongside the untouched account dict — or ``None``. Kept separate 

210 from :func:`_access_for_user` so callers that need the raw entry 

211 (e.g. the per-member ``/my-access`` endpoint, whose response mirrors 

212 the terraform ``user_accounts`` shape) reuse the exact same matching 

213 logic as the mail path without re-deriving the normalised form. 

214 """ 

215 accounts = _user_accounts(outputs) 

216 candidates_raw: set[str] = { 

217 user.username or "", 

218 (user.email or "").split("@")[0], 

219 } 

220 if user.firstName and user.lastName: 

221 candidates_raw.add(f"{user.firstName} {user.lastName}") 

222 candidates = {_normalise_account_key(c) for c in candidates_raw if c} 

223 candidates.discard("") 

224 

225 for key, raw in accounts.items(): 

226 if not isinstance(raw, dict): 

227 continue 

228 # Strip the team-name prefix when present so a key like 

229 # ``"Team-1-luca-baeck"`` becomes ``"luca-baeck"`` before 

230 # normalisation. The prefix itself is normalised the same 

231 # way so a team named ``"Team 1"`` (space) also matches. 

232 team_prefix = _normalise_account_key(team_name) + "-" 

233 normalised_key = _normalise_account_key(key) 

234 suffix = normalised_key[len(team_prefix):] if normalised_key.startswith(team_prefix) else normalised_key 

235 

236 candidates_with_inner_username = candidates | {_normalise_account_key(raw.get("username"))} 

237 candidates_with_inner_username.discard("") 

238 

239 if suffix in candidates_with_inner_username: 

240 return key, raw 

241 return None 

242 

243 

244def _access_for_user( 

245 outputs: dict[str, Any] | None, 

246 team_name: str, 

247 user: User, 

248) -> dict[str, Any] | None: 

249 """Build the normalised access dict the mail templates consume. 

250 

251 Thin wrapper over :func:`_find_account_for_user`: locates the user's 

252 raw account entry, then maps it onto the ``username`` / 

253 ``auth_type`` / ``auth_value`` shape the Jinja mail templates expect. 

254 Returns ``None`` when no entry matches the user. 

255 """ 

256 match = _find_account_for_user(outputs, team_name, user) 

257 if match is None: 

258 return None 

259 key, raw = match 

260 team_prefix = _normalise_account_key(team_name) + "-" 

261 normalised_key = _normalise_account_key(key) 

262 suffix = normalised_key[len(team_prefix):] if normalised_key.startswith(team_prefix) else normalised_key 

263 

264 # Normalise the ``type`` slot once. Unknown values fall back to 

265 # ``password`` rendering so unforeseen app outputs still produce a 

266 # useful mail rather than dropping silently. 

267 raw_type = raw.get("type") 

268 auth_type = raw_type if raw_type in ("password", "ssh_key", "oauth", "none") else "password" 

269 auth_value = raw.get("auth") 

270 # ``password`` field is kept for backwards-compatibility with any 

271 # template path that still reads it directly; it is only populated 

272 # for the password type so a missing value renders as None (templates 

273 # check ``auth_type`` before pulling ``password``). 

274 password = auth_value if auth_type == "password" else None 

275 return { 

276 "username": raw.get("username") or suffix, 

277 "password": password, 

278 "ip": raw.get("ip"), 

279 "port": raw.get("port"), 

280 "auth_type": auth_type, 

281 # ``auth_value`` carries the raw credential regardless of type — 

282 # templates use it together with ``auth_type`` to decide WHERE to 

283 # show it (password field, SSH-key block, OAuth login link, ...). 

284 "auth_value": auth_value, 

285 # Convenience fallback so the user-mail can show a URL even when 

286 # the per-user output doesn't carry one — use the team VM's URL. 

287 "url": (_vm_for_team(outputs, team_name) or {}).get("url"), 

288 } 

289 

290 

291 

292# ---------------------------------------------------------------------------- 

293# Senders 

294# ---------------------------------------------------------------------------- 

295 

296 

297def _team_members(db: Session, team: Team) -> list[User]: 

298 """Resolve team members via the join table. ``Team.user_to_teams`` 

299 is the relationship; we go through ``crud_deployments`` so the 

300 join logic stays centralised (and works regardless of session 

301 state).""" 

302 return crud_deployments.get_team_members(db, team.teamId) 

303 

304 

305def _send_user_mail( 

306 *, 

307 db: Session, 

308 user: User, 

309 teammates: list[User], 

310 team_name: str, 

311 deployment: Deployment, 

312 app: App, 

313 access: dict[str, Any], 

314) -> None: 

315 # Re-pull from Keycloak immediately before composing the mail. 

316 # The DB row may have been written minutes (or longer) ago when 

317 # the wizard picker first stored the membership; meanwhile the 

318 # user might have changed their address upstream. Refreshing here 

319 # keeps the recipient honest. ``refresh_user_from_keycloak`` is 

320 # best-effort: if KC is unreachable the helper logs and returns 

321 # the DB row, so the mail still goes out to the last-known-good 

322 # address rather than failing entirely. 

323 # 

324 # The notify caller already pre-refreshed every team member to 

325 # keep the ``teammates`` list consistent. We refresh the recipient 

326 # once more here because this helper is also called from the 

327 # ``resend_user_access`` path, which doesn't run that pre-loop — 

328 # making the refresh a property of the sender keeps both call 

329 # sites honest. The extra KC hit on the notify path is one 

330 # roundtrip per user mail, which is negligible compared to the 

331 # SMTP work that follows. 

332 user = refresh_user_from_keycloak(db, user) 

333 ctx = { 

334 "user": user, 

335 "user_display_name": _display_name(user), 

336 "teammates": [ 

337 {"user": m, "display_name": _display_name(m)} 

338 for m in teammates 

339 if m.userId != user.userId 

340 ], 

341 "team_name": team_name, 

342 "deployment": { 

343 "name": deployment.name, 

344 "git_url": app.git_link, 

345 "release_tag": deployment.releaseTag, 

346 "app_name": app.name, 

347 }, 

348 "access": access, 

349 } 

350 email_service.send_email( 

351 to=user.email, 

352 subject=f"[{deployment.name}] Your access details", 

353 html_body=email_service.render("user_invite.html", **ctx), 

354 text_body=email_service.render("user_invite.txt", **ctx), 

355 ) 

356 

357 

358def _send_owner_mail( 

359 *, 

360 db: Session, 

361 owner: User, 

362 deployment: Deployment, 

363 app: App, 

364 teams_payload: list[dict[str, Any]], 

365) -> None: 

366 # Same refresh contract as ``_send_user_mail`` — the owner of a 

367 # deployment is also a Keycloak user whose address may have 

368 # changed since the deployment was created. 

369 owner = refresh_user_from_keycloak(db, owner) 

370 ctx = { 

371 "owner": owner, 

372 "owner_display_name": _display_name(owner), 

373 "deployment": { 

374 "name": deployment.name, 

375 "git_url": app.git_link, 

376 "release_tag": deployment.releaseTag, 

377 "app_name": app.name, 

378 }, 

379 "teams": teams_payload, 

380 "detail_url": f"{settings.APP_BASE_URL.rstrip('/')}/deployments/{deployment.deploymentId}", 

381 } 

382 email_service.send_email( 

383 to=owner.email, 

384 subject=f"[{deployment.name}] Deployment summary", 

385 html_body=email_service.render("owner_summary.html", **ctx), 

386 text_body=email_service.render("owner_summary.txt", **ctx), 

387 ) 

388 

389 

390def notify_deployment_succeeded( 

391 db: Session, 

392 deployment_id: UUID, 

393 terraform_outputs: dict[str, Any] | None, 

394) -> None: 

395 """Top-level entry point — call from the celery event listener 

396 after a successful DEPLOY task. 

397 

398 Loads the deployment with all relations, walks its teams, and 

399 sends one mail per user plus one summary mail to the owner. 

400 Silent no-op when the deployment is missing or the outputs are 

401 empty (e.g. a deploy that produced no resources to credential). 

402 """ 

403 deployment = crud_deployments.get_deployment_with_details(db, deployment_id) 

404 if not deployment: 

405 logger.warning("notify: deployment %s not found", deployment_id) 

406 return 

407 

408 app = deployment.app 

409 owner = deployment.user 

410 if not app or not owner: 

411 logger.warning( 

412 "notify: deployment %s missing app or owner relation", deployment_id 

413 ) 

414 return 

415 

416 if not terraform_outputs: 

417 logger.info( 

418 "notify: deployment %s has no terraform outputs, skipping mails", 

419 deployment_id, 

420 ) 

421 return 

422 

423 # Build the per-team payload once. Used for the owner mail and for 

424 # picking each user's individual access. 

425 teams_payload: list[dict[str, Any]] = [] 

426 for team in deployment.teams or []: 

427 members = _team_members(db, team) 

428 # Refresh every team member from Keycloak up front so the 

429 # teammates section in each per-user mail and the owner 

430 # summary's member list both reflect the current upstream 

431 # records, not whatever was on file when the deployment was 

432 # created. ``_send_user_mail`` does its own refresh of the 

433 # specific recipient too, but doing it once here keeps the 

434 # ``teammates`` rendering consistent and avoids N redundant 

435 # KC roundtrips per user mail. 

436 members = [refresh_user_from_keycloak(db, m) for m in members] 

437 member_payload: list[dict[str, Any]] = [] 

438 for member in members: 

439 access = _access_for_user(terraform_outputs, team.name, member) 

440 if access is None: 

441 # No credential output for this user — still include 

442 # them in the owner summary so the owner sees who's on 

443 # the team, but skip the per-user mail (no useful 

444 # content to send). 

445 member_payload.append({ 

446 "user": member, 

447 "display_name": _display_name(member), 

448 "access": { 

449 "username": "—", 

450 "password": "—", 

451 "auth_type": "password", 

452 "auth_value": None, 

453 }, 

454 }) 

455 continue 

456 member_payload.append({ 

457 "user": member, 

458 "display_name": _display_name(member), 

459 "access": access, 

460 }) 

461 

462 # Per-user mail — fire-and-forget; failures already logged 

463 # inside ``email_service.send_email``. 

464 try: 

465 _send_user_mail( 

466 db=db, 

467 user=member, 

468 teammates=members, 

469 team_name=team.name, 

470 deployment=deployment, 

471 app=app, 

472 access=access, 

473 ) 

474 except Exception as e: 

475 logger.warning( 

476 "notify: user mail to %s for deployment %s failed: %s", 

477 member.email, deployment_id, e, 

478 ) 

479 

480 teams_payload.append({ 

481 "name": team.name, 

482 "vm": _vm_for_team(terraform_outputs, team.name), 

483 "members": member_payload, 

484 }) 

485 

486 # Owner summary last so it includes everything we managed to 

487 # resolve. 

488 try: 

489 _send_owner_mail( 

490 db=db, 

491 owner=owner, 

492 deployment=deployment, 

493 app=app, 

494 teams_payload=teams_payload, 

495 ) 

496 except Exception as e: 

497 logger.warning( 

498 "notify: owner mail for deployment %s failed: %s", 

499 deployment_id, e, 

500 ) 

501 

502 

503# ---------------------------------------------------------------------------- 

504# Single-user resend 

505# ---------------------------------------------------------------------------- 

506 

507 

508class ResendError(Exception): 

509 """Resend prerequisites weren't met (no successful deploy, user not in team, no credentials).""" 

510 

511 

512def resend_user_access( 

513 db: Session, 

514 deployment_id: UUID, 

515 team_id: UUID, 

516 user_id: UUID, 

517) -> bool: 

518 """Re-send the access mail for one specific user of a deployment. 

519 

520 Loads the deployment's latest successful DEPLOY task to recover 

521 the original ``terraform_outputs`` (those carry the user-specific 

522 credentials), then sends the same per-user mail 

523 ``notify_deployment_succeeded`` would have sent — to that user 

524 only. Used by the "Resend access" button in the Teams card on the 

525 deployment detail page. 

526 

527 Raises ``ResendError`` with a structured reason when: 

528 

529 * the deployment doesn't exist or has no successful deploy task 

530 (nothing to resend yet) 

531 * the team or the user isn't part of this deployment (caller 

532 shouldn't have asked, but defend in depth) 

533 * the deploy didn't produce a credential for this user (template 

534 doesn't issue per-user accounts, or the matcher missed) 

535 

536 Returns ``True`` on a successful SMTP handover, ``False`` if the 

537 mail was sent but SMTP rejected it (logged inside ``send_email``). 

538 """ 

539 deployment = crud_deployments.get_deployment_with_details(db, deployment_id) 

540 if not deployment: 

541 raise ResendError("deployment_not_found") 

542 app = deployment.app 

543 if not app: 

544 raise ResendError("deployment_app_missing") 

545 

546 # Find the team scoped to this deployment. 

547 team: Team | None = next( 

548 (t for t in (deployment.teams or []) if t.teamId == team_id), 

549 None, 

550 ) 

551 if team is None: 

552 raise ResendError("team_not_in_deployment") 

553 

554 members = _team_members(db, team) 

555 user: User | None = next((m for m in members if m.userId == user_id), None) 

556 if user is None: 

557 raise ResendError("user_not_in_team") 

558 

559 # Pull the most recent successful DEPLOY task's outputs — the same 

560 # JSON the original notify ran against. The crud helper filters to 

561 # DEPLOY + SUCCESS and returns None for missing/unparseable outputs; 

562 # both collapse to "nothing to resend yet" (409 at the router). 

563 outputs = crud_deployments.get_latest_successful_deploy_outputs( 

564 db, deployment_id 

565 ) 

566 if outputs is None: 

567 raise ResendError("no_successful_deploy") 

568 

569 access = _access_for_user(outputs, team.name, user) 

570 if access is None: 

571 raise ResendError("no_credentials_for_user") 

572 

573 try: 

574 _send_user_mail( 

575 db=db, 

576 user=user, 

577 teammates=members, 

578 team_name=team.name, 

579 deployment=deployment, 

580 app=app, 

581 access=access, 

582 ) 

583 return True 

584 except Exception as e: 

585 logger.warning( 

586 "resend: user mail to %s for deployment %s failed: %s", 

587 user.email, deployment_id, e, 

588 ) 

589 return False 

590 

591 

592# ---------------------------------------------------------------------------- 

593# Per-member self-access lookup (read-only) 

594# ---------------------------------------------------------------------------- 

595 

596 

597def get_user_access( 

598 db: Session, 

599 deployment_id: UUID, 

600 user_id: UUID, 

601) -> dict[str, Any] | None: 

602 """Return the terraform output slices a single member is allowed to see. 

603 

604 Powers the ``GET /deployments/{id}/my-access`` endpoint: a team member 

605 (typically a student) may retrieve THEIR OWN access credentials without 

606 the owner-view that gates the full ``outputs`` payload. Only the caller's 

607 own account entry is ever returned — teammates' credentials are never 

608 included. 

609 

610 The result mirrors the raw terraform ``user_accounts`` / ``team_vms`` 

611 output shape (one key each: the member's account and their team's VM) 

612 so the frontend's existing account-matching pipeline consumes it 

613 unchanged: 

614 

615 {"user_accounts": {"<key>": {...}}, "team_vms": {"<team>": {...}}} 

616 

617 Returns ``None`` when the deployment is missing, the user isn't part of 

618 any of its teams, there's no successful deploy yet, or the deploy issued 

619 no per-user credential for this user. The router maps ``None`` onto an 

620 empty-map 200 response so the UI can render a clean "no credentials yet" 

621 state instead of an error. 

622 """ 

623 deployment = crud_deployments.get_deployment_with_details(db, deployment_id) 

624 if not deployment: 

625 return None 

626 

627 # Find which team of this deployment the user belongs to. We need the 

628 # Team (for its name, the account-key prefix) and the User object (its 

629 # identifiers drive the fuzzy account match). Mirrors the team/user 

630 # resolution in ``resend_user_access``. 

631 matched_team: Team | None = None 

632 matched_user: User | None = None 

633 for team in deployment.teams or []: 

634 members = crud_deployments.get_team_members(db, team.teamId) 

635 member = next((m for m in members if m.userId == user_id), None) 

636 if member is not None: 

637 matched_team = team 

638 matched_user = member 

639 break 

640 if matched_team is None or matched_user is None: 

641 return None 

642 

643 outputs = crud_deployments.get_latest_successful_deploy_outputs( 

644 db, deployment_id 

645 ) 

646 if outputs is None: 

647 return None 

648 

649 match = _find_account_for_user(outputs, matched_team.name, matched_user) 

650 if match is None: 

651 return None 

652 

653 key, raw = match 

654 # Return the RAW account entry keyed by its ORIGINAL output key so the 

655 # frontend's ``enrichedTeams`` matching (which derives the same key 

656 # from team + email) resolves it exactly as it does for the owner-view 

657 # ``user_accounts`` map. Include only the caller's own team VM block. 

658 result: dict[str, Any] = {"user_accounts": {key: raw}, "team_vms": {}} 

659 team_vm = _team_vms(outputs).get(matched_team.name) 

660 if isinstance(team_vm, dict): 

661 result["team_vms"][matched_team.name] = team_vm 

662 return result 

663