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
« 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
11from fastapi import APIRouter, Depends, HTTPException, status
12from pydantic import BaseModel, ConfigDict
13from sqlalchemy.orm import Session
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
37logger = logging.getLogger(__name__)
40def _serialize_app(app):
41 """Replace ``app.image`` (bytes) with the data-URL form in-place.
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
58def _version_tag(version) -> str:
59 """Extract the tag string from a git version entry.
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", "")
71router = APIRouter()
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
86class AppVariableResponse(BaseModel):
87 """Shape of one entry in ``GET /apps/{id}/variables``.
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 """
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")
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
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# ----------------------------------------------------------------
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}
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"}
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]+)*$")
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"}
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"}
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)
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]")
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|]")
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)
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)
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.
284 ``code`` is a stable machine-readable key (e.g. ``MARKER_WHITESPACE``);
285 ``message`` is human-readable German for now.
286 """
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
295def _parse_var_scope(var_name: str, slot: str | None) -> str | None:
296 """Validate and normalize the ``var_scope`` slot of a marker.
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 )
316def _forbid_packer_team_user_scope(var_name: str, source: str, var_scope: str | None) -> None:
317 """Reject ``team``/``user`` scopes on packer variables.
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 )
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.
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 )
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
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 )
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 )
402def _select_marker(var_name: str, description: str):
403 """Pick the effective marker match from the description.
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
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 "")
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 )
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``).
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)
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.
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 )
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 )
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("|")
551 return ("file", None, None, file_scope, file_scope, file_exts)
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.
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 )
604def _parse_marker_multi(var_name: str, raw_multi: str | None) -> bool | None:
605 """Parse the multi slot (``multi``/``list``/``single``) of a marker.
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 )
628def _collection_check_type(type_lower: str, var_scope: str | None) -> str:
629 """Return the HCL type to run the collection check against.
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
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.
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 )
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)
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)
714 _forbid_packer_team_user_scope(var_name, source, var_scope)
715 return (os_type, mode, multi, None, var_scope, None)
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).
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.
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}``.
742 Returns: ``(os_type, mode, multi, file_scope, var_scope, file_exts)``.
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.
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)
762 _reject_malformed_markers(var_name, description)
764 chosen = _select_marker(var_name, description)
765 if chosen is None:
766 return (None, None, None, None, None, None)
768 _, raw_type, raw_mode, raw_multi, raw_scope = chosen
769 os_type: str | None = raw_type.lower() if raw_type else None
771 if os_type is None:
772 return _parse_scope_only_marker(var_name, source, raw_mode, raw_multi, raw_scope)
774 if os_type == "file":
775 return _parse_file_marker(var_name, source, raw_mode, raw_multi, raw_scope)
777 return _parse_resource_marker(
778 var_name, var_type, source, os_type, raw_mode, raw_multi, raw_scope
779 )
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:
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"
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 )
809 return (mode, multi)
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
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
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.
834 The contract — documented in the deploy/file-uploads design — is:
836 * ``scope = all`` → ``map(object({...}))``
837 * ``scope = team`` → ``map(map(object({...})))``
838 * ``scope = user`` → ``map(map(object({...})))``
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.
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 )
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.
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.
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 )
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").
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)
912 stripped = raw_default.strip()
913 if stripped == "":
914 return (None, True)
916 # Literal HCL ``null`` → Variable ist required.
917 if stripped.lower() == "null":
918 return (None, True)
920 type_lower = (var_type or "").strip().lower()
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)
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)
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")
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)
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)
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.
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"
995 # Extract description
996 desc_match = re.search(r'description\s*=\s*"([^"]*)"', var_block)
997 description = desc_match.group(1) if desc_match else ""
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
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
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 }
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
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
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"
1081 return var_info
1084def _iter_variable_blocks(content: str):
1085 """Yield ``(var_name, var_block, block_offset)`` for each HCL
1086 ``variable "name" { ... }`` block, brace-balanced.
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.
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
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()
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 ))
1154 return variables
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``.
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()
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)
1187 return variables
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# ----------------------------------------------------------------
1225@dataclass
1226class _PackerTemplate:
1227 """One Packer template discovered under ``<repo>/packer``.
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 """
1235 key: str
1236 template_path: str
1237 variables_path: str
1240_TEMPLATE_KEY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,30}$")
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).
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 """
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.
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 []
1264 legacy_template = os.path.join(packer_dir, "template.pkr.hcl")
1265 has_legacy = os.path.isfile(legacy_template)
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 ))
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 )
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 )
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 )]
1309 return multi_templates
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.
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]
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.
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 )
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 )
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 = []
1397 return _serialize_app(app)
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.
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.
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 )
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 )
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
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 )
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)
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)
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 )
1514 return variables
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)
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}")
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)
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}")
1561 return _serialize_app(db_app)
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.
1576 ``git_link`` is immutable after creation — sending it in the body
1577 returns HTTP 400. Use ``is_private`` to toggle visibility.
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 )
1588 # Check access permission — owner-or-admin only.
1589 ensure_edit_app(current_user, app)
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)
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)
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.
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")
1626 ensure_submit_app_version(current_user, app)
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 )
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.
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 )
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.
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")
1684 ensure_submit_app_version(current_user, app)
1685 crud_approvals.withdraw(db, app_id=app_id, version_tag=version_tag)
1686 return None
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.
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")
1709 ensure_edit_app(current_user, app)
1711 return crud_approvals.get_approvals_for_app(db, app_id)
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.
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.
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 )
1739 # Check access permission — owner-or-admin only.
1740 ensure_delete_app(current_user, app)
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