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
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Build and send post-deploy notification mails.
3Two flavours:
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.
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.
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.
25Failures are logged at the call site and never bubble up — sending
26mail is best-effort, the deploy itself is already done.
27"""
29from __future__ import annotations
31import logging
32from typing import Any
33from uuid import UUID
35from sqlalchemy.orm import Session
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
43logger = logging.getLogger(__name__)
46def _display_name(user: User) -> str:
47 """Render a user-friendly greeting name.
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]
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.
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
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 {}
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 {}
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 }
157def _normalise_account_key(value: str | None) -> str:
158 """Normalise an account/username key for fuzzy matching.
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("-")
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.
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 ``-``.
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.
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("")
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
236 candidates_with_inner_username = candidates | {_normalise_account_key(raw.get("username"))}
237 candidates_with_inner_username.discard("")
239 if suffix in candidates_with_inner_username:
240 return key, raw
241 return None
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.
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
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 }
292# ----------------------------------------------------------------------------
293# Senders
294# ----------------------------------------------------------------------------
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)
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 )
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 )
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.
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
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
416 if not terraform_outputs:
417 logger.info(
418 "notify: deployment %s has no terraform outputs, skipping mails",
419 deployment_id,
420 )
421 return
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 })
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 )
480 teams_payload.append({
481 "name": team.name,
482 "vm": _vm_for_team(terraform_outputs, team.name),
483 "members": member_payload,
484 })
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 )
503# ----------------------------------------------------------------------------
504# Single-user resend
505# ----------------------------------------------------------------------------
508class ResendError(Exception):
509 """Resend prerequisites weren't met (no successful deploy, user not in team, no credentials)."""
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.
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.
527 Raises ``ResendError`` with a structured reason when:
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)
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")
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")
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")
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")
569 access = _access_for_user(outputs, team.name, user)
570 if access is None:
571 raise ResendError("no_credentials_for_user")
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
592# ----------------------------------------------------------------------------
593# Per-member self-access lookup (read-only)
594# ----------------------------------------------------------------------------
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.
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.
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:
615 {"user_accounts": {"<key>": {...}}, "team_vms": {"<team>": {...}}}
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
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
643 outputs = crud_deployments.get_latest_successful_deploy_outputs(
644 db, deployment_id
645 )
646 if outputs is None:
647 return None
649 match = _find_account_for_user(outputs, matched_team.name, matched_user)
650 if match is None:
651 return None
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