Coverage for app/routers/apps.py: 80.04%

546 statements  

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

1import contextlib 

2import difflib 

3import json 

4import logging 

5import os 

6import re 

7from dataclasses import dataclass 

8from typing import Any 

9from uuid import UUID 

10 

11from fastapi import APIRouter, Depends, HTTPException, status 

12from pydantic import BaseModel, ConfigDict 

13from sqlalchemy.orm import Session 

14 

15from app.crud import app_version_approvals as crud_approvals 

16from app.crud import apps as crud_apps 

17from app.database import get_db 

18from app.models import User, UserRole 

19from app.schemas import ( 

20 AppCreate, 

21 AppResponse, 

22 AppUpdate, 

23 AppVersionApprovalResponse, 

24 AppVersionApprovalSubmit, 

25 AppWithVersions, 

26) 

27from app.services.git_service import git_service 

28from app.utils.app_image import build_image_data_url, parse_image_data_url 

29from app.utils.capabilities import ( 

30 ensure_delete_app, 

31 ensure_edit_app, 

32 ensure_submit_app_version, 

33 ensure_view_app, 

34) 

35from app.utils.keycloak_auth import get_current_user_keycloak 

36 

37logger = logging.getLogger(__name__) 

38 

39 

40def _serialize_app(app): 

41 """Replace ``app.image`` (bytes) with the data-URL form in-place. 

42 

43 The ORM model carries the raw bytes plus a separate mime column. 

44 The Pydantic ``AppResponse`` schema declares ``image: Optional[str]`` 

45 and uses ``from_attributes=True``, so Pydantic reads ``app.image`` 

46 directly. Overwriting that attribute with the rebuilt data-URL 

47 means the response serialiser sees a string and the wire format 

48 matches the schema. Returns ``app`` so callers can chain. 

49 """ 

50 if app is None: 

51 return None 

52 raw_bytes = getattr(app, "image", None) 

53 if isinstance(raw_bytes, (bytes, memoryview, bytearray)): 

54 app.image = build_image_data_url(bytes(raw_bytes), getattr(app, "image_mime", None)) 

55 return app 

56 

57 

58def _version_tag(version) -> str: 

59 """Extract the tag string from a git version entry. 

60 

61 ``git_service.get_versions`` yields either a plain tag string or a 

62 dict carrying the tag under one of ``version`` / ``releaseTag`` / 

63 ``tag``. Returns ``""`` when nothing matches, so callers can treat 

64 the result uniformly (empty string is falsy and never a valid tag). 

65 """ 

66 if isinstance(version, str): 

67 return version 

68 return version.get("version") or version.get("releaseTag") or version.get("tag", "") 

69 

70 

71router = APIRouter() 

72 

73 

74# ---------------------------------------------------------------- 

75# Pydantic schema for the /apps/{id}/variables response 

76# ---------------------------------------------------------------- 

77# Mirrors the exact keys ``_parse_one_variable`` returns (mixed 

78# snake/camelCase) so the frontend and generated OpenAPI stay in sync. 

79class _MarkerErrorPayload(BaseModel): 

80 variable: str 

81 message: str 

82 location: str 

83 code: str | None = None 

84 

85 

86class AppVariableResponse(BaseModel): 

87 """Shape of one entry in ``GET /apps/{id}/variables``. 

88 

89 Keys match what ``_parse_one_variable`` writes to the dict exactly — 

90 the frontend reads ``osType``/``osMode``/``osMulti``/``osScope``/ 

91 ``varScope``/``fileExtensions`` in camelCase and the rest in 

92 snake/lowercase. Keys are kept verbatim (no auto-aliasing). 

93 """ 

94 

95 # ``populate_by_name`` lets callers construct with either field name 

96 # or alias; the dict-style names are the canonical source. 

97 model_config = ConfigDict(populate_by_name=True, extra="allow") 

98 

99 name: str 

100 type: str 

101 description: str | None = None 

102 # Default is typed (Number/Bool/List/Dict/None); ``Any`` is 

103 # deliberately broad because HCL covers a whole literal family. 

104 default: Any | None = None 

105 required: bool 

106 source: str 

107 osType: str | None = None 

108 osMode: str | None = None 

109 osMulti: bool | None = None 

110 osScope: str | None = None 

111 varScope: str | None = None 

112 fileExtensions: list[str] | None = None 

113 markerError: _MarkerErrorPayload | None = None 

114 # ``template_key`` is null for ``source = terraform`` variables and 

115 # carries the per-template key (``default`` for the legacy layout, 

116 # or the subdirectory name like ``webserver``/``database`` in 

117 # multi-image apps) for ``source = packer`` variables. Lets the 

118 # wizard group Packer variables per image and avoid name collisions 

119 # across templates. 

120 template_key: str | None = None 

121 

122 

123# ---------------------------------------------------------------- 

124# OPENSTACK MARKER PARSING (HCL VARIABLES) 

125# ---------------------------------------------------------------- 

126# Apps declare value-help for OpenStack resources exclusively via an 

127# explicit marker in the variable's ``description``. No heuristics, no 

128# name inference. A variable without a marker renders as free text. 

129# 

130# Grammar (positional, with defaults): 

131# 

132# @openstack:<type>[:<mode>][:<multi>][:<var_scope>] 

133# 

134# <type> — one of the resource kinds in ``_OS_TYPES``, OR EMPTY. An 

135# empty type slot is allowed when the marker only sets a 

136# ``var_scope`` (e.g. ``@openstack:::user`` scopes an 

137# otherwise free string variable per-user). 

138# <mode> — 'id' | 'name' (default 'name'; see ``_NAME_ONLY_TYPES``). 

139# <multi> — 'multi' | 'list' | 'single' ('list' is a synonym for 

140# 'multi'). Default derives from the HCL type: 

141# ``list``/``set``/``tuple`` → multi, else single. 

142# ``map(...)``/``object(...)`` count as single. 

143# <var_scope> — 'all' | 'team' | 'user' (default 'all'). Controls 

144# whether the wizard renders one input (``all``), one per 

145# team, or one per user. ``team``/``user`` require a 

146# ``map(...)`` HCL type. Packer variables allow only ``all``. 

147# 

148# Examples: 

149# @openstack:network → network, name-mode, multi from HCL 

150# @openstack:network:id → network, id-mode 

151# @openstack:security_group:name:multi → SG, name-mode, multi 

152# @openstack:flavor::multi → empty mode slot ⇒ default 'name' 

153# @openstack:flavor:id:single:team → one flavor ID per team; map(string) 

154# @openstack:::user → free-text, per-user scoped; map(...) 

155# 

156# The marker may appear anywhere in the description but must terminate at 

157# a word boundary. With multiple markers, the first with a KNOWN type 

158# wins; markers with unknown types are skipped. If no marker has a known 

159# type, that's an error (reported with a "did you mean …?" hint). 

160# 

161# Error handling: malformed or invalid markers raise ``MarkerError``, 

162# caught per-variable and attached to the payload as ``markerError`` so 

163# that variable renders as free text with an inline hint while the rest 

164# stay usable. 

165# ---------------------------------------------------------------- 

166 

167# Supported OpenStack resource types. Must stay consistent with: 

168# - backend/app/routers/openstack_resources.py (list endpoints) 

169# - frontend/src/types/index.ts (`AppVariableOsType`) 

170# - frontend/src/components/OpenStackResourcePicker.vue (render) 

171_OS_TYPES: set[str] = { 

172 "network", 

173 "subnet", 

174 "flavor", 

175 "image", 

176 "keypair", 

177 "security_group", 

178 "floating_ip_pool", 

179 "volume", 

180 "router", 

181 "availability_zone", 

182 # ``file`` is a special pseudo-resource: it doesn't pick from a 

183 # remote OpenStack API, it tells the wizard to render a file-upload 

184 # widget and route the bytes into ``userInputVar.terraform`` so the 

185 # template can drop them onto the VM via cloud-init ``write_files``. 

186 # The mode slot carries the scope (``all``/``team``/``user``); the 

187 # multi slot carries the mandatory extension filter (e.g. ``pdf`` or 

188 # ``pdf|docx``) — a file marker without a filter is rejected. 

189 "file", 

190} 

191 

192# Allowed scope tokens for ``@openstack:file:<scope>``. Reuses the 

193# mode slot of the marker grammar — keeps the regex shape unchanged 

194# while teaching the parser to interpret the slot per-type. 

195_FILE_SCOPES: set[str] = {"all", "team", "user"} 

196 

197# Mandatory extension filter in the fourth marker slot for file 

198# variables. Only letters/digits and ``|`` as separator; matched 

199# case-insensitively, lowercased internally. Examples: ``pdf``, 

200# ``pdf|docx|txt``. Empty is not allowed. 

201_FILE_EXTENSIONS_RE = re.compile(r"^[a-z0-9]+(?:\|[a-z0-9]+)*$") 

202 

203# Allowed values for the general ``var_scope`` slot (fourth slot on 

204# non-file markers). ``all`` is the default — variables without a marker 

205# and markers without a 4th slot resolve to ``all``. 

206_VAR_SCOPES: set[str] = {"all", "team", "user"} 

207 

208# Resource kinds that effectively have no UUID in OpenStack or are 

209# addressed by name throughout — e.g. keypairs (Nova uses names only), 

210# availability zones (no UUID at all), floating-IP pools (external 

211# networks, referenced by name in modules). 

212# 

213# The name-only default applies ONLY when the author omits the mode. 

214# ``@openstack:keypair`` → mode='name'. ``@openstack:keypair:id`` is 

215# respected but practically pointless (yields an empty ID list). 

216_NAME_ONLY_TYPES: set[str] = {"keypair", "availability_zone", "floating_ip_pool"} 

217 

218# Marker regex. Matches the whole token at word boundaries so prose 

219# examples like ``"see @openstack:network in the docs"`` are recognised 

220# but ``"@openstackbar"`` is not. Slot content may not contain 

221# whitespace. Five or more colons = malformed (see 

222# ``_TOO_MANY_SEGMENTS_RE``). 

223# 

224# Right boundary: any non-identifier char — whitespace, line end, common 

225# punctuation ``. , ; : ! ? ) ] " '``. Left boundary: start or the same. 

226# 

227# Slot content: 

228# * Slot 1 (type): ``[A-Za-z][A-Za-z0-9_]*`` or EMPTY. An empty type 

229# slot means "only set var_scope, don't force a resource picker". 

230# * Slots 2/3: ``[A-Za-z]*``. 

231# * Slot 4 (var_scope for non-file, file-extensions filter for file): 

232# ``[A-Za-z0-9|]*``. The ``|`` is only needed for the file-filter 

233# case (e.g. ``pdf|docx``); the parser splits the semantics. 

234_MARKER_RE = re.compile( 

235 r""" 

236 (?:^|(?<=[\s.,;:!?()\[\]"'])) # Left boundary: start or whitespace/punctuation 

237 @openstack 

238 :([A-Za-z][A-Za-z0-9_]*)? # 1: type (may be empty → scope-only marker) 

239 (?::([A-Za-z]*))? # 2: mode slot (may be empty) 

240 (?::([A-Za-z]*))? # 3: multi slot (may be empty) 

241 (?::([A-Za-z0-9|]*))? # 4: var_scope / file-extensions (may be empty) 

242 (?=$|[\s.,;:!?)\]"']) # Right boundary 

243 """, 

244 # Marker prefix is accepted case-insensitively (``@OpenStack:flavor`` 

245 # == ``@openstack:flavor``); ``_parse_marker`` lowercases slot content. 

246 re.VERBOSE | re.IGNORECASE, 

247) 

248 

249# Detects a ``<whitespace>:<token>`` continuation right after a match 

250# (e.g. ``@openstack:flavor :id``). ``_MARKER_RE`` stops at the 

251# whitespace, so we raise an explicit error to surface the typo. 

252_MARKER_WHITESPACE_CONT_RE = re.compile(r"\s+:\s*[A-Za-z]") 

253 

254# A comma as a slot separator is a common typo — see the call site. 

255# Match form: ``<tail starts with>,<token-char>``. 

256_MARKER_COMMA_CONT_RE = re.compile(r",\s*[A-Za-z0-9|]") 

257 

258# Quick check: does the marker have too many segments? 

259# ``@openstack:network:id:multi:team:extra`` → fail. Every 5+ segment 

260# must be non-empty, otherwise a trailing-colon 4-slot form would be 

261# wrongly caught. 

262_TOO_MANY_SEGMENTS_RE = re.compile( 

263 r"@openstack(?::[A-Za-z0-9_|]+){5,}", 

264 re.IGNORECASE, 

265) 

266 

267 

268# Detects a marker-attempted-but-malformed input: fires when the 

269# description contains ``@openstack:`` but the strict regex matches 

270# nothing (dash/slash/equals separators, whitespace, empty type, etc.). 

271# Matches ``@openstack`` followed by ``:`` or whitespace+``:``. 

272_BAD_PREFIX_RE = re.compile( 

273 r"@openstack\s*:", 

274 re.IGNORECASE, 

275) 

276 

277 

278class MarkerError(ValueError): 

279 """Raised when an ``@openstack`` marker is syntactically or 

280 semantically invalid. Translated to HTTP 400 in the endpoint so the 

281 app author sees the error on the first ``GET /apps/{id}/variables`` 

282 instead of the variable silently rendering as free text. 

283 

284 ``code`` is a stable machine-readable key (e.g. ``MARKER_WHITESPACE``); 

285 ``message`` is human-readable German for now. 

286 """ 

287 

288 def __init__(self, var_name: str, message: str, code: str = "MARKER_INVALID"): 

289 super().__init__(f"Variable '{var_name}': {message}") 

290 self.var_name = var_name 

291 self.message = message 

292 self.code = code 

293 

294 

295def _parse_var_scope(var_name: str, slot: str | None) -> str | None: 

296 """Validate and normalize the ``var_scope`` slot of a marker. 

297 

298 Returns ``None`` for an empty slot; raises ``MarkerError`` for an 

299 unknown token (with a closest-match hint when one exists). 

300 """ 

301 if slot is None or slot == "": 

302 return None 

303 rs = slot.lower() 

304 if rs in _VAR_SCOPES: 

305 return rs 

306 suggestion = _closest_match(rs, _VAR_SCOPES) 

307 hint = f"; meintest du '{suggestion}'?" if suggestion else "" 

308 raise MarkerError( 

309 var_name, 

310 f"ungültiger var_scope '{slot}'{hint} — erwartet " 

311 f"{sorted(_VAR_SCOPES)}", 

312 code="MARKER_INVALID_VAR_SCOPE", 

313 ) 

314 

315 

316def _forbid_packer_team_user_scope(var_name: str, source: str, var_scope: str | None) -> None: 

317 """Reject ``team``/``user`` scopes on packer variables. 

318 

319 Packer builds ONE image shared by all later VMs/teams/users, so a 

320 per-team/per-user value would have no effect. Called from both the 

321 scope-only and resource marker paths. 

322 """ 

323 if source == "packer" and var_scope in ("team", "user"): 

324 raise MarkerError( 

325 var_name, 

326 f"packer-Variablen unterstützen nur ``var_scope = all``; " 

327 f"angegeben: '{var_scope}'. Begründung: Packer baut EIN " 

328 f"Image, das von allen späteren VMs/Teams/Usern geteilt " 

329 f"wird — ein Per-Team-Wert hätte keine Wirkung.", 

330 code="MARKER_PACKER_SCOPE_FORBIDDEN", 

331 ) 

332 

333 

334def _reject_malformed_markers(var_name: str, description: str) -> None: 

335 """Raise ``MarkerError`` for the malformed-marker shapes a plain 

336 regex match would silently swallow. 

337 

338 Covers: too many segments, whitespace between segments, and a comma 

339 used as a slot separator (the only valid separator is ``|``). 

340 """ 

341 # Six+ segments (i.e. five+ colons after ``@openstack:``) are never 

342 # legitimate. Check this first, BEFORE the main regex (which stops 

343 # after four slots) even notices. 

344 if _TOO_MANY_SEGMENTS_RE.search(description): 

345 raise MarkerError( 

346 var_name, 

347 "marker hat zu viele Segmente — erlaubt: " 

348 "@openstack:<type>[:<mode>][:<multi>][:<var_scope>]", 

349 code="MARKER_TOO_MANY_SEGMENTS", 

350 ) 

351 

352 matches = list(_MARKER_RE.finditer(description)) 

353 if not matches: 

354 # Strict hard-fail path: someone typed ``@openstack:`` but our 

355 # grammar doesn't match — e.g. whitespace, a dash, ``=``, or a 

356 # slash. Fail loudly rather than render the variable as free-text. 

357 if _BAD_PREFIX_RE.search(description): 

358 raise MarkerError( 

359 var_name, 

360 "marker konnte nicht geparst werden — erlaubt ist nur " 

361 "``@openstack:<type>[:<mode>][:<multi>][:<var_scope>]`` mit " 

362 "Doppelpunkten als Trenner und ohne Whitespace zwischen " 

363 "den Segmenten", 

364 code="MARKER_UNPARSEABLE", 

365 ) 

366 return 

367 

368 # Whitespace between marker segments silently truncates: 

369 # ``_MARKER_RE`` stops at the first whitespace, so 

370 # ``@openstack:flavor :id`` parses only as ``@openstack:flavor``. 

371 # For each match, check for a ``<whitespace>:<token>`` continuation 

372 # and raise a clear error instead of swallowing the typo. 

373 for m in matches: 

374 tail = description[m.end():] 

375 if _MARKER_WHITESPACE_CONT_RE.match(tail): 

376 raise MarkerError( 

377 var_name, 

378 "marker enthält Whitespace zwischen den Segmenten — " 

379 "schreibe ihn ohne Leerzeichen (z.B. " 

380 "``@openstack:flavor:id:multi`` statt " 

381 "``@openstack:flavor :id :multi``)", 

382 code="MARKER_WHITESPACE", 

383 ) 

384 

385 # A comma as a slot separator is a common typo — the only allowed 

386 # separator is ``|`` (e.g. ``@openstack:file:all:pdf|docx``). 

387 # ``_MARKER_RE`` matches only up to the comma, so we detect 

388 # ``<match>,<token>`` explicitly and raise a clear error. 

389 for m in matches: 

390 tail = description[m.end():] 

391 if _MARKER_COMMA_CONT_RE.match(tail): 

392 raise MarkerError( 

393 var_name, 

394 "ungültiger Endungsfilter mit Komma — marker-Slots werden " 

395 "mit ``|`` getrennt, nicht mit Komma (z.B. " 

396 "``@openstack:file:all:pdf|docx`` statt " 

397 "``@openstack:file:all:pdf,docx``)", 

398 code="MARKER_FILE_INVALID_EXTENSIONS", 

399 ) 

400 

401 

402def _select_marker(var_name: str, description: str): 

403 """Pick the effective marker match from the description. 

404 

405 The first marker with a KNOWN type OR an empty type slot (= a 

406 scope-only marker) wins; markers with an unknown, non-empty type are 

407 skipped (tolerated). Returns the chosen 

408 ``(match, raw_type, raw_mode, raw_multi, raw_scope)`` tuple, or 

409 ``None`` when the description carries no marker at all. Raises 

410 ``MarkerError`` when every marker had an unknown type. 

411 """ 

412 matches = list(_MARKER_RE.finditer(description)) 

413 if not matches: 

414 return None 

415 

416 first_unknown: tuple[str, str] | None = None # (raw_type, suggestion) 

417 for m in matches: 

418 raw_type = (m.group(1) or "") 

419 os_type_candidate = raw_type.lower() 

420 if raw_type == "" or os_type_candidate in _OS_TYPES: 

421 return (m, raw_type, m.group(2), m.group(3), m.group(4)) 

422 if first_unknown is None: 

423 first_unknown = (raw_type, _closest_match(os_type_candidate, _OS_TYPES) or "") 

424 

425 # There were markers, but all with unknown types. Hard-fail with a 

426 # hint pointing at the first — that is very likely the author's typo. 

427 raw_type, suggestion = first_unknown # type: ignore[misc] 

428 hint = f"; meintest du '{suggestion}'?" if suggestion else "" 

429 raise MarkerError( 

430 var_name, 

431 f"unbekannter resource-type '{raw_type}'{hint} — " 

432 f"erwartet: {sorted(_OS_TYPES)}", 

433 code="MARKER_UNKNOWN_OS_TYPE", 

434 ) 

435 

436 

437def _parse_scope_only_marker( 

438 var_name: str, source: str, raw_mode: str | None, raw_multi: str | None, raw_scope: str | None 

439): 

440 """Parse a scope-only marker (empty type slot, e.g. ``@openstack:::team``). 

441 

442 Such a marker has no type/mode/multi — only scope meaning. When the 

443 author uses a short form (``@openstack::team`` with two slots instead 

444 of four), ``team`` lands in the mode slot rather than the fourth. We 

445 take the first non-empty slot of mode/multi/scope and accept it as 

446 long as it is a var_scope token — this makes marker spelling robust 

447 against the number of colons. Several occupied slots at once remain 

448 an error (ambiguous). 

449 """ 

450 candidates = [s for s in (raw_mode, raw_multi, raw_scope) if s not in (None, "")] 

451 if len(candidates) > 1: 

452 raise MarkerError( 

453 var_name, 

454 "leerer type-slot ist nur in Kombination mit ``var_scope`` " 

455 "erlaubt (z.B. ``@openstack:::team``); mehrere belegte " 

456 "Slots sind hier nicht zulässig", 

457 code="MARKER_EMPTY_TYPE_AMBIGUOUS", 

458 ) 

459 var_scope = _parse_var_scope(var_name, candidates[0] if candidates else None) 

460 if var_scope is None: 

461 raise MarkerError( 

462 var_name, 

463 "leerer Marker — entweder einen resource-type angeben " 

464 "(z.B. ``@openstack:flavor``) oder einen var_scope " 

465 "(z.B. ``@openstack:::team``)", 

466 code="MARKER_EMPTY", 

467 ) 

468 _forbid_packer_team_user_scope(var_name, source, var_scope) 

469 return (None, None, None, None, var_scope, None) 

470 

471 

472def _parse_file_marker( 

473 var_name: str, source: str, raw_mode: str | None, raw_multi: str | None, raw_scope: str | None 

474): 

475 """Parse a ``@openstack:file`` marker. 

476 

477 File markers have their own slot semantics: the mode slot carries the 

478 scope (``all``/``team``/``user``) and the multi slot carries the 

479 MANDATORY extension filter (``pdf`` or ``pdf|docx``). Handled 

480 separately so the generic mode/multi logic stays untouched. 

481 """ 

482 if source == "packer": 

483 # Packer builds an image — file variables would never reach the 

484 # build (the files path today merges hard-coded into 

485 # ``userInputVar.terraform``). Rather than a silent trap: a 

486 # marker error. 

487 raise MarkerError( 

488 var_name, 

489 "``@openstack:file`` ist in Packer-Variablen nicht " 

490 "unterstützt — Dateien werden ausschließlich im " 

491 "Terraform-Pfad zugestellt", 

492 code="MARKER_FILE_PACKER_FORBIDDEN", 

493 ) 

494 

495 file_scope: str | None = None 

496 if raw_mode is not None and raw_mode != "": 

497 rs = raw_mode.lower() 

498 if rs in _FILE_SCOPES: 

499 file_scope = rs 

500 else: 

501 scope_suggestion = _closest_match(rs, _FILE_SCOPES) 

502 hint = f"; meintest du '{scope_suggestion}'?" if scope_suggestion else "" 

503 raise MarkerError( 

504 var_name, 

505 f"ungültiger file-scope '{raw_mode}'{hint} — erwartet " 

506 f"{sorted(_FILE_SCOPES)}", 

507 code="MARKER_INVALID_FILE_SCOPE", 

508 ) 

509 

510 # The multi slot is now the mandatory extensions filter. An empty 

511 # slot is an error — file variables need an explicit allow-list so 

512 # the wizard can filter in the ``accept`` attribute and the backend 

513 # upload has a clear validation path. 

514 # 

515 # Regex detail: for values with ``|`` (e.g. ``pdf|docx``) the content 

516 # lands in the fourth slot instead of the third, because the third 

517 # slot does not accept a pipe. We accept that transparently — both 

518 # positions are checked for the extensions content. 

519 exts_slot: str | None = None 

520 if raw_multi not in (None, ""): 

521 exts_slot = raw_multi 

522 if raw_scope not in (None, ""): 

523 raise MarkerError( 

524 var_name, 

525 f"@openstack:file akzeptiert keinen fünften Slot " 

526 f"(angegeben: '{raw_scope}') — der Scope steht im " 

527 f"dritten Slot (z.B. ``@openstack:file:user:pdf``)", 

528 code="MARKER_FILE_EXTRA_SLOT", 

529 ) 

530 elif raw_scope not in (None, ""): 

531 exts_slot = raw_scope 

532 if exts_slot is None: 

533 raise MarkerError( 

534 var_name, 

535 "``@openstack:file`` braucht einen Endungsfilter im " 

536 "vierten Slot, z.B. ``@openstack:file:all:pdf`` oder " 

537 "``@openstack:file:user:pdf|docx``", 

538 code="MARKER_FILE_MISSING_EXTENSIONS", 

539 ) 

540 exts_raw = exts_slot.lower() 

541 if not _FILE_EXTENSIONS_RE.match(exts_raw): 

542 raise MarkerError( 

543 var_name, 

544 f"ungültiger Endungsfilter '{exts_slot}' — erlaubt sind " 

545 f"alphanumerische Endungen, mehrere getrennt mit '|' " 

546 f"(z.B. ``pdf|docx``)", 

547 code="MARKER_FILE_INVALID_EXTENSIONS", 

548 ) 

549 file_exts = exts_raw.split("|") 

550 

551 return ("file", None, None, file_scope, file_scope, file_exts) 

552 

553 

554def _parse_marker_mode(var_name: str, os_type: str, raw_mode: str | None) -> str | None: 

555 """Parse the mode slot (``id``/``name``) of a resource marker. 

556 

557 Empty slot → ``None`` (defaults applied by the caller). Raises with a 

558 targeted hint when the author placed a multi-flag or a var_scope into 

559 the mode slot, or used an unknown token. 

560 """ 

561 if raw_mode is None: 

562 return None 

563 rm = raw_mode.lower() 

564 if rm == "": 

565 # An empty slot is allowed: ``@openstack:flavor::multi`` means 

566 # "mode = default, multi = multi". We leave ``mode = None``; the 

567 # defaults are applied by the caller. 

568 return None 

569 if rm in ("id", "name"): 

570 return rm 

571 if rm in ("multi", "list", "single"): 

572 # Common author mistake: the user wanted to set ``:multi`` but 

573 # didn't leave the mode slot empty. Instead of a generic "invalid 

574 # mode" message, show the correct marker. 

575 raise MarkerError( 

576 var_name, 

577 f"'{raw_mode}' ist ein multi-Flag, nicht ein Mode — " 

578 f"schreibe den Marker mit leerem Mode-Slot, z.B. " 

579 f"``@openstack:{os_type}::{rm}``", 

580 code="MARKER_MULTI_IN_MODE_SLOT", 

581 ) 

582 if rm in _VAR_SCOPES: 

583 # var-scope-in-mode-slot: same logic as multi-in-mode-slot. The 

584 # app author wanted to set the ``var_scope`` but didn't leave the 

585 # middle slots empty (``@openstack:flavor:team`` instead of 

586 # ``@openstack:flavor:::team``). Instead of a cryptic "invalid 

587 # mode" message, show the correct marker. 

588 raise MarkerError( 

589 var_name, 

590 f"'{raw_mode}' ist ein var_scope, nicht ein Mode — " 

591 f"schreibe den Marker mit leerem Mode-/Multi-Slot, z.B. " 

592 f"``@openstack:{os_type}:::{rm}``", 

593 code="MARKER_SCOPE_IN_MODE_SLOT", 

594 ) 

595 mode_suggestion = _closest_match(rm, {"id", "name"}) 

596 hint = f"; meintest du '{mode_suggestion}'?" if mode_suggestion else "" 

597 raise MarkerError( 

598 var_name, 

599 f"ungültiger mode '{raw_mode}'{hint} — erwartet 'id' oder 'name'", 

600 code="MARKER_INVALID_MODE", 

601 ) 

602 

603 

604def _parse_marker_multi(var_name: str, raw_multi: str | None) -> bool | None: 

605 """Parse the multi slot (``multi``/``list``/``single``) of a marker. 

606 

607 ``list`` is a synonym for ``multi``. Empty slot → ``None``. 

608 """ 

609 if raw_multi is None: 

610 return None 

611 mm = raw_multi.lower() 

612 if mm == "": 

613 return None 

614 if mm in ("multi", "list"): 

615 return True 

616 if mm == "single": 

617 return False 

618 multi_suggestion = _closest_match(mm, {"multi", "list", "single"}) 

619 hint = f"; meintest du '{multi_suggestion}'?" if multi_suggestion else "" 

620 raise MarkerError( 

621 var_name, 

622 f"ungültiger multi-Flag '{raw_multi}'{hint} — erwartet " 

623 "'multi', 'list' oder 'single'", 

624 code="MARKER_INVALID_MULTI", 

625 ) 

626 

627 

628def _collection_check_type(type_lower: str, var_scope: str | None) -> str: 

629 """Return the HCL type to run the collection check against. 

630 

631 For scope team/user the wizard contract requires a ``map(...)`` HCL 

632 type. A naive ``is_collection`` check would fail (``map(list(string))`` 

633 starts with ``map(``) even though the inner element type is a real 

634 collection. For scoped markers we unwrap the outer ``map(...)`` and 

635 check the INNER type against the multi expectation. 

636 """ 

637 if var_scope not in ("team", "user") or not type_lower.startswith("map("): 

638 return type_lower 

639 # Bracket-balance the inner part out of ``map(...)``. Naive 

640 # ``[4:-1]`` slicing isn't enough because nested ``map(map(...))`` is 

641 # legitimate — we walk the characters once and count parentheses. 

642 depth = 0 

643 start = type_lower.find("(") 

644 inner_end = -1 

645 for i in range(start, len(type_lower)): 

646 ch = type_lower[i] 

647 if ch == "(": 

648 depth += 1 

649 elif ch == ")": 

650 depth -= 1 

651 if depth == 0: 

652 inner_end = i 

653 break 

654 if inner_end > start + 1: 

655 return type_lower[start + 1:inner_end].strip() 

656 return type_lower 

657 

658 

659def _check_multi_type_conflict( 

660 var_name: str, var_type: str, type_for_collection_check: str, multi: bool | None 

661) -> None: 

662 """Cross-check the marker's ``:multi``/``:single`` against the HCL type. 

663 

664 ``list``/``set``/``tuple`` are the collection-capable picker types; 

665 for these ``:multi`` is natural. ``map``/``object`` are technical 

666 collections the picker can't drive, so they're treated like 

667 single-strings for conflict detection. 

668 """ 

669 is_collection_type = ( 

670 type_for_collection_check.startswith(("list(", "set(", "tuple(")) 

671 or type_for_collection_check in ("list", "set") 

672 ) 

673 if multi is True and not is_collection_type and type_for_collection_check not in ("", "string"): 

674 # ``string`` is let through because many apps declare 

675 # ``type = string`` without a multi-marker and the frontend then 

676 # delivers CSV anyway. But e.g. ``type = number`` or 

677 # ``type = map(...)`` with ``:multi`` is clearly contradictory. 

678 raise MarkerError( 

679 var_name, 

680 f"marker deklariert ':multi', aber HCL-Type ist '{var_type}' " 

681 "— erlaubt sind nur ``string``, ``list(...)``, ``set(...)`` " 

682 "und ``tuple(...)``", 

683 code="MARKER_MULTI_TYPE_CONFLICT", 

684 ) 

685 if multi is False and is_collection_type: 

686 raise MarkerError( 

687 var_name, 

688 f"marker deklariert ':single', aber HCL-Type ist '{var_type}' " 

689 "(eine list/set/tuple-Kollektion) — fixe einen der beiden", 

690 code="MARKER_SINGLE_TYPE_CONFLICT", 

691 ) 

692 

693 

694def _parse_resource_marker( 

695 var_name: str, 

696 var_type: str, 

697 source: str, 

698 os_type: str, 

699 raw_mode: str | None, 

700 raw_multi: str | None, 

701 raw_scope: str | None, 

702): 

703 """Parse a generic (non-file) resource marker: mode, multi, scope.""" 

704 mode = _parse_marker_mode(var_name, os_type, raw_mode) 

705 multi = _parse_marker_multi(var_name, raw_multi) 

706 

707 type_lower = (var_type or "").strip().lower() 

708 # Parse the scope once; reused for both the inner-type collection 

709 # lookup and the returned value so the same error can't fire twice. 

710 var_scope = _parse_var_scope(var_name, raw_scope) 

711 type_for_collection_check = _collection_check_type(type_lower, var_scope) 

712 _check_multi_type_conflict(var_name, var_type, type_for_collection_check, multi) 

713 

714 _forbid_packer_team_user_scope(var_name, source, var_scope) 

715 return (os_type, mode, multi, None, var_scope, None) 

716 

717 

718def _parse_marker( 

719 var_name: str, var_type: str, description: str, source: str = "terraform" 

720) -> tuple[str | None, str | None, bool | None, str | None, str | None, list[str] | None]: 

721 """ 

722 Parse the ``@openstack:<type>[:<mode>][:<multi>][:<var_scope>]`` marker 

723 from the description. Returns ``(None, None, None, None, None, None)`` 

724 when NO marker is present (not an error — the variable renders as free 

725 text). 

726 

727 Multi-marker behavior: if several markers are found, the first with a 

728 known type OR an empty type slot (a pure var_scope marker) is used. 

729 This is intentionally tolerant. Mode/multi validation errors of the 

730 chosen marker remain hard failures. 

731 

732 Raises ``MarkerError`` on: 

733 - a malformed marker (too many segments, internal whitespace, 

734 unknown mode/multi/scope tokens, wrong slot separators) 

735 - a marker contradicting the HCL type (``:single`` with 

736 ``type = list(...)`` or ``:multi`` with ``type = number``; 

737 ``:team``/``:user`` with ``type = string``) 

738 - file-specific: invalid scope, missing extension filter, or an 

739 invalid filter. 

740 - packer source with ``var_scope in {team, user}``. 

741 

742 Returns: ``(os_type, mode, multi, file_scope, var_scope, file_exts)``. 

743 

744 * ``os_type`` — None when the marker had an empty type (pure 

745 var_scope marker). 

746 * ``mode`` — set for non-file only. 

747 * ``multi`` — set for non-file only. 

748 * ``file_scope`` — set for file only (``all``/``team``/``user``). 

749 * ``var_scope`` — generic scope (``all``/``team``/``user``); for file 

750 variables it mirrors ``file_scope`` so the wizard 

751 has one source for slot resolution. 

752 * ``file_exts`` — set for file only: list of allowed extensions 

753 (e.g. ``["pdf", "docx"]``), order stable. 

754 

755 The heavy lifting is delegated to focused helpers: this function only 

756 orchestrates the pipeline (reject malformed → select marker → 

757 dispatch to the scope-only / file / generic resource parser). 

758 """ 

759 if not description: 

760 return (None, None, None, None, None, None) 

761 

762 _reject_malformed_markers(var_name, description) 

763 

764 chosen = _select_marker(var_name, description) 

765 if chosen is None: 

766 return (None, None, None, None, None, None) 

767 

768 _, raw_type, raw_mode, raw_multi, raw_scope = chosen 

769 os_type: str | None = raw_type.lower() if raw_type else None 

770 

771 if os_type is None: 

772 return _parse_scope_only_marker(var_name, source, raw_mode, raw_multi, raw_scope) 

773 

774 if os_type == "file": 

775 return _parse_file_marker(var_name, source, raw_mode, raw_multi, raw_scope) 

776 

777 return _parse_resource_marker( 

778 var_name, var_type, source, os_type, raw_mode, raw_multi, raw_scope 

779 ) 

780 

781 

782 

783def _apply_defaults( 

784 os_type: str, mode: str | None, multi: bool | None, var_type: str 

785) -> tuple[str, bool]: 

786 """ 

787 Apply the documented defaults when the marker leaves slots empty: 

788 

789 - ``mode``: 'name'. For ``_NAME_ONLY_TYPES`` (keypair, availability 

790 zone, floating-IP pool) 'name' is effectively the only useful 

791 choice; ``:id`` is respected but yields little. 

792 - ``multi``: derived from the HCL type — ``list``/``set``/``tuple`` 

793 → True, else False. 

794 """ 

795 if mode is None: 

796 mode = "name" 

797 

798 if multi is None: 

799 type_lower = (var_type or "").strip().lower() 

800 # ``map(...)``/``object({...})`` are technically collections but 

801 # the picker can't drive them, so we treat them as "single" and 

802 # leave it to the author to request ``:multi`` explicitly. 

803 # ``list``/``set``/``tuple`` are auto-detected as multi. 

804 multi = ( 

805 type_lower.startswith(("list(", "set(", "tuple(")) 

806 or type_lower in ("list", "set") 

807 ) 

808 

809 return (mode, multi) 

810 

811 

812def _closest_match(s: str, candidates: set[str]) -> str | None: 

813 """ 

814 Simple Levenshtein-1 heuristic for "did you mean …?" hints. 

815 ``difflib`` is imported lazily since this is the only place it's used. 

816 """ 

817 if not s: 

818 return None 

819 matches = difflib.get_close_matches(s, candidates, n=1, cutoff=0.7) 

820 return matches[0] if matches else None 

821 

822 

823def _line_number_at(content: str, char_index: int) -> int: 

824 """1-based line index for a char position. Used to point 

825 MarkerError messages at the line in ``variables.tf`` instead of only 

826 naming the variable.""" 

827 return content.count("\n", 0, char_index) + 1 

828 

829 

830def _validate_file_var_shape(var_name: str, var_type: str, scope: str) -> None: 

831 """Verify a ``@openstack:file:<scope>``-marked variable has the 

832 HCL type the wizard contract expects. 

833 

834 The contract — documented in the deploy/file-uploads design — is: 

835 

836 * ``scope = all`` → ``map(object({...}))`` 

837 * ``scope = team`` → ``map(map(object({...})))`` 

838 * ``scope = user`` → ``map(map(object({...})))`` 

839 

840 The outer map keys content by upload-key (today always 

841 ``"uploaded"``, reserved for future multi-file-per-slot). For 

842 ``team``/``user`` the next layer keys by team name resp. 

843 ``Team-User``-pair so the worker can route per-recipient bytes. 

844 

845 We don't try to parse HCL — we just check the prefix shape with 

846 cheap string ops. False positives are unlikely (no real-world HCL 

847 type accidentally starts with ``map(map(`` unless it is one) and 

848 a strict full parse would be a big dependency for one check. 

849 """ 

850 type_normalised = (var_type or "").strip().lower().replace(" ", "") 

851 if scope == "all" and not type_normalised.startswith("map(object("): 

852 raise MarkerError( 

853 var_name, 

854 f"marker ``@openstack:file:all`` erwartet HCL-Type " 

855 f"``map(object({{name=string, content_b64=string, " 

856 f"size=number, content_type=string}}))`` — angegeben: '{var_type}'", 

857 code="MARKER_FILE_TYPE_SHAPE", 

858 ) 

859 if scope in ("team", "user") and not type_normalised.startswith("map(map(object("): 

860 raise MarkerError( 

861 var_name, 

862 f"marker ``@openstack:file:{scope}`` erwartet HCL-Type " 

863 f"``map(map(object({{name=string, content_b64=string, " 

864 f"size=number, content_type=string}})))`` — angegeben: '{var_type}'", 

865 code="MARKER_FILE_TYPE_SHAPE", 

866 ) 

867 

868 

869def _validate_scoped_var_shape(var_name: str, var_type: str, scope: str) -> None: 

870 """Verify a non-file variable marked with ``var_scope = team|user`` 

871 has a map-typed HCL declaration. 

872 

873 Reasoning: bei ``team``/``user``-Scope schickt der Wizard eine Map 

874 (slot_key → value) an Terraform/Packer. Wenn der HCL-Type ein 

875 Skalar ist (``string``, ``number``, ...), würde Terraform die Map 

876 beim Apply ablehnen. Wir fangen das hier ab, damit der App-Autor 

877 den Fehler bei ``GET /apps/{id}/variables`` sieht und nicht erst 

878 beim ersten Deploy. 

879 

880 Bei ``scope = all`` (oder fehlendem Scope) gilt das nicht — dann 

881 rendert der Wizard genau EIN Eingabefeld, das wie heute direkt 

882 als Skalar oder Liste an Terraform durchgereicht wird. 

883 """ 

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

885 return 

886 type_normalised = (var_type or "").strip().lower().replace(" ", "") 

887 if not type_normalised.startswith("map(") and type_normalised not in ("map",): 

888 raise MarkerError( 

889 var_name, 

890 f"marker deklariert ``var_scope = {scope}``, aber HCL-Type " 

891 f"ist '{var_type}'. Pro Scope-Eintrag liefert der Wizard " 

892 f"eine Map (slot_key → value), also muss der HCL-Type " 

893 f"``map(...)`` sein — z.B. ``map(string)`` oder " 

894 f"``map(list(string))``.", 

895 code="MARKER_SCOPED_REQUIRES_MAP", 

896 ) 

897 

898 

899def _coerce_hcl_default(raw_default: str, var_type: str) -> tuple[Any, bool]: 

900 """Coerce an HCL default literal into its Python equivalent so the 

901 frontend sees ``default = 2`` as ``2`` (number) rather than ``"2"`` 

902 (string). Returns ``(value, required)`` — an HCL ``null`` default 

903 yields ``None`` AND ``required = True`` (Terraform treats null as "no 

904 default"). 

905 

906 Robust against minor whitespace and trailing commas; any parse error 

907 falls back to the raw string. 

908 """ 

909 if raw_default is None: 

910 return (None, True) 

911 

912 stripped = raw_default.strip() 

913 if stripped == "": 

914 return (None, True) 

915 

916 # Literal HCL ``null`` → Variable ist required. 

917 if stripped.lower() == "null": 

918 return (None, True) 

919 

920 type_lower = (var_type or "").strip().lower() 

921 

922 # Bool first, otherwise ``"true"`` as a string default is caught by 

923 # the string path. 

924 if type_lower == "bool": 

925 if stripped.lower() == "true": 

926 return (True, False) 

927 if stripped.lower() == "false": 

928 return (False, False) 

929 

930 if type_lower == "number": 

931 try: 

932 if "." in stripped or "e" in stripped.lower(): 

933 return (float(stripped), False) 

934 return (int(stripped), False) 

935 except ValueError: 

936 return (stripped, False) 

937 

938 is_list_like = ( 

939 type_lower.startswith(("list(", "set(", "tuple(")) 

940 or type_lower in ("list", "set") 

941 ) 

942 is_map_like = type_lower.startswith("map(") or type_lower in ("map", "object") 

943 

944 if is_list_like or is_map_like or stripped.startswith(("[", "{")): 

945 # python-hcl2 would be the clean option, but it isn't available 

946 # in the backend right now and a lazy import would make the 

947 # import path fragile. Instead we use json.loads — HCL literals 

948 # for lists/maps with string/number/bool values are a true 

949 # subset of JSON. 

950 try: 

951 return (json.loads(stripped), False) 

952 except (ValueError, TypeError): 

953 # Fallback: HCL allows unquoted identifiers as strings 

954 # (``[NAT]``) and ``true``/``false``/``null`` as values. Try 

955 # a gentle pre-tokenize step; on further failure the string 

956 # passes through unchanged. 

957 try: 

958 normalised = re.sub( 

959 r"\b(true|false|null)\b", 

960 lambda m: m.group(0).lower(), 

961 stripped, 

962 flags=re.IGNORECASE, 

963 ) 

964 return (json.loads(normalised), False) 

965 except (ValueError, TypeError): 

966 return (stripped, False) 

967 

968 # String (or unknown type): strip outer quotes if the caller hasn't. 

969 if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in ('"', "'"): 

970 return (stripped[1:-1], False) 

971 return (stripped, False) 

972 

973 

974def _parse_one_variable( 

975 *, 

976 var_name: str, 

977 var_block: str, 

978 var_block_offset: int, 

979 file_content: str, 

980 file_label: str, 

981 source: str, 

982) -> dict[str, Any]: 

983 """ 

984 Process a single ``variable "..." { ... }`` block. 

985 

986 Always returns the variable dict; marker errors are NOT raised but 

987 attached to the variable in the ``markerError`` field, so the frontend 

988 can render the variable as free text and show the error inline instead 

989 of breaking the whole wizard on one bad marker. 

990 """ 

991 # Extract type 

992 type_match = re.search(r'type\s*=\s*([^\n]+)', var_block) 

993 var_type = type_match.group(1).strip() if type_match else "string" 

994 

995 # Extract description 

996 desc_match = re.search(r'description\s*=\s*"([^"]*)"', var_block) 

997 description = desc_match.group(1) if desc_match else "" 

998 

999 # Extract default value 

1000 default_match = re.search(r'default\s*=\s*([^\n]+)', var_block) 

1001 default_raw = default_match.group(1).strip() if default_match else None 

1002 

1003 # Coerce HCL defaults into their Python equivalents (number→int/float, 

1004 # bool→bool, list/map→lists/dicts). ``null`` resets ``required`` to 

1005 # True. On parse error the value falls back to the raw string. 

1006 try: 

1007 default_value, required = _coerce_hcl_default(default_raw, var_type) 

1008 except Exception: 

1009 # Defensive: no HCL edge case should crash the wizard. Worst 

1010 # case, keep the raw string with required=False if a default was 

1011 # present. 

1012 default_value = default_raw 

1013 required = default_raw is None 

1014 

1015 var_info: dict[str, Any] = { 

1016 "name": var_name, 

1017 "type": var_type, 

1018 "description": description, 

1019 "default": default_value, 

1020 "required": required, 

1021 "source": source, 

1022 } 

1023 

1024 # Evaluate @openstack markers. Per-variable try/except: a typo in ONE 

1025 # variable description must not block the whole wizard; the error 

1026 # travels in the payload alongside the variable. 

1027 try: 

1028 ( 

1029 os_type, 

1030 raw_mode, 

1031 raw_multi, 

1032 file_scope, 

1033 var_scope, 

1034 file_exts, 

1035 ) = _parse_marker(var_name, var_type, description, source=source) 

1036 # File variables have a hard contract with cloud-init: the wizard 

1037 # must know whether to render a single slot (scope=all), a map 

1038 # over teams, or a map over users. The HCL type nesting must match 

1039 # the scope or Terraform rejects the decode at apply — we catch it 

1040 # here and give the author a clear error. 

1041 if os_type == "file": 

1042 _validate_file_var_shape(var_name, var_type, file_scope or "all") 

1043 elif var_scope: 

1044 _validate_scoped_var_shape(var_name, var_type, var_scope) 

1045 except MarkerError as exc: 

1046 line = _line_number_at(file_content, var_block_offset) 

1047 var_info["markerError"] = { 

1048 "variable": exc.var_name, 

1049 "message": exc.message, 

1050 "location": f"{file_label}:{line}", 

1051 # ``code`` is the stable key for future i18n / frontend logic. 

1052 "code": exc.code, 

1053 } 

1054 return var_info 

1055 

1056 if os_type: 

1057 if os_type == "file": 

1058 # File variables are neither mode- nor multi-driven; the 

1059 # wizard renders a FileDropZone, not the resource picker. 

1060 # ``osMode`` and ``osMulti`` are deliberately left unset so 

1061 # the frontend reads the absence as "not applicable" rather 

1062 # than inventing a default. 

1063 var_info["osType"] = os_type 

1064 var_info["osScope"] = file_scope or "all" 

1065 if file_exts: 

1066 var_info["fileExtensions"] = file_exts 

1067 else: 

1068 mode, multi = _apply_defaults(os_type, raw_mode, raw_multi, var_type) 

1069 var_info["osType"] = os_type 

1070 var_info["osMode"] = mode 

1071 var_info["osMulti"] = multi 

1072 

1073 # ``varScope`` is orthogonal to the resource type — even a free-text 

1074 # variable (no ``osType``) can be scoped. For file variables we mirror 

1075 # ``osScope`` into ``varScope`` so the frontend reads one source. 

1076 if var_scope: 

1077 var_info["varScope"] = var_scope 

1078 elif os_type == "file": 

1079 var_info["varScope"] = file_scope or "all" 

1080 

1081 return var_info 

1082 

1083 

1084def _iter_variable_blocks(content: str): 

1085 """Yield ``(var_name, var_block, block_offset)`` for each HCL 

1086 ``variable "name" { ... }`` block, brace-balanced. 

1087 

1088 A naive ``variable\\s+"([^"]+)"\\s*\\{([^}]+)\\}`` regex stops the 

1089 block at the FIRST ``}`` and truncates any variable whose type or 

1090 default literal contains braces — e.g. ``type = object({...})``, 

1091 ``map(...)`` or ``default = {}``. Instead we match only the block 

1092 HEAD and then walk the string counting ``{``/``}`` until depth 

1093 returns to zero. 

1094 

1095 ``var_block`` is the content BETWEEN the outer braces (exclusive); 

1096 ``block_offset`` is the start index of the whole ``variable`` 

1097 declaration (used for line-number hints). 

1098 """ 

1099 head_pattern = r'variable\s+"([^"]+)"\s*\{' 

1100 for head in re.finditer(head_pattern, content): 

1101 var_name = head.group(1) 

1102 block_offset = head.start() 

1103 open_brace = head.end() - 1 # index of the ``{`` matched above 

1104 depth = 0 

1105 end_index = -1 

1106 for i in range(open_brace, len(content)): 

1107 ch = content[i] 

1108 if ch == "{": 

1109 depth += 1 

1110 elif ch == "}": 

1111 depth -= 1 

1112 if depth == 0: 

1113 end_index = i 

1114 break 

1115 if end_index == -1: 

1116 # Unbalanced braces — skip this malformed block rather than 

1117 # emitting a truncated one. 

1118 continue 

1119 var_block = content[open_brace + 1:end_index] 

1120 yield var_name, var_block, block_offset 

1121 

1122 

1123def _parse_terraform_variables(file_path: str) -> list[dict[str, Any]]: 

1124 """Parse Terraform `variables.tf` file. Per-variable marker errors 

1125 travel in the ``markerError`` field (not raised) — see 

1126 ``_parse_one_variable``.""" 

1127 with open(file_path) as f: 

1128 content = f.read() 

1129 

1130 variables = [] 

1131 for var_name, var_block, block_offset in _iter_variable_blocks(content): 

1132 # Filter: drop ``users`` and ``image_name`` 

1133 if var_name == "users" or var_name == "image_name": 

1134 continue 

1135 # Multi-image apps declare ``image_name_<key>`` per template and 

1136 # mark those declarations with ``@platform:internal`` in the 

1137 # description. The worker fills these from the discovered Packer 

1138 # templates; the wizard must not surface them as user-editable 

1139 # variables. Same rationale as the ``image_name``/``users`` 

1140 # filter above — these are platform-injected, not user input. 

1141 desc_match = re.search(r'description\s*=\s*"([^"]*)"', var_block) 

1142 description = desc_match.group(1) if desc_match else "" 

1143 if "@platform:internal" in description: 

1144 continue 

1145 variables.append(_parse_one_variable( 

1146 var_name=var_name, 

1147 var_block=var_block, 

1148 var_block_offset=block_offset, 

1149 file_content=content, 

1150 file_label="terraform/variables.tf", 

1151 source="terraform", 

1152 )) 

1153 

1154 return variables 

1155 

1156 

1157def _parse_packer_variables(file_path: str, template_key: str = "default") -> list[dict[str, Any]]: 

1158 """Parse Packer `variables.pkr.hcl` file. Per-variable marker errors 

1159 travel in the ``markerError`` field; see ``_parse_one_variable``. 

1160 

1161 ``template_key`` is recorded on each variable so the wizard can 

1162 group Packer variables per template (and avoid name collisions 

1163 across templates in multi-image apps). For the single-template 

1164 layout the caller passes ``"default"``. 

1165 """ 

1166 with open(file_path) as f: 

1167 content = f.read() 

1168 

1169 variables = [] 

1170 for var_name, var_block, block_offset in _iter_variable_blocks(content): 

1171 # Filter: image_name rauslassen 

1172 if var_name == "image_name": 

1173 continue 

1174 var_info = _parse_one_variable( 

1175 var_name=var_name, 

1176 var_block=var_block, 

1177 var_block_offset=block_offset, 

1178 file_content=content, 

1179 file_label=f"packer/{template_key}/variables.pkr.hcl" 

1180 if template_key != "default" 

1181 else "packer/variables.pkr.hcl", 

1182 source="packer", 

1183 ) 

1184 var_info["template_key"] = template_key 

1185 variables.append(var_info) 

1186 

1187 return variables 

1188 

1189 

1190# ---------------------------------------------------------------- 

1191# PACKER TEMPLATE DISCOVERY 

1192# ---------------------------------------------------------------- 

1193# Apps may ship Packer templates in one of two layouts: 

1194# 

1195# 1. Legacy single-template layout: 

1196# packer/template.pkr.hcl 

1197# packer/variables.pkr.hcl 

1198# → exactly ONE image, conventionally keyed ``default``. The 

1199# worker injects ``image_name`` (a single Terraform variable). 

1200# 

1201# 2. Multi-template layout: 

1202# packer/<key>/template.pkr.hcl 

1203# packer/<key>/variables.pkr.hcl (optional) 

1204# → one image per ``<key>``. The worker injects one 

1205# ``image_name_<key>`` Terraform variable per template, each 

1206# marked ``@platform:internal`` in its description so the wizard 

1207# skips them. 

1208# 

1209# Discovery rules: 

1210# - No ``packer/`` directory → returns ``[]`` (no Packer phase). 

1211# - Legacy file present → returns ``[_PackerTemplate("default", ...)]``. 

1212# - Subdirectories with a 

1213# ``template.pkr.hcl`` → returns one entry per subdir, sorted. 

1214# - Both legacy AND subdirs → ``PackerTemplateDiscoveryError`` (hard). 

1215# - Subdir without 

1216# ``template.pkr.hcl`` → ignored (e.g. ``_common/``, ``scripts/``). 

1217# - Subdir with a key that 

1218# doesn't match the pattern → ``PackerTemplateDiscoveryError``. 

1219# 

1220# Key pattern is intentionally narrow (``[a-z][a-z0-9_-]{0,30}``) so 

1221# the key is safe to embed in Terraform variable names and image 

1222# tags without quoting. 

1223# ---------------------------------------------------------------- 

1224 

1225@dataclass 

1226class _PackerTemplate: 

1227 """One Packer template discovered under ``<repo>/packer``. 

1228 

1229 ``variables_path`` may point at a non-existing file — the caller 

1230 must check ``os.path.isfile`` before reading it. We don't filter 

1231 here because the file is optional and a missing one is not an 

1232 error. 

1233 """ 

1234 

1235 key: str 

1236 template_path: str 

1237 variables_path: str 

1238 

1239 

1240_TEMPLATE_KEY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,30}$") 

1241 

1242 

1243class PackerTemplateDiscoveryError(ValueError): 

1244 """Raised when the Packer directory has a layout the platform can't 

1245 reconcile (ambiguous, contradictory, or with an unsafe key). 

1246 

1247 Translated to HTTP 422 at the load_variable_definitions boundary 

1248 so the app author sees the error immediately on the first 

1249 ``GET /apps/{id}/variables`` instead of at first deploy. 

1250 """ 

1251 

1252 

1253def _discover_packer_templates(repo_path: str) -> list[_PackerTemplate]: 

1254 """Walk ``<repo_path>/packer`` and return the list of templates the 

1255 worker will build for this app. 

1256 

1257 See the section docstring above for the layout rules. Returns 

1258 ``[]`` for apps without any Packer at all (Terraform-only). 

1259 """ 

1260 packer_dir = os.path.join(repo_path, "packer") 

1261 if not os.path.isdir(packer_dir): 

1262 return [] 

1263 

1264 legacy_template = os.path.join(packer_dir, "template.pkr.hcl") 

1265 has_legacy = os.path.isfile(legacy_template) 

1266 

1267 multi_templates: list[_PackerTemplate] = [] 

1268 bad_keys: list[str] = [] 

1269 for entry in sorted(os.listdir(packer_dir)): 

1270 sub = os.path.join(packer_dir, entry) 

1271 if not os.path.isdir(sub): 

1272 continue 

1273 tmpl = os.path.join(sub, "template.pkr.hcl") 

1274 if not os.path.isfile(tmpl): 

1275 # Subdirs without a template (``_common/``, ``scripts/``, 

1276 # ``http/`` for boot-time HTTP servers, ...) are silently 

1277 # ignored — they're tooling, not images to build. 

1278 continue 

1279 if not _TEMPLATE_KEY_RE.match(entry): 

1280 bad_keys.append(entry) 

1281 continue 

1282 multi_templates.append(_PackerTemplate( 

1283 key=entry, 

1284 template_path=tmpl, 

1285 variables_path=os.path.join(sub, "variables.pkr.hcl"), 

1286 )) 

1287 

1288 if bad_keys: 

1289 raise PackerTemplateDiscoveryError( 

1290 f"Packer template subdirectories with invalid keys " 

1291 f"(must match [a-z][a-z0-9_-]{{0,30}}): {bad_keys}" 

1292 ) 

1293 

1294 if has_legacy and multi_templates: 

1295 raise PackerTemplateDiscoveryError( 

1296 "App repository has BOTH packer/template.pkr.hcl (legacy " 

1297 "layout) AND packer/<key>/template.pkr.hcl subdirectories " 

1298 f"({[t.key for t in multi_templates]}). Choose one layout " 

1299 "— remove the legacy file or the subdirectories." 

1300 ) 

1301 

1302 if has_legacy: 

1303 return [_PackerTemplate( 

1304 key="default", 

1305 template_path=legacy_template, 

1306 variables_path=os.path.join(packer_dir, "variables.pkr.hcl"), 

1307 )] 

1308 

1309 return multi_templates 

1310 

1311 

1312# ---------------------------------------------------------------- 

1313# GET ALL APPS 

1314# ---------------------------------------------------------------- 

1315@router.get("/", response_model=list[AppResponse]) 

1316def list_apps( 

1317 skip: int = 0, 

1318 limit: int = 100, 

1319 db: Session = Depends(get_db), 

1320 current_user: User = Depends(get_current_user_keycloak) 

1321): 

1322 """List apps visible to the current user. 

1323 

1324 Admins see every non-deleted app (full platform view). Everyone else, 

1325 including teachers, sees the student-style filter: own apps + public 

1326 apps with at least one approved version. 

1327 """ 

1328 if current_user.role == UserRole.ADMIN: 

1329 apps = crud_apps.get_apps(db, skip=skip, limit=limit) 

1330 else: 

1331 apps = crud_apps.get_visible_apps(db, current_user.userId, skip=skip, limit=limit) 

1332 return [_serialize_app(a) for a in apps] 

1333 

1334 

1335# ---------------------------------------------------------------- 

1336# GET APP BY ID 

1337# ---------------------------------------------------------------- 

1338@router.get("/{app_id}", response_model=AppWithVersions) 

1339def get_app( 

1340 app_id: UUID, 

1341 db: Session = Depends(get_db), 

1342 current_user: User = Depends(get_current_user_keycloak) 

1343): 

1344 """Get app by ID with available versions. 

1345 

1346 Soft-deleted apps are still readable here so existing deployments 

1347 that still reference them can render their app name, git link, 

1348 etc. They just don't show up in the apps list / deploy wizard 

1349 (those use the default-filtered ``get_apps``). 

1350 """ 

1351 app = crud_apps.get_app(db, app_id, include_deleted=True) 

1352 if not app: 

1353 raise HTTPException( 

1354 status_code=status.HTTP_404_NOT_FOUND, 

1355 detail="App not found" 

1356 ) 

1357 

1358 # Only owner OR admin sees private/unapproved apps; everyone else 

1359 # (incl. teachers) needs the app to be public AND have an approved 

1360 # version. Same gate everywhere via :func:`can_view_app`. 

1361 is_owner_or_admin = ( 

1362 str(app.userId) == str(current_user.userId) 

1363 or current_user.role == UserRole.ADMIN 

1364 ) 

1365 if not is_owner_or_admin and (app.is_private or not crud_approvals.has_any_approved_version(db, app.appId)): 

1366 raise HTTPException( 

1367 status_code=status.HTTP_403_FORBIDDEN, 

1368 detail="You don't have permission to access this resource" 

1369 ) 

1370 

1371 # Fetch versions if git_link exists. Skipped for soft-deleted apps 

1372 # — listing versions is a "what could I deploy" affordance and the 

1373 # answer is "nothing", you already deleted this app. 

1374 if app.git_link and app.deleted_at is None: 

1375 try: 

1376 all_versions = git_service.get_versions(app.git_link) 

1377 if is_owner_or_admin: 

1378 # Owner/Admin see all Git tags 

1379 app.versions = all_versions 

1380 else: 

1381 # Everyone else only sees approved version tags 

1382 approved_tags = { 

1383 a.version_tag 

1384 for a in crud_approvals.get_approvals_for_app(db, app.appId) 

1385 if a.status == "approved" 

1386 } 

1387 app.versions = [ 

1388 v for v in all_versions 

1389 if _version_tag(v) in approved_tags 

1390 ] 

1391 except Exception as e: 

1392 app.versions = [] 

1393 logger.warning(f"Could not fetch versions: {str(e)}") 

1394 else: 

1395 app.versions = [] 

1396 

1397 return _serialize_app(app) 

1398 

1399 

1400# ---------------------------------------------------------------- 

1401# GET APP VARIABLES 

1402# ---------------------------------------------------------------- 

1403def load_variable_definitions(app, version: str) -> list[dict[str, Any]]: 

1404 """Clone the app's release-vars and parse all Terraform/Packer 

1405 variables into the same shape ``GET /apps/{id}/variables`` returns. 

1406 

1407 Reusable from ``POST /deployments`` so the deployment endpoint can 

1408 enforce per-variable contracts (``varScope``, ``fileExtensions``) 

1409 using the App-Autor's declarations as source-of-truth. Cleans up 

1410 the temporary clone on its own — callers don't manage paths. 

1411 

1412 Raises ``HTTPException(400)`` if the app has no Git link and 

1413 bubbles unexpected errors as ``HTTPException(500)``. 

1414 """ 

1415 if not app.git_link: 

1416 raise HTTPException( 

1417 status_code=status.HTTP_400_BAD_REQUEST, 

1418 detail="App has no Git repository configured", 

1419 ) 

1420 

1421 deployment_id = f"vars_{app.appId}_{version}".replace("/", "_") 

1422 repo_path = None 

1423 try: 

1424 repo_path = git_service.clone_release_vars(app.git_link, version, deployment_id) 

1425 variables: list[dict[str, Any]] = [] 

1426 tf_vars_path = os.path.join(repo_path, "terraform", "variables.tf") 

1427 if os.path.exists(tf_vars_path): 

1428 variables.extend(_parse_terraform_variables(tf_vars_path)) 

1429 # Discover all Packer templates (legacy single-file layout OR 

1430 # per-key subdirectories) and parse each one's variables. The 

1431 # ``template_key`` is recorded on every Packer variable so the 

1432 # wizard can group inputs per image. Discovery raises if the 

1433 # repo has an ambiguous or unsafe layout — surface that as 

1434 # HTTP 422 so the app author can fix the repo before any 

1435 # deploy attempt. 

1436 try: 

1437 templates = _discover_packer_templates(repo_path) 

1438 except PackerTemplateDiscoveryError as exc: 

1439 raise HTTPException( 

1440 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

1441 detail=str(exc), 

1442 ) 

1443 for tmpl in templates: 

1444 if os.path.isfile(tmpl.variables_path): 

1445 variables.extend( 

1446 _parse_packer_variables(tmpl.variables_path, template_key=tmpl.key) 

1447 ) 

1448 return variables 

1449 except HTTPException: 

1450 raise 

1451 except Exception: 

1452 logger.exception( 

1453 "Failed to load variable definitions for app %s version %s", 

1454 app.appId, version, 

1455 ) 

1456 raise HTTPException( 

1457 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1458 detail="Failed to fetch variables", 

1459 ) 

1460 finally: 

1461 if repo_path: 

1462 try: 

1463 git_service.cleanup_repository(repo_path) 

1464 except Exception as cleanup_error: 

1465 logger.error( 

1466 "Failed to cleanup repository: %s", str(cleanup_error) 

1467 ) 

1468 

1469 

1470@router.get("/{app_id}/variables", response_model=list[AppVariableResponse]) 

1471def get_app_variables( 

1472 app_id: UUID, 

1473 version: str, 

1474 db: Session = Depends(get_db), 

1475 current_user: User = Depends(get_current_user_keycloak) 

1476): 

1477 """ 

1478 Get dynamic app variables from app's Git repository 

1479 Parses variables.tf file and returns all configurable variables 

1480 

1481 Returns: 

1482 - name: Variable name 

1483 - type: Variable type (string, number, bool, list, map, etc.) 

1484 - description: Variable description 

1485 - default: Default value (if any) 

1486 - required: Whether variable is required 

1487 """ 

1488 app = crud_apps.get_app(db, app_id) 

1489 if not app: 

1490 raise HTTPException( 

1491 status_code=status.HTTP_404_NOT_FOUND, 

1492 detail="App not found" 

1493 ) 

1494 

1495 # Check access permission. ``ensure_view_app`` enforces the matrix: 

1496 # owner OR admin sees private/unapproved, others need the 

1497 # public+approved combination. 

1498 ensure_view_app(current_user, app, db=db) 

1499 

1500 variables = load_variable_definitions(app, version) 

1501 if not variables: 

1502 logger.warning("No variables found for app %s version %s", app_id, version) 

1503 

1504 # Marker errors travel per-variable in the ``markerError`` field; the 

1505 # endpoint does not 400 on a single bad marker but leaves the frontend 

1506 # to show it inline, keeping the other variables usable. 

1507 bad = [v for v in variables if v.get("markerError")] 

1508 if bad: 

1509 logger.warning( 

1510 "App %s version %s has %d variable(s) with bad @openstack markers: %s", 

1511 app_id, version, len(bad), [v["name"] for v in bad], 

1512 ) 

1513 

1514 return variables 

1515 

1516 

1517# ---------------------------------------------------------------- 

1518# CREATE APP 

1519# ---------------------------------------------------------------- 

1520@router.post("/", response_model=AppResponse, status_code=status.HTTP_201_CREATED) 

1521def create_app( 

1522 app: AppCreate, 

1523 db: Session = Depends(get_db), 

1524 current_user: User = Depends(get_current_user_keycloak) 

1525): 

1526 """ 

1527 Create a new app 

1528 - **All authenticated users** can create apps 

1529 - **Git repository access is verified** before creating the app 

1530 """ 

1531 # Decode the optional image data-URL up front so a malformed 

1532 # payload fails before we hit Keycloak / Git / DB. 

1533 image_bytes, image_mime = parse_image_data_url(app.image) 

1534 

1535 # Verify repository access if git_link is provided 

1536 if app.git_link: 

1537 access_result = git_service.verify_repository_access(app.git_link) 

1538 if not access_result['success']: 

1539 raise HTTPException( 

1540 status_code=status.HTTP_403_FORBIDDEN, 

1541 detail=access_result['message'] 

1542 ) 

1543 logger.info(f"Repository access verified for {app.git_link}") 

1544 

1545 db_app = crud_apps.create_app(db, app, current_user.userId) 

1546 if image_bytes is not None: 

1547 db_app = crud_apps.set_app_image(db, db_app.appId, image_bytes, image_mime) 

1548 

1549 # Auto-submit all tags for review if requested (public apps only) 

1550 if app.submit_all_versions and not app.is_private and app.git_link: 

1551 try: 

1552 versions = git_service.get_versions(app.git_link) 

1553 for v in versions: 

1554 tag = _version_tag(v) 

1555 if tag: 

1556 with contextlib.suppress(Exception): 

1557 crud_approvals.submit_version(db, app_id=db_app.appId, version_tag=tag) 

1558 except Exception as e: 

1559 logger.warning(f"Could not auto-submit versions for app {db_app.appId}: {e}") 

1560 

1561 return _serialize_app(db_app) 

1562 

1563 

1564# ---------------------------------------------------------------- 

1565# UPDATE APP 

1566# ---------------------------------------------------------------- 

1567@router.put("/{app_id}", response_model=AppResponse) 

1568def update_app( 

1569 app_id: UUID, 

1570 app_update: AppUpdate, 

1571 db: Session = Depends(get_db), 

1572 current_user: User = Depends(get_current_user_keycloak) 

1573): 

1574 """Update an app. 

1575 

1576 ``git_link`` is immutable after creation — sending it in the body 

1577 returns HTTP 400. Use ``is_private`` to toggle visibility. 

1578 

1579 Owner OR admin only. 

1580 """ 

1581 app = crud_apps.get_app(db, app_id) 

1582 if not app: 

1583 raise HTTPException( 

1584 status_code=status.HTTP_404_NOT_FOUND, 

1585 detail="App not found" 

1586 ) 

1587 

1588 # Check access permission — owner-or-admin only. 

1589 ensure_edit_app(current_user, app) 

1590 

1591 image_was_provided = "image" in app_update.model_fields_set 

1592 image_bytes, image_mime = (None, None) 

1593 if image_was_provided: 

1594 image_bytes, image_mime = parse_image_data_url(app_update.image) 

1595 

1596 updated_app = crud_apps.update_app(db, app_id, app_update) 

1597 if image_was_provided: 

1598 updated_app = crud_apps.set_app_image(db, app_id, image_bytes, image_mime) 

1599 return _serialize_app(updated_app) 

1600 

1601 

1602# ---------------------------------------------------------------- 

1603# SUBMIT VERSION FOR REVIEW 

1604# ---------------------------------------------------------------- 

1605@router.post( 

1606 "/{app_id}/versions/{version_tag}/submit", 

1607 response_model=AppVersionApprovalResponse, 

1608 status_code=status.HTTP_201_CREATED, 

1609) 

1610def submit_version( 

1611 app_id: UUID, 

1612 version_tag: str, 

1613 body: AppVersionApprovalSubmit, 

1614 db: Session = Depends(get_db), 

1615 current_user: User = Depends(get_current_user_keycloak), 

1616): 

1617 """Submit a specific version tag for admin review. 

1618 

1619 Owner OR admin only. A REJECTED version can be resubmitted; PENDING 

1620 and APPROVED cannot. 

1621 """ 

1622 app = crud_apps.get_app(db, app_id) 

1623 if not app: 

1624 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="App not found") 

1625 

1626 ensure_submit_app_version(current_user, app) 

1627 

1628 if not app.git_link: 

1629 raise HTTPException( 

1630 status_code=status.HTTP_400_BAD_REQUEST, 

1631 detail="App has no git repository configured", 

1632 ) 

1633 

1634 # Marker validation — blocks submit on invalid @openstack markers. 

1635 # Same logic as the approve endpoint; git errors (400/500) are 

1636 # skipped so submit still works when the repo is unreachable. 

1637 try: 

1638 variables = load_variable_definitions(app, version_tag) 

1639 marker_errors = [v.get("markerError") for v in variables if v.get("markerError")] 

1640 if marker_errors: 

1641 raise HTTPException( 

1642 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

1643 detail={ 

1644 "message": ( 

1645 "Version kann nicht eingereicht werden — fehlerhafte " 

1646 "@openstack-Marker in den Variablen-Dateien" 

1647 ), 

1648 "marker_errors": marker_errors, 

1649 }, 

1650 ) 

1651 except HTTPException as exc: 

1652 if exc.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: 

1653 raise 

1654 # 400 (no git_link, handled above) or 500 (git unreachable) — 

1655 # allow submit anyway. 

1656 

1657 return crud_approvals.submit_version( 

1658 db, app_id=app_id, version_tag=version_tag, diff_url=body.diff_url, notes=body.notes 

1659 ) 

1660 

1661 

1662# ---------------------------------------------------------------- 

1663# WITHDRAW VERSION SUBMISSION 

1664# ---------------------------------------------------------------- 

1665@router.delete( 

1666 "/{app_id}/versions/{version_tag}/submit", 

1667 status_code=status.HTTP_204_NO_CONTENT, 

1668) 

1669def withdraw_version( 

1670 app_id: UUID, 

1671 version_tag: str, 

1672 db: Session = Depends(get_db), 

1673 current_user: User = Depends(get_current_user_keycloak), 

1674): 

1675 """Withdraw a PENDING version submission. 

1676 

1677 Owner OR admin only. Deletes the approval entry so the version 

1678 appears as unsubmitted again. 

1679 """ 

1680 app = crud_apps.get_app(db, app_id) 

1681 if not app: 

1682 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="App not found") 

1683 

1684 ensure_submit_app_version(current_user, app) 

1685 crud_approvals.withdraw(db, app_id=app_id, version_tag=version_tag) 

1686 return None 

1687 

1688 

1689# ---------------------------------------------------------------- 

1690# GET VERSION APPROVALS FOR APP 

1691# ---------------------------------------------------------------- 

1692@router.get( 

1693 "/{app_id}/versions", 

1694 response_model=list[AppVersionApprovalResponse], 

1695) 

1696def list_version_approvals( 

1697 app_id: UUID, 

1698 db: Session = Depends(get_db), 

1699 current_user: User = Depends(get_current_user_keycloak), 

1700): 

1701 """List all version approval entries for an app. 

1702 

1703 Owner OR admin only. 

1704 """ 

1705 app = crud_apps.get_app(db, app_id, include_deleted=True) 

1706 if not app: 

1707 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="App not found") 

1708 

1709 ensure_edit_app(current_user, app) 

1710 

1711 return crud_approvals.get_approvals_for_app(db, app_id) 

1712 

1713 

1714# ---------------------------------------------------------------- 

1715# DELETE APP 

1716# ---------------------------------------------------------------- 

1717@router.delete("/{app_id}", status_code=status.HTTP_204_NO_CONTENT) 

1718def delete_app( 

1719 app_id: UUID, 

1720 db: Session = Depends(get_db), 

1721 current_user: User = Depends(get_current_user_keycloak) 

1722): 

1723 """Soft-delete an app. 

1724 

1725 Sets ``apps.deleted_at`` instead of removing the row, so any 

1726 historical or still-running deployment that points at this app keeps 

1727 resolving. The app stops appearing in listings and the deploy wizard; 

1728 existing deployments live on until destroyed individually. 

1729 

1730 Owner OR admin only. 

1731 """ 

1732 app = crud_apps.get_app(db, app_id) 

1733 if not app: 

1734 raise HTTPException( 

1735 status_code=status.HTTP_404_NOT_FOUND, 

1736 detail="App not found" 

1737 ) 

1738 

1739 # Check access permission — owner-or-admin only. 

1740 ensure_delete_app(current_user, app) 

1741 

1742 success = crud_apps.soft_delete_app(db, app_id) 

1743 if not success: 

1744 raise HTTPException( 

1745 status_code=status.HTTP_404_NOT_FOUND, 

1746 detail="App not found" 

1747 ) 

1748 return None