Coverage for app/routers/deployments.py: 71.13%

523 statements  

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

1import asyncio 

2import base64 

3import binascii 

4import json 

5import logging 

6import re 

7from collections.abc import AsyncIterator 

8from dataclasses import asdict 

9from uuid import UUID 

10 

11from fastapi import APIRouter, Depends, HTTPException, Request, Response, status 

12from fastapi.responses import JSONResponse, StreamingResponse 

13from sqlalchemy import desc 

14from sqlalchemy.orm import Session 

15 

16from app.crud import apps as crud_apps 

17from app.crud import deployments as crud_deployments 

18from app.crud import locks as crud_locks 

19from app.crud import openstack_credentials as crud_openstack_credentials 

20from app.crud import teams as crud_teams 

21from app.crud import users as crud_users 

22from app.database import get_db 

23from app.models import Deployment, TaskStatus, TaskType, User, UserRole 

24from app.models import Task as TaskModel # for ad-hoc state queries 

25from app.schemas import ( 

26 DeploymentCreate, 

27 DeploymentDetail, 

28 DeploymentOutputs, 

29 DeploymentResourceListResponse, 

30 DeploymentResourceSchema, 

31 DeploymentResponse, 

32 DeploymentTeamMember, 

33 DeploymentTeamResponse, 

34 MyAccessResponse, 

35 TaskSummary, 

36) 

37from app.services import deployment_notifier, email_service 

38from app.services import lifecycle as lifecycle_service 

39from app.services import task_service as task_service_module 

40from app.services.deployment_pubsub import pubsub 

41from app.services.deployment_status import ( 

42 build_resource_detail, 

43 build_resource_views, 

44) 

45from app.services.tf_state_parser import parse_tf_state 

46from app.utils.capabilities import ( 

47 can_view_deployment_owner, 

48 ensure_operate_deployment, 

49 ensure_resend_access, 

50 ensure_view_app, 

51 ensure_view_deployment_owner, 

52 get_my_course_teacher_ids, 

53) 

54from app.utils.keycloak_auth import get_current_user_keycloak 

55from app.utils.permissions import ( 

56 ensure_deployment_access, 

57 is_deployment_owner_view, 

58) 

59 

60logger = logging.getLogger(__name__) 

61router = APIRouter() 

62 

63 

64def _list_course_scope_deployments( 

65 db: Session, 

66 current_user: User, 

67 skip: int, 

68 limit: int, 

69 app_id: UUID | None, 

70 status_filter: str | None, 

71 student: UUID | None, 

72) -> list: 

73 """Resolve the ``?scope=course`` deployment listing for staff callers. 

74 

75 Lists every deployment whose owner sits in one of the requestor's 

76 taught courses. Admins use the same code path for symmetry — usually 

77 their set is empty. Returns the raw ``Deployment`` rows; the caller 

78 runs the shared enrichment loop over the result. 

79 """ 

80 my_courses = get_my_course_teacher_ids(current_user, db) 

81 if current_user.role == UserRole.ADMIN: 

82 # Admins always see everything inside the chosen scope. 

83 # We still need the course-id filter to make ``?scope=course`` 

84 # narrow the listing in some way — otherwise the param is a 

85 # no-op for admins. The implementation: pull every course 

86 # the admin is registered as course-teacher for (typically 

87 # empty), and fall back to the union with the explicit 

88 # ``student`` filter so the route still does something 

89 # useful in the common case of an admin requesting a 

90 # specific student's deployments via the profile page. 

91 pass 

92 if not my_courses and current_user.role != UserRole.ADMIN: 

93 # Teacher with no course-teacher rows — the course scope 

94 # is empty by definition. Return an empty page rather 

95 # than the teacher's own owned set, because that would 

96 # mask the absence of any teacher-course assignment. 

97 return [] 

98 

99 # Resolve the set of candidate student userIds: every user 

100 # whose ``courseId`` falls inside ``my_courses``. When the 

101 # caller also passed ``?student=<id>``, narrow to that single 

102 # user IFF they actually sit inside one of those courses. 

103 student_q = db.query(User.userId).filter( 

104 User.courseId.in_(my_courses) 

105 ) 

106 if student is not None: 

107 # ``?student=<id>``: require the student to be inside one 

108 # of the teacher's courses; otherwise we'd leak that 

109 # ``student`` exists at all to a teacher who can't see them. 

110 student_q = student_q.filter(User.userId == student) 

111 owner_ids = [row[0] for row in student_q.all()] 

112 

113 if current_user.role == UserRole.ADMIN and not owner_ids: 

114 # Admin path with empty course set + no student filter → 

115 # show the admin's own owned set instead of an empty page. 

116 if student is None: 

117 return crud_deployments.get_deployments( 

118 db, 

119 skip=skip, 

120 limit=limit, 

121 user_id=current_user.userId, 

122 app_id=app_id, 

123 status=status_filter, 

124 ) 

125 return [] 

126 if not owner_ids: 

127 # Teacher in scope mode but their courses are empty, or 

128 # the named ``student`` isn't in any of their courses — 

129 # empty page, no leak about that student's existence. 

130 return [] 

131 

132 # We can't easily widen the existing get_deployments 

133 # signature to accept an owner_id IN clause without 

134 # disturbing the other branches, so we build the query 

135 # inline here. Same soft-delete + app_id + status 

136 # semantics as the helper. 

137 q = db.query(Deployment).filter(Deployment.deleted_at.is_(None)) 

138 q = q.filter(Deployment.userId.in_(owner_ids)) 

139 if app_id: 

140 q = q.filter(Deployment.appId == app_id) 

141 # Reuse the helper's status-filter implementation by 

142 # forwarding to ``get_deployments`` with a synthetic 

143 # ``user_id`` of None and a post-filter — but the helper 

144 # short-circuits on user_id, so simplest is to inline the 

145 # ordering/pagination and skip the status filter here. A 

146 # course-teacher list view rarely needs status filtering 

147 # in the index; the per-deployment detail page handles 

148 # status-specific UX. 

149 q = q.order_by(desc(Deployment.deploymentId)) 

150 return q.offset(skip).limit(limit).all() 

151 

152 

153# ---------------------------------------------------------------- 

154# GET ALL DEPLOYMENTS 

155# ---------------------------------------------------------------- 

156@router.get("/", response_model=list[DeploymentResponse]) 

157def list_deployments( 

158 skip: int = 0, 

159 limit: int = 100, 

160 app_id: UUID | None = None, 

161 status_filter: str | None = None, 

162 scope: str | None = None, 

163 student: UUID | None = None, 

164 db: Session = Depends(get_db), 

165 current_user: User = Depends(get_current_user_keycloak) 

166): 

167 """List deployments visible to the caller. 

168 

169 Visibility rules: 

170 * **Admins**: their own owned set (default), or — with 

171 ``?scope=course`` — every deployment whose owner sits in a 

172 course the admin is a designated teacher of (rare; admins are 

173 usually not in ``course_teachers`` rows, but the path is 

174 symmetric with the teacher one for consistency). 

175 * **Teachers** (default ``scope`` omitted): deployments they 

176 created (their own owned set). Cross-user listing is 

177 intentional UX: a teacher opens an individual deployment via 

178 direct link or via a student's profile page, not from this 

179 index. 

180 * **Teachers** (``?scope=course``): every non-deleted deployment 

181 whose owner sits in a course they teach (course-teacher 

182 inspect right). Optional ``?student=<userId>`` narrows the 

183 listing to one course-member's deployments, 

184 which is what the student-profile page uses to render the 

185 deployments list of a single student under the teacher's care. 

186 * **Students** (and any non-staff role): deployments they own 

187 OR are a member of — either via a team mapping or a direct 

188 ``UserToDeployment`` row. 

189 

190 The ``scope`` parameter is accepted only for teachers and admins; 

191 students passing it get the standard student listing back (the 

192 parameter is ignored, not rejected, to keep the API forgiving 

193 against query-string-builder bugs in the frontend). 

194 """ 

195 is_staff = current_user.role in (UserRole.TEACHER, UserRole.ADMIN) 

196 use_course_scope = is_staff and scope == "course" 

197 

198 if use_course_scope: 

199 deployments = _list_course_scope_deployments( 

200 db, 

201 current_user, 

202 skip=skip, 

203 limit=limit, 

204 app_id=app_id, 

205 status_filter=status_filter, 

206 student=student, 

207 ) 

208 elif is_staff: 

209 deployments = crud_deployments.get_deployments( 

210 db, 

211 skip=skip, 

212 limit=limit, 

213 user_id=current_user.userId, 

214 app_id=app_id, 

215 status=status_filter, 

216 ) 

217 else: 

218 deployments = crud_deployments.get_deployments( 

219 db, 

220 skip=skip, 

221 limit=limit, 

222 member_user_id=current_user.userId, 

223 app_id=app_id, 

224 status=status_filter, 

225 ) 

226 

227 # Enrich with status and created_at from tasks. The summary is 

228 # bulk-fetched in two queries (latest + first task per deployment via 

229 # window functions) so the list endpoint stays at a constant query 

230 # count regardless of page size. 

231 task_summary = crud_deployments.bulk_get_task_summary( 

232 db, [d.deploymentId for d in deployments] 

233 ) 

234 

235 result = [] 

236 for deployment in deployments: 

237 # Pull the latest-task ``(status, type)`` and the first-task 

238 # timestamp out of the bulk map. Deployments without any task 

239 # yet (new row, dispatch in flight) map to ``(None, None, None)`` 

240 # — ``derive_status`` returns None for that, which the schema 

241 # accepts (``status: str | None``). 

242 latest_status, latest_type, first_created_at = task_summary.get( 

243 deployment.deploymentId, (None, None, None) 

244 ) 

245 status_value = crud_deployments.derive_status(latest_status, latest_type) 

246 

247 # Parse userInputVar JSON string back to dict if it exists. 

248 # File uploads are stripped down to metadata here so the list 

249 # view doesn't ship megabytes of base64 to the browser; the 

250 # detail endpoint follows the same rule, and the dedicated 

251 # download route is the only path that returns raw bytes. 

252 user_input_var_parsed = _parse_and_strip_user_input(deployment.userInputVar) 

253 

254 result.append(DeploymentResponse( 

255 deploymentId=deployment.deploymentId, 

256 name=deployment.name, 

257 appId=deployment.appId, 

258 userId=deployment.userId, 

259 releaseTag=deployment.releaseTag, 

260 userInputVar=user_input_var_parsed, 

261 status=status_value, 

262 created_at=first_created_at, 

263 )) 

264 

265 return result 

266 

267 

268# ---------------------------------------------------------------- 

269# GET DEPLOYMENT BY ID (Full Details) 

270# ---------------------------------------------------------------- 

271@router.get("/{deployment_id}", response_model=DeploymentDetail) 

272def get_deployment( 

273 deployment_id: UUID, 

274 include_logs: bool = False, 

275 db: Session = Depends(get_db), 

276 current_user: User = Depends(get_current_user_keycloak) 

277): 

278 """ 

279 Get deployment by ID with full details including: 

280 - User and App relations 

281 - Teams with members 

282 - Latest task status 

283 - Terraform outputs 

284 - Optionally: full logs (use include_logs=true) 

285 """ 

286 deployment = crud_deployments.get_deployment_with_details(db, deployment_id) 

287 if not deployment: 

288 raise HTTPException( 

289 status_code=status.HTTP_404_NOT_FOUND, 

290 detail="Deployment not found" 

291 ) 

292 

293 # Check access permission 

294 ensure_deployment_access(deployment, current_user, db) 

295 

296 # Get latest task 

297 latest_task = crud_deployments.get_latest_task(db, deployment_id) 

298 task_summary = None 

299 logs = None 

300 

301 if latest_task: 

302 task_summary = TaskSummary( 

303 taskId=latest_task.taskId, 

304 type=latest_task.type, 

305 status=latest_task.status, 

306 started_at=latest_task.started_at, 

307 finished_at=latest_task.finished_at, 

308 created_at=latest_task.created_at, 

309 current_phase=getattr(latest_task, "current_phase", None), 

310 progress_pct=getattr(latest_task, "progress_pct", None), 

311 ) 

312 if include_logs: 

313 logs = latest_task.logs 

314 

315 # Get teams with members. The owner view sees every team and 

316 # every member. The member view only sees their own team(s) so 

317 # they can't browse who else has access to the deployment. 

318 # ``can_view_deployment_owner`` widens "owner view" to course-teachers 

319 # of the deployment-owner's course, so they get the full roster + logs 

320 # + outputs alongside owners and admins. 

321 is_owner_view = can_view_deployment_owner(current_user, deployment, db) 

322 teams_data = crud_deployments.get_deployment_teams_with_members(db, deployment_id) 

323 if not is_owner_view: 

324 teams_data = [ 

325 t for t in teams_data 

326 if any(str(m["userId"]) == str(current_user.userId) for m in t["members"]) 

327 ] 

328 teams = [ 

329 DeploymentTeamResponse( 

330 teamId=team["teamId"], 

331 name=team["name"], 

332 members=[ 

333 DeploymentTeamMember( 

334 userId=member["userId"], 

335 email=member["email"], 

336 username=member["username"] 

337 ) 

338 for member in team["members"] 

339 ] 

340 ) 

341 for team in teams_data 

342 ] 

343 

344 # Outputs / state / logs are owner-only — members don't get to 

345 # browse the credentials of teammates or the raw infrastructure 

346 # state. They have their own resend-access action for their own 

347 # credentials. 

348 if is_owner_view: 

349 outputs_data = crud_deployments.get_deployment_outputs(db, deployment_id) 

350 outputs = DeploymentOutputs(raw=outputs_data) if outputs_data else None 

351 else: 

352 outputs = None 

353 logs = None 

354 

355 # Get status and created_at from tasks 

356 status_value = crud_deployments.get_deployment_status(db, deployment_id) 

357 created_at = crud_deployments.get_deployment_created_at(db, deployment_id) 

358 

359 # Parse userInputVar JSON string back to dict if it exists. Same 

360 # strip-file-bytes treatment as the list endpoint — base64 

361 # payloads are surfaced via the download route, not the JSON view. 

362 user_input_var_parsed = _parse_and_strip_user_input(deployment.userInputVar) 

363 

364 # ``deployment.app`` is the raw ORM relation whose ``image`` column 

365 # carries bytes. Pydantic's ``DeploymentDetail`` declares 

366 # ``app.image: Optional[str]`` (the wire shape is a ``data:image/...`` 

367 # URL), so handing it the bytes verbatim throws ``string_unicode``. 

368 # Run it through ``_serialize_app`` — the same helper the 

369 # ``/apps``-endpoints already use — to swap the bytes for the 

370 # data-URL string in place. Apps without an uploaded image are 

371 # unaffected (``getattr`` returns ``None`` and the helper no-ops). 

372 from app.routers.apps import _serialize_app # local: avoid import cycle 

373 serialised_app = _serialize_app(deployment.app) 

374 

375 return DeploymentDetail( 

376 deploymentId=deployment.deploymentId, 

377 name=deployment.name, 

378 appId=deployment.appId, 

379 userId=deployment.userId, 

380 releaseTag=deployment.releaseTag, 

381 userInputVar=user_input_var_parsed, 

382 status=status_value, 

383 created_at=created_at, 

384 user=deployment.user, 

385 app=serialised_app, 

386 teams=teams, 

387 latest_task=task_summary, 

388 outputs=outputs, 

389 logs=logs, 

390 ) 

391 

392 

393# ---------------------------------------------------------------- 

394# CREATE DEPLOYMENT 

395# ---------------------------------------------------------------- 

396 

397# Defense-in-depth limits for inline file uploads. The UX-side warning 

398# is mirrored on the wizard, but a hand-crafted POST could still try 

399# to push GBs of payload through ``userInputVar``. We refuse before 

400# the row hits the DB. 

401# 

402# Per-file cap matches the existing app-image cap so users don't have 

403# to learn a second number; deployment-wide cap is 5× that, leaving 

404# headroom for (e.g.) one big assignment plus several small starter 

405# files. Both are enforced post-base64-decode so a malicious base64 

406# blob of right-shape but wrong-size still fails fast. 

407_MAX_FILE_BYTES_PER_FILE = 2 * 1024 * 1024 

408_MAX_FILE_BYTES_PER_DEPLOYMENT = 10 * 1024 * 1024 

409 

410 

411def _attach_files_to_user_input( 

412 user_input_var: dict | None, 

413 files: dict | None, 

414 variable_definitions: list[dict] | None = None, 

415) -> dict: 

416 """Validate and merge wizard-uploaded files into ``userInputVar``. 

417 

418 The wizard ships files in a parallel ``files`` field instead of 

419 nesting them straight into ``userInputVar.terraform`` so the 

420 request payload's shape is obvious to a reader and so we can 

421 apply size / encoding validation in one place. Result is a fresh 

422 dict with the files folded into ``terraform[var_name]`` — the 

423 worker doesn't need to know they originally came from a separate 

424 field. 

425 

426 Validation: 

427 * each top-level key in ``files`` becomes one terraform variable 

428 * each inner-map entry is one ``DeploymentFileUpload`` record 

429 * ``content_b64`` decodes cleanly (RFC 4648, padding optional) 

430 * decoded size matches the declared ``size`` (within rounding — 

431 client may have set it before encoding so we accept ±1) 

432 * per-file cap and total deployment cap 

433 * if ``variable_definitions`` are provided and a file variable 

434 declares ``fileExtensions``, each uploaded filename's suffix 

435 (lowercased, after the last dot) must be in the allowed list. 

436 Defense-in-depth: the wizard's ``accept`` attribute already 

437 filters in the picker, but a hand-crafted POST could bypass it. 

438 

439 Raises ``HTTPException(413)`` for size violations and 

440 ``HTTPException(422)`` for malformed payload — Pydantic already 

441 rejected the obvious cases (missing fields, wrong types) before 

442 we get here, so we only catch what gets past it. 

443 """ 

444 base = dict(user_input_var or {}) 

445 base.setdefault("terraform", {}) 

446 base.setdefault("packer", {}) 

447 

448 if not files: 

449 return base 

450 

451 # Build an index var_name → allowed_extensions for the extension 

452 # check below. Variables without ``fileExtensions`` skip the 

453 # filter — keeps backward compatibility for any caller that doesn't 

454 # supply ``variable_definitions``. 

455 allowed_exts_by_var: dict[str, list[str]] = {} 

456 scoped_file_vars: set[str] = set() 

457 if variable_definitions: 

458 for vdef in variable_definitions: 

459 exts = vdef.get("fileExtensions") 

460 if exts: 

461 allowed_exts_by_var[vdef["name"]] = [e.lower() for e in exts] 

462 if vdef.get("varScope") in ("team", "user"): 

463 scoped_file_vars.add(vdef["name"]) 

464 

465 total_bytes = 0 

466 terraform_block = dict(base.get("terraform") or {}) 

467 

468 for var_name, slot_map in files.items(): 

469 if var_name in terraform_block: 

470 # Wizard already routed something into this variable — a 

471 # collision means the frontend filled both the variables 

472 # picker AND the file uploader for the same name. That's an 

473 # unrecoverable contract violation; surface it clearly. 

474 raise HTTPException( 

475 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

476 detail={ 

477 "reason": "file_var_collision", 

478 "variable": var_name, 

479 }, 

480 ) 

481 if not isinstance(slot_map, dict) or not slot_map: 

482 raise HTTPException( 

483 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

484 detail={"reason": "file_var_empty", "variable": var_name}, 

485 ) 

486 

487 encoded_slots: dict[str, dict] = {} 

488 for slot_key, upload in slot_map.items(): 

489 # ``upload`` arrives here as a Pydantic model instance 

490 # already (FastAPI deserialised the request body into 

491 # ``DeploymentCreate``) — pull fields off attributes. 

492 content_b64 = upload.content_b64 

493 

494 # Extension-filter check — only when the app author declared 

495 # an ``@openstack:file:<scope>:<exts>`` filter. We compare 

496 # the filename suffix (after the last dot, lowercased) to 

497 # the allowed list. Missing dot or unknown suffix → 422. 

498 allowed_exts = allowed_exts_by_var.get(var_name) 

499 if allowed_exts is not None: 

500 name = upload.name or "" 

501 dot = name.rfind(".") 

502 suffix = name[dot + 1 :].lower() if dot >= 0 else "" 

503 if suffix not in allowed_exts: 

504 raise HTTPException( 

505 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

506 detail={ 

507 "reason": "file_extension_rejected", 

508 "variable": var_name, 

509 "slot": slot_key, 

510 "filename": upload.name, 

511 "allowed": allowed_exts, 

512 }, 

513 ) 

514 try: 

515 # ``validate=True`` would reject any non-base64 

516 # whitespace; the wizard sends compact base64 so this 

517 # is fine. 

518 decoded = base64.b64decode(content_b64, validate=True) 

519 except (binascii.Error, ValueError) as e: 

520 raise HTTPException( 

521 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

522 detail={ 

523 "reason": "file_b64_invalid", 

524 "variable": var_name, 

525 "slot": slot_key, 

526 "error": str(e), 

527 }, 

528 ) 

529 

530 if abs(len(decoded) - upload.size) > 1: 

531 raise HTTPException( 

532 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

533 detail={ 

534 "reason": "file_size_mismatch", 

535 "variable": var_name, 

536 "slot": slot_key, 

537 "declared": upload.size, 

538 "actual": len(decoded), 

539 }, 

540 ) 

541 

542 if len(decoded) > _MAX_FILE_BYTES_PER_FILE: 

543 raise HTTPException( 

544 status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, 

545 detail={ 

546 "reason": "file_too_large", 

547 "variable": var_name, 

548 "slot": slot_key, 

549 "limit_bytes": _MAX_FILE_BYTES_PER_FILE, 

550 "actual_bytes": len(decoded), 

551 }, 

552 ) 

553 total_bytes += len(decoded) 

554 if total_bytes > _MAX_FILE_BYTES_PER_DEPLOYMENT: 

555 raise HTTPException( 

556 status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, 

557 detail={ 

558 "reason": "deployment_files_too_large", 

559 "limit_bytes": _MAX_FILE_BYTES_PER_DEPLOYMENT, 

560 }, 

561 ) 

562 

563 encoded_slots[slot_key] = { 

564 "name": upload.name, 

565 "content_b64": content_b64, 

566 "size": upload.size, 

567 "content_type": upload.content_type or "application/octet-stream", 

568 } 

569 # scope=team|user: HCL type is map(map(object({...}))) — 

570 # outer key is the team/user slot, inner key is the upload slot. 

571 # scope=all: HCL type is map(object({...})) — flat map. 

572 if var_name in scoped_file_vars: 

573 terraform_block[var_name] = { 

574 slot_key: {"uploaded": file_obj} 

575 for slot_key, file_obj in encoded_slots.items() 

576 } 

577 else: 

578 terraform_block[var_name] = encoded_slots 

579 

580 base["terraform"] = terraform_block 

581 return base 

582 

583 

584def _validate_scoped_user_input( 

585 user_input_var: dict | None, 

586 variable_definitions: list[dict], 

587 teams_payload: list, 

588) -> None: 

589 """Enforce that variables marked with ``varScope = team|user`` 

590 arrive as a map whose keys match the deployment's team / user roster. 

591 

592 Reasoning: the wizard packs scoped variables as a Map 

593 (``{slot_key: value, ...}``) and ships them via ``userInputVar``. 

594 A hand-crafted POST could ship arbitrary keys; we want unknown 

595 Scope-Targets to fail fast and loud before they hit Terraform, 

596 where the error would be a confusing "module: invalid for_each 

597 key" deep in the worker log. 

598 

599 File variables are NOT skipped here — they share the same scoped 

600 map shape (``{slot_key: file_obj}``) and a hand-crafted POST could 

601 just as easily smuggle an unknown team name into a file-scope 

602 variable. We validate slot identity against the same roster; the 

603 per-file size / base64 / extension validation stays in 

604 :func:`_attach_files_to_user_input` because that's the layer that 

605 actually decodes the bytes. 

606 

607 Raises ``HTTPException(422)`` with ``reason="unknown_scope_target"``, 

608 ``reason="scoped_var_not_map"``, or ``reason="required_slot_empty"`` 

609 for shape/identity/completeness problems. 

610 """ 

611 if not user_input_var: 

612 return 

613 

614 # Compose the universe of valid slot keys per scope. ``team`` 

615 # accepts any team name; ``user`` accepts ``TeamName-Username`` 

616 # composites — mirror of ``userSlotKey`` in the wizard. 

617 team_names: set[str] = set() 

618 for team in teams_payload or []: 

619 team_name = getattr(team, "name", None) or (team.get("name") if isinstance(team, dict) else None) 

620 if not team_name: 

621 continue 

622 team_names.add(team_name) 

623 # ``team.userIds`` contains UUID strings here, not usernames — 

624 # the deployment endpoint resolves usernames just below us 

625 # when assembling ``teams_dict``. We accept any non-empty 

626 # composite key prefix-matching ``f"{team_name}-"`` for 

627 # user-scoped variables, because the wizard renders one slot 

628 # per member and labels it with the username (not the UUID). 

629 # A stricter check would require an extra DB round-trip; the 

630 # prefix-and-non-empty check is enough to catch typos and 

631 # cross-team key smuggling. 

632 

633 # Longest-prefix-match helper for user-scope composite keys: 

634 # ``TeamName-Username``. A naive ``slot_key.find('-')`` would 

635 # truncate a team named ``Team-A`` to just ``Team``, so any team 

636 # name containing a dash would be misclassified as unknown. We 

637 # iterate the known team names from longest to shortest and pick 

638 # the first one that either equals ``slot_key`` (empty username, 

639 # rejected below) or prefixes it as ``f"{team}-"``. 

640 teams_by_length = sorted(team_names, key=len, reverse=True) 

641 

642 def _user_slot_team_prefix(slot_key: str) -> str | None: 

643 for team in teams_by_length: 

644 if slot_key == team: 

645 # No trailing ``-Username`` — caller treats this as a 

646 # missing-user-segment and surfaces ``unknown_scope_target``. 

647 return team 

648 if slot_key.startswith(team + "-"): 

649 return team 

650 return None 

651 

652 def _is_empty_slot_value(val) -> bool: 

653 """Treat None, empty string, empty list, and empty dict as 

654 "slot not filled". The wizard would otherwise let a required 

655 team/user-scoped var slip through with one team left blank, 

656 which Terraform would catch with a much less actionable 

657 ``Inappropriate value for attribute`` deep in the worker log. 

658 """ 

659 if val is None: 

660 return True 

661 if isinstance(val, str) and val == "": 

662 return True 

663 return isinstance(val, (list, dict)) and len(val) == 0 

664 

665 for source_key in ("terraform", "packer"): 

666 block = user_input_var.get(source_key) 

667 if not isinstance(block, dict): 

668 continue 

669 for vdef in variable_definitions: 

670 if vdef.get("source") != source_key: 

671 continue 

672 scope = vdef.get("varScope") 

673 if scope not in ("team", "user"): 

674 continue 

675 var_name = vdef["name"] 

676 value = block.get(var_name) 

677 is_file = vdef.get("osType") == "file" 

678 required = bool(vdef.get("required")) 

679 if value is None: 

680 # File-scope vars MUST be present — the wizard always 

681 # ships at least an empty map for them, so a None here 

682 # is a hand-crafted-POST shape. For non-file required 

683 # scoped vars, raise on the slot-completeness check 

684 # below by treating the absent value as an empty map. 

685 if required: 

686 value = {} 

687 else: 

688 continue # variable left at HCL default — allowed 

689 if not isinstance(value, dict): 

690 raise HTTPException( 

691 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

692 detail={ 

693 "reason": "scoped_var_not_map", 

694 "variable": var_name, 

695 "scope": scope, 

696 }, 

697 ) 

698 for slot_key in value: 

699 if scope == "team": 

700 if slot_key not in team_names: 

701 raise HTTPException( 

702 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

703 detail={ 

704 "reason": "unknown_scope_target", 

705 "variable": var_name, 

706 "scope": scope, 

707 "slot": slot_key, 

708 "allowed": sorted(team_names), 

709 }, 

710 ) 

711 else: # user scope 

712 # Longest-prefix-match against known team names so 

713 # a team named ``Team-A`` parses to prefix 

714 # ``Team-A`` and rest ``Username`` instead of 

715 # prefix ``Team`` (which wouldn't be a known team). 

716 prefix = _user_slot_team_prefix(slot_key) 

717 if prefix is None or slot_key == prefix: 

718 raise HTTPException( 

719 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

720 detail={ 

721 "reason": "unknown_scope_target", 

722 "variable": var_name, 

723 "scope": scope, 

724 "slot": slot_key, 

725 "hint": "expected ``TeamName-Username``", 

726 }, 

727 ) 

728 

729 # Required slot-completeness check: for required team / 

730 # user scoped variables every expected slot key must carry 

731 # a non-empty value. Without this an empty map (or one 

732 # team left blank) would silently pass here and only fail 

733 # downstream with an opaque Terraform error. 

734 # 

735 # File vars are skipped from the completeness sweep — the 

736 # per-file size/decode validation in 

737 # :func:`_attach_files_to_user_input` raises a more specific 

738 # error (file_var_empty / file_b64_invalid) for them. We 

739 # only checked slot identity above; the bytes themselves 

740 # are validated at that layer. 

741 if required and not is_file: 

742 expected_slots: set[str] = set() 

743 if scope == "team": 

744 expected_slots = set(team_names) 

745 # For ``user`` scope we don't have the per-team member 

746 # roster here (would need a DB round-trip we already 

747 # avoid above), so we only enforce that each slot the 

748 # caller did ship carries a non-empty value. The 

749 # wizard's frontend check is the primary guard; this 

750 # is defense-in-depth against hand-crafted POSTs that 

751 # ship one half-filled team. A POST that omits a team 

752 # entirely for a required user-scope var is caught by 

753 # the team-scope branch via team_names because the 

754 # wizard always emits at least one slot per team. 

755 

756 missing: list[str] = [] 

757 for slot in expected_slots: 

758 if _is_empty_slot_value(value.get(slot)): 

759 missing.append(slot) 

760 # Also flag empty values among slots the caller did 

761 # provide — covers user-scope and any partial-fill case. 

762 for slot, val in value.items(): 

763 if _is_empty_slot_value(val) and slot not in missing: 

764 missing.append(slot) 

765 if missing: 

766 raise HTTPException( 

767 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

768 detail={ 

769 "reason": "required_slot_empty", 

770 "variable": var_name, 

771 "scope": scope, 

772 "missing_slots": sorted(missing), 

773 }, 

774 ) 

775 

776 

777def _strip_file_vars_from_user_input(user_input_var: dict | None) -> dict | None: 

778 """Strip per-file ``content_b64`` payloads from a userInputVar dict. 

779 

780 Used by the deployment detail responses so the JSON the frontend 

781 receives only carries metadata (name/size/content_type) — the 

782 decoded bytes can be many MBs each and shipping them on every 

783 page render is wasteful. Owners who actually want the file fetch 

784 it via the dedicated download endpoint. 

785 

786 Heuristic-based: a variable is a file slot when its value is a 

787 mapping whose entries each carry a ``content_b64`` field — the 

788 same shape ``_attach_files_to_user_input`` writes. We match on 

789 that key because no other user-input kind uses it. 

790 """ 

791 if not isinstance(user_input_var, dict): 

792 return user_input_var 

793 

794 out = {k: v for k, v in user_input_var.items() if k != "terraform"} 

795 tf_block = user_input_var.get("terraform") 

796 if not isinstance(tf_block, dict): 

797 if "terraform" in user_input_var: 

798 out["terraform"] = tf_block 

799 return out 

800 

801 stripped_tf: dict = {} 

802 for var_name, value in tf_block.items(): 

803 if _looks_like_file_var(value): 

804 stripped_tf[var_name] = _file_var_metadata_only(value) 

805 else: 

806 stripped_tf[var_name] = value 

807 out["terraform"] = stripped_tf 

808 return out 

809 

810 

811def _parse_and_strip_user_input(raw: str | None) -> dict | None: 

812 """Parse a stored ``userInputVar`` JSON string and strip file bytes. 

813 

814 Wraps the ``json.loads`` → :func:`_strip_file_vars_from_user_input` 

815 chain (with a malformed-JSON guard) shared by the list, detail, and 

816 create responses so all three surface the same file-stripped shape. 

817 Returns ``None`` for an empty or unparseable value. 

818 """ 

819 if not raw: 

820 return None 

821 try: 

822 return _strip_file_vars_from_user_input(json.loads(raw)) 

823 except json.JSONDecodeError: 

824 return None 

825 

826 

827def _looks_like_file_var(value) -> bool: 

828 """True if ``value`` matches the file-upload shape produced by 

829 :func:`_attach_files_to_user_input`: a non-empty mapping whose 

830 values are objects carrying ``content_b64`` plus the metadata 

831 triplet. Used at response-shaping time to identify file-typed 

832 variables without consulting the app's variable schema, AND at 

833 lifecycle-dispatch time (destroy/pause/resume/redeploy) to drop 

834 file vars from the worker's var-set so Terraform's schema 

835 validation doesn't trip on a payload it doesn't need. 

836 

837 Shape examples it matches (and only these): 

838 

839 * ``scope=all`` → ``{"all": {name, content_b64, size, content_type}}`` 

840 * ``scope=team`` → ``{"Team-1": {...}, "Team-2": {...}}`` 

841 * ``scope=user`` → ``{"Team-1-luca": {...}, ...}`` 

842 

843 Strict signature: each slot must carry ``content_b64``. Rows 

844 that survived an earlier response-side-strip-then-persisted 

845 accident (metadata triplet only, no bytes) are NOT auto- 

846 detected — clean them up by hand (delete the deployment row + 

847 its pg-backend tfstate schema). The strictness is intentional: 

848 a lenient detector would silently swallow legitimate non-file 

849 map variables that coincidentally share the metadata key names. 

850 """ 

851 if not isinstance(value, dict) or not value: 

852 return False 

853 for slot in value.values(): 

854 if not isinstance(slot, dict): 

855 return False 

856 if "content_b64" not in slot: 

857 return False 

858 return True 

859 

860 

861def _file_var_metadata_only(value: dict) -> dict: 

862 """Return a copy of a file-shape variable with the ``content_b64`` 

863 payload stripped. Metadata fields (name, size, content_type) 

864 survive so the UI can list "what was uploaded" without shipping 

865 base64 megabytes on every detail-view render. 

866 """ 

867 out: dict = {} 

868 for slot_key, slot in value.items(): 

869 out[slot_key] = {k: v for k, v in slot.items() if k != "content_b64"} 

870 return out 

871 

872 

873# ---------------------------------------------------------------- 

874# CREATE DEPLOYMENT 

875# ---------------------------------------------------------------- 

876@router.post("/", response_model=DeploymentResponse, status_code=status.HTTP_201_CREATED) 

877def create_deployment( 

878 deployment: DeploymentCreate, 

879 db: Session = Depends(get_db), 

880 current_user: User = Depends(get_current_user_keycloak) 

881): 

882 """ 

883 Create a new deployment 

884 

885 Atomicity: a per-user advisory lock serializes credential mutation 

886 with deployment dispatch. The deployment row, teams, user mappings, 

887 and the initial PENDING task row are all inserted in a single 

888 transaction, so the user can never end up with a deployment row 

889 that has no matching task. Celery dispatch happens AFTER commit; 

890 if it fails, the task row is flipped to FAILED so the deployment 

891 surfaces an honest error instead of hanging in PENDING forever. 

892 """ 

893 # Per-user lock — serializes against PUT /me/openstack-credentials 

894 # and any other concurrent POST /deployments from this user. Held 

895 # until the next COMMIT/ROLLBACK on this connection. 

896 crud_locks.acquire_user_xact_lock(db, current_user.userId) 

897 

898 # Refuse the create if the target app is soft-deleted. 

899 target_app = crud_apps.get_app(db, deployment.appId) 

900 if target_app is None: 

901 raise HTTPException( 

902 status_code=status.HTTP_404_NOT_FOUND, 

903 detail={"reason": "app_not_found_or_deleted"}, 

904 ) 

905 

906 # Gate the create on the same visibility rule the list/detail 

907 # endpoints use. A student cannot deploy a private app they don't 

908 # own, and a non-owner cannot deploy an app without an approved 

909 # version. The owner / admin path stays open. 

910 # ``ensure_view_app`` raises 403 with the structured payload, so 

911 # the frontend receives the same shape it sees on the detail 

912 # endpoint when visibility is denied. 

913 ensure_view_app(current_user, target_app, db=db) 

914 

915 # Load the app author's variable declarations so we can enforce 

916 # per-variable contracts (``varScope``, ``fileExtensions``) below. 

917 # We only do this when the request actually carries variables / 

918 # files — for a no-input deploy the round-trip into Git would be 

919 # waste. Same parser as ``GET /apps/{id}/variables`` so client and 

920 # server agree on which variable is scoped/file/free-text. 

921 variable_definitions: list[dict] = [] 

922 if deployment.userInputVar or deployment.files: 

923 from app.routers.apps import load_variable_definitions 

924 release_tag = deployment.releaseTag or "main" 

925 try: 

926 variable_definitions = load_variable_definitions(target_app, release_tag) 

927 except HTTPException as exc: 

928 if exc.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: 

929 raise 

930 # 400 (no git_link) or 500 (git unreachable) — skip validation, 

931 # proceed without variable definitions. 

932 

933 # Fold the wizard's parallel ``files`` upload into 

934 # ``userInputVar.terraform`` before the row gets persisted, so the 

935 # rest of this handler — and the worker downstream — sees one 

936 # uniform dict. The helper validates base64 / size / per-file and 

937 # per-deployment caps; any failure short-circuits with a 4xx and 

938 # the row never enters the DB. When variable definitions are 

939 # available, the helper also enforces the app author's declared 

940 # ``fileExtensions`` filter on each upload name. 

941 deployment.userInputVar = _attach_files_to_user_input( 

942 deployment.userInputVar, deployment.files, variable_definitions or None, 

943 ) 

944 

945 # Enforce ``varScope = team|user`` contracts: each value must be a 

946 # map whose keys match the deployment's team / user roster. This 

947 # is defense-in-depth — the wizard already only renders slots the 

948 # user can fill, but a hand-crafted POST could ship unknown keys. 

949 if variable_definitions: 

950 _validate_scoped_user_input( 

951 deployment.userInputVar, 

952 variable_definitions, 

953 deployment.teams or [], 

954 ) 

955 

956 db_deployment = crud_deployments.create_deployment( 

957 db, deployment, current_user.userId 

958 ) 

959 

960 user_ids_in_deployment = set() 

961 if deployment.teams: 

962 teams_data = [ 

963 {"name": team.name, "userIds": team.userIds} 

964 for team in deployment.teams 

965 ] 

966 crud_teams.create_teams_for_deployment( 

967 db=db, 

968 deployment_id=db_deployment.deploymentId, 

969 teams_data=teams_data, 

970 ) 

971 for team in deployment.teams: 

972 user_ids_in_deployment.update(team.userIds) 

973 

974 if user_ids_in_deployment: 

975 crud_deployments.create_user_to_deployments( 

976 db=db, 

977 deployment_id=db_deployment.deploymentId, 

978 user_ids=user_ids_in_deployment, 

979 ) 

980 

981 # Parse user input variables 

982 try: 

983 user_vars = ( 

984 json.loads(db_deployment.userInputVar) if db_deployment.userInputVar else {} 

985 ) 

986 except Exception: 

987 user_vars = {} 

988 

989 # Format teams for Terraform (team_name: [user_emails]) 

990 teams_dict = {} 

991 if deployment.teams: 

992 for team in deployment.teams: 

993 team_users = [] 

994 for user_id in team.userIds: 

995 user = crud_users.get_user(db, user_id) 

996 if user: 

997 team_users.append({"email": user.email}) 

998 teams_dict[team.name] = team_users 

999 

1000 # Per-user OpenStack credentials are required to deploy. The envelope 

1001 # carries ciphertext only — the worker decrypts in-process. Reading 

1002 # this inside the locked TX guarantees the envelope matches whatever 

1003 # credential row a concurrent PUT might have committed: PUT is 

1004 # serialized behind us by the same advisory lock. 

1005 openstack_envelope = _fetch_dispatch_envelope( 

1006 db, current_user.userId, rollback_on_missing=True 

1007 ) 

1008 

1009 # Insert PENDING task row in the SAME transaction as the deployment, 

1010 # commit atomically (deployment + teams + user_to_deployments + task), 

1011 # then dispatch to Celery outside the locked TX. On a send failure the 

1012 # task row is flipped to FAILED and we surface 503 — the deployment 

1013 # row stays, but the user sees an obvious failure instead of an 

1014 # eternal PENDING. 

1015 _commit_and_dispatch( 

1016 db, 

1017 deployment_id=db_deployment.deploymentId, 

1018 task_type=TaskType.DEPLOY, 

1019 celery_task_name="tasks.deploy_application", 

1020 celery_args=[ 

1021 str(db_deployment.deploymentId), 

1022 str(db_deployment.appId), 

1023 db_deployment.app.git_link, 

1024 db_deployment.releaseTag, 

1025 user_vars, 

1026 teams_dict, 

1027 openstack_envelope, 

1028 ], 

1029 dispatch_error_label="Could not dispatch deployment task — please retry", 

1030 rollback_on_conflict=True, 

1031 ) 

1032 

1033 db.refresh(db_deployment) 

1034 

1035 status_value = crud_deployments.get_deployment_status(db, db_deployment.deploymentId) 

1036 created_at = crud_deployments.get_deployment_created_at(db, db_deployment.deploymentId) 

1037 

1038 # Same file-strip rule as the list/detail endpoints: the POST 

1039 # response shape mirrors the read shape so the frontend can reuse 

1040 # the same parsing code-path. 

1041 user_input_var_parsed = _parse_and_strip_user_input(db_deployment.userInputVar) 

1042 

1043 return DeploymentResponse( 

1044 deploymentId=db_deployment.deploymentId, 

1045 name=db_deployment.name, 

1046 appId=db_deployment.appId, 

1047 userId=db_deployment.userId, 

1048 releaseTag=db_deployment.releaseTag, 

1049 userInputVar=user_input_var_parsed, 

1050 status=status_value, 

1051 created_at=created_at, 

1052 ) 

1053 

1054 

1055# ---------------------------------------------------------------- 

1056# DELETE DEPLOYMENT 

1057# ---------------------------------------------------------------- 

1058# 

1059# One endpoint, two outcomes — the backend picks the right one from 

1060# the deployment's status: 

1061# 

1062# * ``success`` / ``failed`` / ``paused`` → dispatch a Destroy task 

1063# (terraform destroy + auto-soft-delete on success). ``paused`` is 

1064# in the destroy set because SHUTOFF instances + volumes/networks 

1065# are still OpenStack resources that need to be reclaimed. 

1066# Returns 202 + task_id; the frontend keeps the live stream open 

1067# and routes back to the list when the task finishes. 

1068# * ``cancelled`` → soft-delete immediately. Returns 204. 

1069# * any other status (running / pending / destroying / pausing / resuming) 

1070# → 409, the user has to wait. 

1071# 

1072# Frontend doesn't have to know the difference — it just calls DELETE 

1073# and switches into the live-stream view when the response is 202. 

1074@router.delete("/{deployment_id}") 

1075def delete_deployment( 

1076 deployment_id: UUID, 

1077 db: Session = Depends(get_db), 

1078 current_user: User = Depends(get_current_user_keycloak), 

1079): 

1080 """Unified delete — destroys OpenStack resources first if needed. 

1081 

1082 Restricted to the owner-view (creator, teacher, admin). Members 

1083 can read-access the deployment but never tear it down. 

1084 """ 

1085 deployment = crud_deployments.get_deployment_with_details(db, deployment_id) 

1086 if not deployment: 

1087 raise HTTPException( 

1088 status_code=status.HTTP_404_NOT_FOUND, 

1089 detail="Deployment not found", 

1090 ) 

1091 

1092 # Destructive operation — uses the operate gate, which is 

1093 # owner-or-admin only. Course-teachers explicitly do NOT get 

1094 # delete/destroy rights on deployments in their courses; they only 

1095 # get inspect (logs, infra). 

1096 ensure_operate_deployment(current_user, deployment, db) 

1097 

1098 # Per-deployment advisory lock — serialises against any concurrent 

1099 # POST /pause, /resume or DELETE on the same deployment so the 

1100 # ``current_status`` read below and the eventual 

1101 # ``prepare_task_in_tx`` insert see a consistent picture. Without 

1102 # this, two concurrent destroys could both pass the in-flight check 

1103 # and one would crash on the partial unique index. 

1104 crud_locks.acquire_deployment_xact_lock(db, deployment_id) 

1105 

1106 current_status = crud_deployments.get_deployment_status(db, deployment_id) 

1107 

1108 # Active task in flight — neither path is safe. 

1109 if current_status in lifecycle_service.IN_FLIGHT_STATUSES: 

1110 raise HTTPException( 

1111 status_code=status.HTTP_409_CONFLICT, 

1112 detail=( 

1113 f"Cannot delete a deployment in status '{current_status}'. " 

1114 "Wait for the active task to finish." 

1115 ), 

1116 ) 

1117 

1118 # Resources may exist — destroy them first; the listener will 

1119 # auto-soft-delete the row when the destroy task succeeds. ``paused`` 

1120 # also lands here: SHUTOFF instances + volumes/networks are still 

1121 # OpenStack resources that need to be torn down before the row can 

1122 # be hidden. ``pause_failed`` / ``resume_failed`` likewise still 

1123 # have running OpenStack resources behind them — the deployment 

1124 # itself didn't break, only the lifecycle pass. 

1125 if current_status in ("success", "failed", "paused", "pause_failed", "resume_failed"): 

1126 return _dispatch_destroy(db, deployment, current_user) 

1127 

1128 # No resources to clean up (cancelled, or anything else terminal): 

1129 # straight soft-delete. 

1130 success = crud_deployments.soft_delete_deployment(db, deployment_id) 

1131 if not success: 

1132 raise HTTPException( 

1133 status_code=status.HTTP_404_NOT_FOUND, 

1134 detail="Deployment not found", 

1135 ) 

1136 return Response(status_code=status.HTTP_204_NO_CONTENT) 

1137 

1138 

1139def _fetch_dispatch_envelope(db: Session, user_id, *, rollback_on_missing: bool): 

1140 """Fetch the encrypted OpenStack credential envelope for a dispatch. 

1141 

1142 Raises ``HTTPException(412, openstack_credentials_missing)`` when the 

1143 user has no credentials. ``rollback_on_missing`` is used by the 

1144 create path, which holds an uncommitted deployment insert that must 

1145 be rolled back before the 412 escapes; lifecycle callers have no such 

1146 pending insert and pass ``False``. 

1147 """ 

1148 try: 

1149 return crud_openstack_credentials.get_dispatch_envelope(db, user_id) 

1150 except crud_openstack_credentials.NoCredentialError: 

1151 if rollback_on_missing: 

1152 db.rollback() 

1153 raise HTTPException( 

1154 status_code=status.HTTP_412_PRECONDITION_FAILED, 

1155 detail={"reason": "openstack_credentials_missing"}, 

1156 ) 

1157 

1158 

1159def _commit_and_dispatch( 

1160 db: Session, 

1161 *, 

1162 deployment_id, 

1163 task_type: TaskType, 

1164 celery_task_name: str, 

1165 celery_args: list, 

1166 dispatch_error_label: str, 

1167 rollback_on_conflict: bool, 

1168): 

1169 """Insert the PENDING task in-TX, commit, then dispatch to Celery. 

1170 

1171 Shared tail of the create and lifecycle dispatch paths: 

1172 

1173 * ``prepare_task_in_tx`` → ``HTTPException(409)`` on an active task 

1174 (the create path additionally rolls back its pending insert); 

1175 * ``commit`` + ``refresh`` the task row; 

1176 * ``dispatch_to_celery`` → ``HTTPException(503, dispatch_error_label)`` 

1177 on a Celery send failure (the task row is flipped to FAILED inside 

1178 ``dispatch_to_celery`` in a fresh TX). 

1179 

1180 Returns the committed, dispatched ``Task``. 

1181 """ 

1182 try: 

1183 task = task_service_module.prepare_task_in_tx( 

1184 db, 

1185 deployment_id=deployment_id, 

1186 task_type=task_type, 

1187 ) 

1188 except task_service_module.ActiveTaskExistsError: 

1189 if rollback_on_conflict: 

1190 db.rollback() 

1191 raise HTTPException( 

1192 status_code=status.HTTP_409_CONFLICT, 

1193 detail="Deployment already has an active task", 

1194 ) 

1195 

1196 db.commit() 

1197 db.refresh(task) 

1198 

1199 try: 

1200 task, _celery_id = task_service_module.dispatch_to_celery( 

1201 db, 

1202 task=task, 

1203 celery_task_name=celery_task_name, 

1204 celery_args=celery_args, 

1205 ) 

1206 except Exception: 

1207 raise HTTPException( 

1208 status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 

1209 detail=dispatch_error_label, 

1210 ) 

1211 return task 

1212 

1213 

1214def _dispatch_destroy(db: Session, deployment, current_user: User): 

1215 """Enqueue the destroy worker task for a deployment. 

1216 

1217 Thin wrapper around :func:`_dispatch_lifecycle_task` so DELETE can 

1218 keep its existing two-call sites (success-path / failed-path) clear. 

1219 """ 

1220 return _dispatch_lifecycle_task( 

1221 db, 

1222 deployment, 

1223 current_user, 

1224 task_type=TaskType.DESTROY, 

1225 celery_task_name="tasks.destroy_deployment", 

1226 response_status="destroying", 

1227 ) 

1228 

1229 

1230def _dispatch_lifecycle_task( 

1231 db: Session, 

1232 deployment, 

1233 current_user: User, 

1234 task_type: TaskType, 

1235 celery_task_name: str, 

1236 response_status: str, 

1237 extra_args: list | None = None, 

1238): 

1239 """Enqueue any post-deploy lifecycle worker task for a deployment. 

1240 

1241 Used by destroy, pause, resume, and per-VM redeploy — all four 

1242 follow the same pattern: load user inputs, gather team membership, 

1243 fetch the encrypted OpenStack envelope, atomically insert a 

1244 PENDING task row, commit, then ``send_task`` outside the locked 

1245 TX. On a Celery send failure the task row flips to FAILED in a 

1246 fresh TX (handled inside ``dispatch_to_celery``) and we surface a 

1247 503 so the user sees an obvious failure instead of a permanent 

1248 in-flight status. 

1249 

1250 Args: 

1251 task_type: the ``TaskType`` enum value that drives both 

1252 the task row's ``type`` column and the 

1253 status the partial-unique index prevents 

1254 from coexisting. 

1255 celery_task_name: name registered on the worker side (e.g. 

1256 ``tasks.pause_deployment``). 

1257 response_status: synthetic deployment status returned to the 

1258 frontend in the 202 body — frontend uses 

1259 this to immediately switch the UI into the 

1260 live-stream view without re-fetching. 

1261 extra_args: additional positional args appended to the 

1262 Celery payload after the standard seven. 

1263 Used by ``tasks.redeploy_resource`` to pass 

1264 the targeted resource address. 

1265 """ 

1266 try: 

1267 user_vars = json.loads(deployment.userInputVar) if deployment.userInputVar else {} 

1268 except Exception: 

1269 user_vars = {} 

1270 

1271 # Belt + braces: for lifecycle tasks that do NOT recreate the VM 

1272 # (destroy, pause, resume), strip any ``@openstack:file:*`` payloads 

1273 # from the user-vars BEFORE they reach the worker. Files are only 

1274 # consumed at apply-time by cloud-init's write_files; everything 

1275 # else just hands the same var-set to Terraform which then 

1276 # validates the entire variable surface against the HCL schema. 

1277 # A row whose ``content_b64`` was stripped by a response-side 

1278 # ``_strip_file_vars_from_user_input`` pass (e.g. after a manual 

1279 # DB edit, an in-place row shrink, or any future code path that 

1280 # rewrites the persisted JSON) would otherwise crash destroy with 

1281 # ``element "all": attributes "content_b64", "content_type", 

1282 # "name", and "size" are required`` because the surviving slot 

1283 # violates the variable's object type. Dropping the var 

1284 # altogether lets Terraform fall back on the HCL default. 

1285 # 

1286 # REDEPLOY is the special case: ``terraform apply -replace`` destroys 

1287 # and recreates the VM, so cloud-init runs fresh and MUST receive the 

1288 # original ``write_files`` payload — otherwise the replaced VM ends 

1289 # up empty even though the user/group/password config is preserved. 

1290 # We therefore keep the file vars on REDEPLOY and let the persisted 

1291 # base64 payload flow through to the worker, mirroring the initial 

1292 # deploy path. 

1293 # 

1294 # DEPLOY/UPDATE legitimately need the file bytes too — they don't 

1295 # enter the worker via this helper. 

1296 if task_type in (TaskType.DESTROY, TaskType.PAUSE, TaskType.RESUME): 

1297 terraform_block = user_vars.get("terraform") 

1298 if isinstance(terraform_block, dict): 

1299 user_vars = { 

1300 **user_vars, 

1301 "terraform": { 

1302 k: v 

1303 for k, v in terraform_block.items() 

1304 if not _looks_like_file_var(v) 

1305 }, 

1306 } 

1307 

1308 teams_dict: dict = {} 

1309 if deployment.teams: 

1310 # Persisted Team rows expose membership via the ``user_to_teams`` 

1311 # association, not a flat ``userIds`` field — that lives on the 

1312 # request-side Pydantic schema in the create endpoint, not on 

1313 # the ORM. ``get_team_members`` does the join for us. 

1314 for team in deployment.teams: 

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

1316 teams_dict[team.name] = [{"email": m.email} for m in members] 

1317 

1318 openstack_envelope = _fetch_dispatch_envelope( 

1319 db, current_user.userId, rollback_on_missing=False 

1320 ) 

1321 

1322 task = _commit_and_dispatch( 

1323 db, 

1324 deployment_id=deployment.deploymentId, 

1325 task_type=task_type, 

1326 celery_task_name=celery_task_name, 

1327 celery_args=[ 

1328 str(deployment.deploymentId), 

1329 str(deployment.appId), 

1330 deployment.app.git_link, 

1331 deployment.releaseTag, 

1332 user_vars, 

1333 teams_dict, 

1334 openstack_envelope, 

1335 *(extra_args or []), 

1336 ], 

1337 dispatch_error_label=f"Could not dispatch {task_type.value} task — please retry", 

1338 rollback_on_conflict=False, 

1339 ) 

1340 

1341 return JSONResponse( 

1342 status_code=status.HTTP_202_ACCEPTED, 

1343 content={"task_id": str(task.taskId), "status": response_status}, 

1344 ) 

1345 

1346 

1347# ---------------------------------------------------------------- 

1348# INFRASTRUCTURE RESOURCES (per-deployment status + per-VM redeploy) 

1349# ---------------------------------------------------------------- 

1350# 

1351# Three sibling endpoints power the Infrastructure tab on the 

1352# deployment detail page: 

1353# 

1354# * GET /{deployment_id}/resources?refresh=… 

1355# Stage-1 listing — parses the cached TF state and (default) 

1356# overlays live OpenStack lifecycle/hardware/addresses per VM. 

1357# Returns a flat list spanning compute, network, subnet, SG, 

1358# FIP, and port categories. 

1359# 

1360# * GET /{deployment_id}/resources/{address} 

1361# Stage-2 detail — same shape, plus ports/SG-summary/volumes/ 

1362# metadata for ONE compute instance, identified by its TF state 

1363# address (e.g. ``openstack_compute_instance_v2.team_ide["Team-A"]``). 

1364# Frontend loads this lazily when the user opens a card's drawer. 

1365# 

1366# * POST /{deployment_id}/resources/{address}/redeploy 

1367# Per-VM redeploy — issues ``terraform apply -replace=<addr> 

1368# -target=<addr>`` in a dedicated Celery task. Strictly 

1369# address-whitelisted against the cached TF state and the 

1370# compute-instance category, so a hand-crafted POST can't smuggle 

1371# a network-resource target (which would tear down all team VMs). 

1372# 

1373# All three are owner-only — the data exposed (live OpenStack status, 

1374# the ability to bounce a VM) is not something a teammate should be 

1375# able to access through the deployment detail page. 

1376 

1377 

1378# We accept the same Terraform address vocabulary the user would type 

1379# on ``terraform apply -target=``: ``type.name`` with optional 

1380# ``[<int>]`` or ``["<string>"]`` suffix. Multiple address segments 

1381# (modules, nested resources) aren't supported by the current apps, 

1382# so we keep the regex strict to make smuggling impossible. The 

1383# resource-existence whitelist below is the real defense; the regex 

1384# is just a fast no-op rejection for obviously bad inputs (e.g. 

1385# pipes, semicolons, spaces). 

1386_TF_ADDRESS_RE = re.compile( 

1387 r"""^ 

1388 [A-Za-z_][A-Za-z0-9_]* # provider type (e.g. openstack_compute_instance_v2) 

1389 \.[A-Za-z_][A-Za-z0-9_-]* # resource name (e.g. team_ide) 

1390 (?: 

1391 \[(?:\d+|"[^"\\]+")\] # optional index ([0] or ["Team-A"]) 

1392 )? 

1393 $""", 

1394 re.VERBOSE, 

1395) 

1396 

1397 

1398def _latest_tf_state_for(deployment_id: UUID, db: Session) -> str | None: 

1399 """Return the JSON blob of the most recent task that captured a 

1400 Terraform state for this deployment, or None when no apply ever 

1401 succeeded yet. 

1402 

1403 Note: we deliberately do NOT filter by ``task.type`` — the worker 

1404 captures state on deploy / destroy / pause / resume / redeploy 

1405 alike, and any of those produce a valid snapshot. The "most 

1406 recent" task wins, mirroring the existing ``get_deployment_outputs`` 

1407 semantics in ``crud/deployments.py``. 

1408 """ 

1409 task = ( 

1410 db.query(TaskModel) 

1411 .filter(TaskModel.deploymentId == deployment_id) 

1412 .filter(TaskModel.tf_state.isnot(None)) 

1413 .order_by(desc(TaskModel.created_at)) 

1414 .first() 

1415 ) 

1416 return task.tf_state if task else None 

1417 

1418 

1419@router.get( 

1420 "/{deployment_id}/resources", 

1421 response_model=DeploymentResourceListResponse, 

1422) 

1423def list_deployment_resources( 

1424 deployment_id: UUID, 

1425 refresh: bool = True, 

1426 db: Session = Depends(get_db), 

1427 current_user: User = Depends(get_current_user_keycloak), 

1428): 

1429 """Stage-1 resource listing for the Infrastructure tab. 

1430 

1431 Owner-only. ``refresh=false`` skips the live OpenStack join — use 

1432 that when polling rapidly to avoid hammering Keystone, or when 

1433 OpenStack is known unavailable and the cached state is good enough. 

1434 """ 

1435 deployment = crud_deployments.get_deployment(db, deployment_id) 

1436 if not deployment: 

1437 raise HTTPException( 

1438 status_code=status.HTTP_404_NOT_FOUND, 

1439 detail="Deployment not found", 

1440 ) 

1441 # Inspect-only view — owner, admin, or a course-teacher of the 

1442 # deployment-owner's course. Course-teachers explicitly do NOT have 

1443 # operate rights; this endpoint is read-only. 

1444 ensure_view_deployment_owner(current_user, deployment, db) 

1445 

1446 state_json = _latest_tf_state_for(deployment_id, db) 

1447 views = build_resource_views( 

1448 db=db, 

1449 user=current_user, 

1450 tf_state_json=state_json, 

1451 refresh=refresh, 

1452 ) 

1453 # Convert dataclasses → Pydantic models via dict-roundtrip. The 

1454 # fields line up 1:1 by name, so ``model_validate`` works directly 

1455 # on the dataclass dict. 

1456 payload = [ 

1457 DeploymentResourceSchema.model_validate(_view_asdict(v)) 

1458 for v in views 

1459 ] 

1460 return DeploymentResourceListResponse(resources=payload, live=refresh) 

1461 

1462 

1463@router.get( 

1464 "/{deployment_id}/resources/{address:path}", 

1465 response_model=DeploymentResourceSchema, 

1466) 

1467def get_deployment_resource_detail( 

1468 deployment_id: UUID, 

1469 address: str, 

1470 db: Session = Depends(get_db), 

1471 current_user: User = Depends(get_current_user_keycloak), 

1472): 

1473 """Stage-2 detail for one compute instance. 

1474 

1475 Uses a ``path``-converter on the address so the for_each-key 

1476 quoting (``team_ide["Team-A"]``) survives URL routing without 

1477 aggressive encoding gymnastics on the client side. The address 

1478 MUST exist in the cached state and MUST be a compute instance; 

1479 other categories get 422. 

1480 """ 

1481 deployment = crud_deployments.get_deployment(db, deployment_id) 

1482 if not deployment: 

1483 raise HTTPException( 

1484 status_code=status.HTTP_404_NOT_FOUND, 

1485 detail="Deployment not found", 

1486 ) 

1487 # Inspect-only view — course-teachers may read the per-resource 

1488 # detail; the per-VM redeploy below is operate-gated. 

1489 ensure_view_deployment_owner(current_user, deployment, db) 

1490 

1491 if not _TF_ADDRESS_RE.match(address): 

1492 raise HTTPException( 

1493 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

1494 detail={"reason": "invalid_resource_address"}, 

1495 ) 

1496 

1497 state_json = _latest_tf_state_for(deployment_id, db) 

1498 view = build_resource_detail( 

1499 db=db, 

1500 user=current_user, 

1501 tf_state_json=state_json, 

1502 address=address, 

1503 ) 

1504 if view is None: 

1505 raise HTTPException( 

1506 status_code=status.HTTP_404_NOT_FOUND, 

1507 detail={"reason": "resource_not_in_state", "address": address}, 

1508 ) 

1509 return DeploymentResourceSchema.model_validate(_view_asdict(view)) 

1510 

1511 

1512@router.post( 

1513 "/{deployment_id}/resources/{address:path}/redeploy", 

1514 status_code=status.HTTP_202_ACCEPTED, 

1515) 

1516def redeploy_deployment_resource( 

1517 deployment_id: UUID, 

1518 address: str, 

1519 db: Session = Depends(get_db), 

1520 current_user: User = Depends(get_current_user_keycloak), 

1521): 

1522 """Replace one compute instance via ``terraform apply -replace=…``. 

1523 

1524 Address-whitelisted: we re-parse the cached TF state and only 

1525 accept addresses that resolve to a compute instance. Anything else 

1526 fails with 422 — the redeploy of a network resource would tear 

1527 down all the team VMs, which is not what a one-VM "fix it" action 

1528 should do. 

1529 

1530 Concurrency: same per-user advisory lock as create/destroy/pause 

1531 so a parallel redeploy can't race a destroy. The lock is held 

1532 only for the row insert; Celery dispatch happens after commit. 

1533 """ 

1534 crud_locks.acquire_user_xact_lock(db, current_user.userId) 

1535 

1536 deployment = crud_deployments.get_deployment(db, deployment_id) 

1537 if not deployment: 

1538 raise HTTPException( 

1539 status_code=status.HTTP_404_NOT_FOUND, 

1540 detail="Deployment not found", 

1541 ) 

1542 # Per-VM redeploy is a mutating operation — operate gate 

1543 # (owner-or-admin). Course-teachers may inspect the resource via 

1544 # the GET endpoints above but not bounce it. 

1545 ensure_operate_deployment(current_user, deployment, db) 

1546 

1547 if not _TF_ADDRESS_RE.match(address): 

1548 raise HTTPException( 

1549 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

1550 detail={"reason": "invalid_resource_address"}, 

1551 ) 

1552 

1553 # Whitelist check: the address MUST point to a compute instance in 

1554 # the current state. We re-parse here instead of trusting the 

1555 # output of the list endpoint — a hand-crafted POST would skip 

1556 # the list call entirely. 

1557 state_json = _latest_tf_state_for(deployment_id, db) 

1558 parsed = parse_tf_state(state_json) 

1559 match = next((r for r in parsed if r.address == address), None) 

1560 if match is None: 

1561 raise HTTPException( 

1562 status_code=status.HTTP_404_NOT_FOUND, 

1563 detail={"reason": "resource_not_in_state", "address": address}, 

1564 ) 

1565 if match.category != "instance": 

1566 raise HTTPException( 

1567 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

1568 detail={ 

1569 "reason": "non_redeployable_resource_type", 

1570 "address": address, 

1571 "category": match.category, 

1572 }, 

1573 ) 

1574 

1575 return _dispatch_lifecycle_task( 

1576 db=db, 

1577 deployment=deployment, 

1578 current_user=current_user, 

1579 task_type=TaskType.REDEPLOY, 

1580 celery_task_name="tasks.redeploy_resource", 

1581 response_status="redeploying", 

1582 extra_args=[address], 

1583 ) 

1584 

1585 

1586def _view_asdict(view) -> dict: 

1587 """Recursive dataclass → dict converter, used to bridge 

1588 ``deployment_status.DeploymentResourceView`` to Pydantic. 

1589 

1590 ``dataclasses.asdict`` already recurses into nested dataclasses, 

1591 so we just delegate. Kept as a thin wrapper so the call sites 

1592 above read symmetrically and we can swap in custom handling later 

1593 if needed (e.g. enum serialisation). 

1594 """ 

1595 return asdict(view) 

1596 

1597 

1598 

1599# 

1600# Halts the OpenStack compute instances of a deployment without 

1601# tearing them down. The worker task pulls the terraform state, lists 

1602# every ``openstack_compute_instance_v2`` resource, and runs 

1603# ``openstack server stop`` against each. Volumes and networks stay, 

1604# so RESUME restores the same instances byte-for-byte. 

1605# 

1606# Allowed only on ``status='success'`` — the lifecycle service is the 

1607# single source of truth, the partial-unique index on active tasks is 

1608# the DB-level backstop. 

1609@router.post("/{deployment_id}/pause", status_code=status.HTTP_202_ACCEPTED) 

1610def pause_deployment( 

1611 deployment_id: UUID, 

1612 db: Session = Depends(get_db), 

1613 current_user: User = Depends(get_current_user_keycloak), 

1614): 

1615 """Pause a running deployment by stopping its compute instances. 

1616 

1617 Owner-only — same gate as Destroy, because pausing a teammate's 

1618 deployment is in practice a denial-of-service against the team. 

1619 

1620 Returns ``202 + {task_id, status: "pausing"}`` on dispatch. The 

1621 frontend reads ``status`` to switch to the live SSE view; the 

1622 deployment's effective status is recomputed from the new task 

1623 row by ``crud_deployments.get_deployment_status``. 

1624 """ 

1625 deployment = crud_deployments.get_deployment_with_details(db, deployment_id) 

1626 if not deployment: 

1627 raise HTTPException( 

1628 status_code=status.HTTP_404_NOT_FOUND, 

1629 detail="Deployment not found", 

1630 ) 

1631 

1632 ensure_operate_deployment(current_user, deployment, db) 

1633 # Hold the per-deployment advisory lock across the status check 

1634 # AND the task insert so a parallel POST /pause can't sneak past 

1635 # ``ensure_action_allowed`` between our read and the 

1636 # ``prepare_task_in_tx`` flush. 

1637 crud_locks.acquire_deployment_xact_lock(db, deployment_id) 

1638 lifecycle_service.ensure_action_allowed( 

1639 db, deployment, lifecycle_service.DeploymentAction.PAUSE, 

1640 ) 

1641 

1642 return _dispatch_lifecycle_task( 

1643 db, 

1644 deployment, 

1645 current_user, 

1646 task_type=TaskType.PAUSE, 

1647 celery_task_name="tasks.pause_deployment", 

1648 response_status="pausing", 

1649 ) 

1650 

1651 

1652# ---------------------------------------------------------------- 

1653# RESUME DEPLOYMENT 

1654# ---------------------------------------------------------------- 

1655# 

1656# Reverses Pause. Allowed only on ``status='paused'`` — a deployment 

1657# that was never paused has nothing to resume, so the lifecycle 

1658# matrix gates this strictly. Returns 202 with the same shape as 

1659# Pause/Destroy so the frontend handles all three the same way. 

1660@router.post("/{deployment_id}/resume", status_code=status.HTTP_202_ACCEPTED) 

1661def resume_deployment( 

1662 deployment_id: UUID, 

1663 db: Session = Depends(get_db), 

1664 current_user: User = Depends(get_current_user_keycloak), 

1665): 

1666 """Resume a paused deployment by starting its compute instances.""" 

1667 deployment = crud_deployments.get_deployment_with_details(db, deployment_id) 

1668 if not deployment: 

1669 raise HTTPException( 

1670 status_code=status.HTTP_404_NOT_FOUND, 

1671 detail="Deployment not found", 

1672 ) 

1673 

1674 ensure_operate_deployment(current_user, deployment, db) 

1675 # Per-deployment advisory lock — same justification as in 

1676 # ``pause_deployment`` above: keep the status check and the task 

1677 # insert atomic against concurrent /resume / /pause / DELETE 

1678 # requests on this deployment. 

1679 crud_locks.acquire_deployment_xact_lock(db, deployment_id) 

1680 lifecycle_service.ensure_action_allowed( 

1681 db, deployment, lifecycle_service.DeploymentAction.RESUME, 

1682 ) 

1683 

1684 return _dispatch_lifecycle_task( 

1685 db, 

1686 deployment, 

1687 current_user, 

1688 task_type=TaskType.RESUME, 

1689 celery_task_name="tasks.resume_deployment", 

1690 response_status="resuming", 

1691 ) 

1692 

1693 

1694# ---------------------------------------------------------------- 

1695# DOWNLOAD UPLOADED FILE 

1696# ---------------------------------------------------------------- 

1697# 

1698# Lets the deployment owner re-fetch a file they uploaded at deploy 

1699# time. The list / detail endpoints strip the base64 payload so they 

1700# don't ship megabytes per page render; this endpoint is the only 

1701# path that returns the actual bytes. Owner-only — members can see 

1702# that a file was uploaded (metadata survives the strip), but the 

1703# bytes themselves stay restricted to whoever created the deployment. 

1704@router.get( 

1705 "/{deployment_id}/files/{var_name}/{slot_key}", 

1706 response_class=Response, 

1707) 

1708def download_deployment_file( 

1709 deployment_id: UUID, 

1710 var_name: str, 

1711 slot_key: str, 

1712 db: Session = Depends(get_db), 

1713 current_user: User = Depends(get_current_user_keycloak), 

1714): 

1715 """Stream the raw bytes of one wizard-uploaded file back to the owner. 

1716 

1717 Path components mirror how the upload was indexed: 

1718 * ``var_name`` — the ``@openstack:file:*`` variable name 

1719 * ``slot_key`` — the inner-map key (``"all"`` for scope=all, 

1720 team name for scope=team, ``Team-User`` composite for scope=user) 

1721 

1722 Returns 404 if any layer of the lookup misses; the frontend can 

1723 therefore probe a slot's existence via this endpoint without 

1724 needing a separate metadata response. 

1725 """ 

1726 deployment = crud_deployments.get_deployment(db, deployment_id) 

1727 if not deployment: 

1728 raise HTTPException( 

1729 status_code=status.HTTP_404_NOT_FOUND, 

1730 detail="Deployment not found", 

1731 ) 

1732 # Inspect-only view, gated through capabilities so course-teachers 

1733 # can download the same wizard-uploaded files they can already see 

1734 # referenced in the inspect view (logs / detail). Owners + admins 

1735 # keep their access. The list/detail strip-pass already hid the 

1736 # base64 payload from plain members, so this endpoint stays 

1737 # restricted to the owner-view set. 

1738 ensure_view_deployment_owner(current_user, deployment, db) 

1739 

1740 if not deployment.userInputVar: 

1741 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No files") 

1742 try: 

1743 user_input = json.loads(deployment.userInputVar) 

1744 except json.JSONDecodeError: 

1745 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No files") 

1746 

1747 tf_block = user_input.get("terraform") if isinstance(user_input, dict) else None 

1748 var_value = (tf_block or {}).get(var_name) 

1749 if not _looks_like_file_var(var_value): 

1750 raise HTTPException( 

1751 status_code=status.HTTP_404_NOT_FOUND, 

1752 detail=f"No uploaded file under variable '{var_name}'", 

1753 ) 

1754 # ``var_value`` is ``{slot_key: {name, content_b64, size, content_type}}``; 

1755 # the slot-level entry IS the file metadata, no extra wrapper. 

1756 entry = var_value.get(slot_key) 

1757 if not isinstance(entry, dict) or "content_b64" not in entry: 

1758 raise HTTPException( 

1759 status_code=status.HTTP_404_NOT_FOUND, 

1760 detail=f"No file in slot '{slot_key}'", 

1761 ) 

1762 

1763 try: 

1764 payload = base64.b64decode(entry["content_b64"], validate=True) 

1765 except (binascii.Error, ValueError): 

1766 # Persisted bytes are corrupt — surface as 500 because there's 

1767 # nothing the caller can do; this is a server-side data bug. 

1768 raise HTTPException( 

1769 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1770 detail="Stored file payload is not valid base64", 

1771 ) 

1772 

1773 filename = str(entry.get("name") or f"{var_name}-{slot_key}") 

1774 content_type = str(entry.get("content_type") or "application/octet-stream") 

1775 return Response( 

1776 content=payload, 

1777 media_type=content_type, 

1778 headers={ 

1779 # ``filename*`` is the RFC 5987 form for non-ASCII names; 

1780 # we always emit it alongside the legacy ``filename`` so 

1781 # clients without UTF-8 support still see something. 

1782 "Content-Disposition": ( 

1783 f'attachment; filename="{filename}"; ' 

1784 f"filename*=UTF-8''{filename}" 

1785 ), 

1786 "Content-Length": str(len(payload)), 

1787 }, 

1788 ) 

1789 

1790 

1791# ---------------------------------------------------------------- 

1792# GET OWN ACCESS CREDENTIALS (member self-service) 

1793# ---------------------------------------------------------------- 

1794@router.get("/{deployment_id}/my-access", response_model=MyAccessResponse) 

1795def get_my_access( 

1796 deployment_id: UUID, 

1797 db: Session = Depends(get_db), 

1798 current_user: User = Depends(get_current_user_keycloak), 

1799): 

1800 """Return the calling member's OWN access credentials for a deployment. 

1801 

1802 The full terraform ``outputs`` are owner-view-only (they carry every 

1803 teammate's credentials in one object). This endpoint is the member 

1804 counterpart: a team member — typically a student — retrieves only 

1805 THEIR OWN account, extracted server-side from the latest successful 

1806 deploy. Teammates' credentials are never included in the response. 

1807 

1808 Authorisation reuses :func:`ensure_resend_access` with the target set 

1809 to the caller themself, which collapses to the member-view gate 

1810 (owner, staff, team-member, or direct mapping). A caller with no 

1811 access to the deployment gets 403; the resend endpoint uses the same 

1812 gate for the self-resend button, so the two stay consistent. 

1813 

1814 Returns 200 with empty maps when there's no successful deploy yet or 

1815 the app issued no per-user credential for this user — the UI renders 

1816 a "no credentials yet" state rather than treating it as an error. 

1817 """ 

1818 deployment = crud_deployments.get_deployment(db, deployment_id) 

1819 if not deployment: 

1820 raise HTTPException( 

1821 status_code=status.HTTP_404_NOT_FOUND, 

1822 detail="Deployment not found", 

1823 ) 

1824 # Self-target → member-view gate. Non-members get 403 here. 

1825 ensure_resend_access(current_user, deployment, current_user.userId, db) 

1826 

1827 access = deployment_notifier.get_user_access( 

1828 db, deployment_id, current_user.userId 

1829 ) 

1830 if access is None: 

1831 # No successful deploy yet, user not in any team, or no per-user 

1832 # credential in the outputs — surface an empty (but valid) payload. 

1833 return MyAccessResponse() 

1834 return MyAccessResponse( 

1835 user_accounts=access.get("user_accounts", {}), 

1836 team_vms=access.get("team_vms", {}), 

1837 ) 

1838 

1839 

1840# ---------------------------------------------------------------- 

1841# RESEND ACCESS MAIL FOR ONE TEAM MEMBER 

1842# ---------------------------------------------------------------- 

1843@router.post( 

1844 "/{deployment_id}/teams/{team_id}/users/{user_id}/resend-access", 

1845 status_code=status.HTTP_202_ACCEPTED, 

1846) 

1847def resend_access_credentials( 

1848 deployment_id: UUID, 

1849 team_id: UUID, 

1850 user_id: UUID, 

1851 db: Session = Depends(get_db), 

1852 current_user: User = Depends(get_current_user_keycloak), 

1853): 

1854 """Re-send the per-user access mail for one team member. 

1855 

1856 Reuses the original notify pipeline — same template, same 

1857 credential extraction from the latest successful DEPLOY task's 

1858 ``terraform_outputs``. Useful when the user lost their first mail 

1859 or the deploy ran before the user's email got fixed. 

1860 

1861 Access control: caller must have access to the deployment (owner 

1862 or teacher/admin). The endpoint is intentionally idempotent — 

1863 each call sends one mail to the targeted user. There's no rate 

1864 limit at the API level; SMTP and Gmail's per-account quota are 

1865 the natural backstops. 

1866 

1867 Mapping ResendError to HTTP: 

1868 * ``deployment_not_found`` → 404 

1869 * ``team_not_in_deployment`` / ``user_not_in_team`` → 404 

1870 * ``no_successful_deploy`` → 409 (nothing to resend yet) 

1871 * ``no_credentials_for_user`` → 409 (template didn't issue 

1872 per-user creds, or matcher missed despite the fuzzy logic) 

1873 """ 

1874 deployment = crud_deployments.get_deployment(db, deployment_id) 

1875 if not deployment: 

1876 raise HTTPException( 

1877 status_code=status.HTTP_404_NOT_FOUND, 

1878 detail="Deployment not found", 

1879 ) 

1880 ensure_deployment_access(deployment, current_user, db) 

1881 

1882 # Members may only re-send their own access mail. Owner-view 

1883 # callers (creator, teacher, admin) can resend for anyone in 

1884 # any team. Without this check a student in team A could trigger 

1885 # a mail to anyone else's address, which is both privacy-leaky 

1886 # and a tiny SMTP-amplification vector. 

1887 # 

1888 # This 403 is decided BEFORE the SMTP-disabled 503 below: a 

1889 # not-authorised caller must not learn the SMTP-state of the 

1890 # platform — that would be a (small) information disclosure. 

1891 if not is_deployment_owner_view(deployment, current_user) and str(user_id) != str(current_user.userId): 

1892 raise HTTPException( 

1893 status_code=status.HTTP_403_FORBIDDEN, 

1894 detail="Members may only resend their own access mail", 

1895 ) 

1896 

1897 # SMTP kill-switch check: refuse cleanly with 503 BEFORE running the 

1898 # notifier so the response carries the correct semantic ("we chose 

1899 # not to send" — operator configuration) rather than the existing 

1900 # 502 ("we tried and SMTP rejected" — infrastructure failure). 

1901 # Frontend distinguishes the two reasons in the toast text so the 

1902 # user understands whether to ask an admin to enable mail or to 

1903 # simply retry. The 503 + Retry-After header signals "service 

1904 # temporarily unavailable; come back later" semantics. 

1905 # 

1906 # Order vs. 409 in-flight: a 503 here is non-recoverable until an 

1907 # operator flips the env-flag, whereas 409 is a transient state 

1908 # the caller can simply wait out. Returning the 503 first matches 

1909 # the "service-level concern beats per-request concern" pattern 

1910 # used by every other 5xx vs 4xx ordering in this router. 

1911 if not email_service.is_smtp_enabled(): 

1912 raise HTTPException( 

1913 status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 

1914 detail={"reason": "smtp_disabled"}, 

1915 headers={"Retry-After": "3600"}, 

1916 ) 

1917 

1918 # Refuse while another lifecycle action is in flight. Resending 

1919 # the access mail relies on the latest successful DEPLOY task's 

1920 # ``terraform_outputs``; during pending/running/destroying/ 

1921 # pausing/resuming the deployment is in transition and the 

1922 # credentials might no longer be reachable on the VM (paused → 

1923 # SHUTOFF, destroying → tearing down). Returning 409 here keeps 

1924 # the user's mental model consistent with the rest of the 

1925 # lifecycle gates. 

1926 # 

1927 # Per-deployment advisory lock is acquired BEFORE the status 

1928 # read so a concurrent /pause / /resume / DELETE can't slip a 

1929 # transition past us between the check and the mail send. The 

1930 # lock is the same one those endpoints take, so the four 

1931 # mutators serialise against each other on the same deployment. 

1932 crud_locks.acquire_deployment_xact_lock(db, deployment_id) 

1933 current_status = crud_deployments.get_deployment_status(db, deployment_id) 

1934 if current_status in lifecycle_service.IN_FLIGHT_STATUSES: 

1935 raise HTTPException( 

1936 status_code=status.HTTP_409_CONFLICT, 

1937 detail=( 

1938 f"Cannot resend access mail while deployment is '{current_status}'. " 

1939 "Wait for the active task to finish." 

1940 ), 

1941 ) 

1942 

1943 try: 

1944 sent = deployment_notifier.resend_user_access( 

1945 db, deployment_id, team_id, user_id, 

1946 ) 

1947 except deployment_notifier.ResendError as e: 

1948 reason = str(e) 

1949 # 404 for "this user/team isn't in this deployment", 409 for 

1950 # "the deployment hasn't reached the state where it could 

1951 # have emitted credentials yet". 

1952 if reason in ("deployment_not_found", "team_not_in_deployment", "user_not_in_team"): 

1953 http_status = status.HTTP_404_NOT_FOUND 

1954 else: 

1955 http_status = status.HTTP_409_CONFLICT 

1956 raise HTTPException(status_code=http_status, detail={"reason": reason}) 

1957 

1958 if not sent: 

1959 # Template render + payload were fine, only SMTP rejected. 

1960 # 502 makes the "upstream service failed" semantics clear so 

1961 # the frontend can surface a retry rather than a 4xx that 

1962 # implies the request itself was wrong. 

1963 raise HTTPException( 

1964 status_code=status.HTTP_502_BAD_GATEWAY, 

1965 detail={"reason": "smtp_send_failed"}, 

1966 ) 

1967 return {"status": "sent"} 

1968 

1969 

1970# ---------------------------------------------------------------- 

1971# LIVE STREAM — Server-Sent Events for progress + log tail 

1972# ---------------------------------------------------------------- 

1973# 

1974# Several kinds of events flow through the stream: 

1975# 

1976# * ``event: snapshot`` — fired once at connect with the latest task's 

1977# current_phase / progress_pct / status. Lets a freshly-loaded page 

1978# render the bar at the right position before the worker emits its 

1979# next progress update. 

1980# * ``event: progress`` — every ``task-progress`` from the worker. The 

1981# payload includes ``phase``, ``phase_index``, ``total_phases``, 

1982# ``progress_pct``, ``message``. 

1983# * ``event: log`` — every ``task-log`` from the worker. The payload 

1984# is the LogEntry dict (timestamp, level, category, message, plus 

1985# tool/streaming flags for streaming subprocess lines). 

1986# * ``event: overflow`` — emitted by the in-process pubsub when a 

1987# slow consumer overran its bounded queue. 

1988# * comment lines starting with ``:`` are SSE keepalive pings. 

1989# 

1990# The stream stays open until the deployment reaches a terminal state 

1991# (success/failed/cancelled), the client disconnects, or the backend 

1992# shuts down. There's no client-driven close — EventSource handles 

1993# reconnect automatically. 

1994@router.get("/{deployment_id}/stream") 

1995async def stream_deployment_events( 

1996 deployment_id: UUID, 

1997 request: Request, 

1998 db: Session = Depends(get_db), 

1999 current_user: User = Depends(get_current_user_keycloak), 

2000): 

2001 """Live progress + log stream for one deployment as Server-Sent Events. 

2002 

2003 The connection is authenticated with the same Keycloak dependency 

2004 used elsewhere; the standard auth middleware also vets the token 

2005 before this handler runs. After auth we attach to the in-process 

2006 pubsub for this deployment and forward every event to the client. 

2007 """ 

2008 deployment = crud_deployments.get_deployment(db, deployment_id) 

2009 if not deployment: 

2010 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deployment not found") 

2011 # Inspect-only view via capabilities. The live stream surfaces 

2012 # task-log lines (raw worker stdout incl. terraform output, packer 

2013 # build chatter, etc.); course-teachers of the deployment-owner's 

2014 # course are in the inspect set, owners and admins keep their access, 

2015 # and plain members still see metadata only. 

2016 ensure_view_deployment_owner(current_user, deployment, db) 

2017 

2018 # Snapshot the latest task once before subscribing so the client 

2019 # gets a meaningful initial state. Reading happens before the 

2020 # generator yields its first chunk to avoid the "subscribed but 

2021 # nothing buffered yet" gap. 

2022 latest_task = crud_deployments.get_latest_task(db, deployment_id) 

2023 snapshot_payload = { 

2024 "task_id": str(latest_task.taskId) if latest_task else None, 

2025 "status": latest_task.status.value if latest_task else None, 

2026 "current_phase": getattr(latest_task, "current_phase", None), 

2027 "progress_pct": getattr(latest_task, "progress_pct", None), 

2028 "type": latest_task.type.value if latest_task else None, 

2029 } 

2030 initial_status = latest_task.status if latest_task else None 

2031 

2032 deployment_id_str = str(deployment_id) 

2033 

2034 async def event_stream() -> AsyncIterator[bytes]: 

2035 queue = pubsub.subscribe(deployment_id_str) 

2036 try: 

2037 yield _sse_frame("snapshot", snapshot_payload) 

2038 

2039 # Backfill what's been happening lately. The pubsub keeps a 

2040 # bounded ring buffer of recent events per deployment so a 

2041 # client connecting mid-stream sees the last few minutes of 

2042 # progress / log output instead of an empty tail until the 

2043 # next worker line lands. Replays the buffer in order so 

2044 # ``streamCurrentPhaseIndex``/``streamProgress`` end up at 

2045 # their latest values before the live loop starts. 

2046 for past_event in pubsub.recent(deployment_id_str): 

2047 event_name = _event_name_for(past_event.get("type")) 

2048 yield _sse_frame(event_name, past_event) 

2049 

2050 # If the task is already in a terminal state we still yield 

2051 # the snapshot but close the stream right away — no live 

2052 # events will ever arrive for this deployment. 

2053 if initial_status in (TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.CANCELLED): 

2054 return 

2055 

2056 # Heartbeat / event-pump loop. Wait up to 15s for an event; 

2057 # if nothing arrives, send a ``: keepalive`` comment so 

2058 # proxies and the EventSource client don't time out. 

2059 while True: 

2060 if await request.is_disconnected(): 

2061 return 

2062 try: 

2063 event = await asyncio.wait_for(queue.get(), timeout=15.0) 

2064 except TimeoutError: 

2065 yield b": keepalive\n\n" 

2066 continue 

2067 

2068 event_name = _event_name_for(event.get("type")) 

2069 yield _sse_frame(event_name, event) 

2070 

2071 # Stop streaming once the parent task reaches a 

2072 # terminal state. The lifecycle events (succeeded / 

2073 # failed / revoked) flow through the same pubsub key, 

2074 # so we look for them right here. Without this break 

2075 # the connection would dangle until the client closes 

2076 # it. 

2077 if event.get("type") in ("task-succeeded", "task-failed", "task-revoked"): 

2078 return 

2079 except asyncio.CancelledError: 

2080 # FastAPI cancels the generator on client disconnect. 

2081 raise 

2082 finally: 

2083 pubsub.unsubscribe(deployment_id_str, queue) 

2084 

2085 return StreamingResponse( 

2086 event_stream(), 

2087 media_type="text/event-stream", 

2088 headers={ 

2089 "Cache-Control": "no-cache, no-transform", 

2090 "X-Accel-Buffering": "no", # disable nginx response buffering 

2091 "Connection": "keep-alive", 

2092 }, 

2093 ) 

2094 

2095 

2096_EVENT_NAME_MAP: dict[str, str] = { 

2097 "task-progress": "progress", 

2098 "task-log": "log", 

2099 "task-overflow": "overflow", 

2100 "task-started": "started", 

2101 "task-succeeded": "succeeded", 

2102 "task-failed": "failed", 

2103 "task-revoked": "revoked", 

2104} 

2105 

2106 

2107def _event_name_for(celery_event_type: str | None) -> str: 

2108 """Map Celery event type names onto short SSE event names. 

2109 

2110 Frontend code attaches listeners by these short names rather than 

2111 the verbose celery-internal ones; ``_EVENT_NAME_MAP`` is the 

2112 single source of truth on both sides of the wire. 

2113 """ 

2114 return _EVENT_NAME_MAP.get(celery_event_type or "", "message") 

2115 

2116 

2117def _sse_frame(event_name: str, payload: dict) -> bytes: 

2118 """Serialise one SSE frame. 

2119 

2120 SSE format: 

2121 

2122 ``` 

2123 event: <name>\\n 

2124 data: <json>\\n 

2125 \\n 

2126 ``` 

2127 

2128 Embedded newlines in the JSON would split the frame into multiple 

2129 ``data:`` lines per the SSE spec; we use ``json.dumps`` defaults 

2130 which keep everything on one line. 

2131 """ 

2132 body = json.dumps(payload, default=str) 

2133 return f"event: {event_name}\ndata: {body}\n\n".encode()