mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
meta/schema.yml held credential definitions and their runtime values, but the name said 'schema' and read like structural metadata next to services.yml and volumes.yml. Nothing in the file is a schema in the sense the other meta files use the word, and a contributor had to open it to learn it holds secrets. 62 role files renamed. ROLE_FILE_META_SCHEMA becomes ROLE_FILE_META_SECRETS and keeps its role as the SPOT the path is built from. Two readers bypassed that constant and spelled the name themselves: utils/cache/applications.py built meta_dir / 'schema.yml' twice, and plugins/lookup/application_gid.py carries the filename in the marker list it detects application roles by. Left behind, the rename would not have raised — credentials would have resolved empty and the role would have stopped counting as an application. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""Project-wide scanner for `lookup('config', ...)` calls.
|
|
|
|
Two cached entry points:
|
|
|
|
* :func:`iter_matches` — returns the deduplicated stream of qualifying
|
|
:class:`LookupMatch` records (regex-parsed, comment-stripped,
|
|
suppression-filtered). Knows nothing about how each test classifies
|
|
the matches.
|
|
* :func:`get_context` — returns the :class:`ScanContext` carrying the
|
|
application defaults, user defaults, per-role schemas, and the
|
|
``application_id``-declaring role set. Built from
|
|
``utils.cache.{applications,users,yaml}`` so every YAML read goes
|
|
through the process-wide caches.
|
|
|
|
Per-test classification (literal / variable / wildcard / role-local)
|
|
is intentionally NOT done here — each test owns its own builder, so
|
|
a change to one classification cannot leak into the others. See the
|
|
sibling ``test_*.py`` files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from utils.annotations.suppress import is_suppressed_at
|
|
from utils.cache.applications import get_application_defaults
|
|
from utils.cache.files import iter_project_files_with_content
|
|
from utils.cache.users import get_user_defaults
|
|
from utils.cache.yaml import load_yaml_any
|
|
from utils.roles.mapping import ROLE_FILE_META_SECRETS, ROLE_FILE_VARS_MAIN
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
SUPPRESS_RULE: str = "lookup-config-path"
|
|
|
|
|
|
PATTERN = re.compile(
|
|
r"lookup\(\s*['\"]config['\"]\s*,\s*([^,]+?)\s*,\s*['\"]([^'\"]+)['\"]"
|
|
)
|
|
CONCAT_PATTERN = re.compile(
|
|
r"lookup\(\s*['\"]config['\"]\s*,\s*([^,]+?)\s*,\s*(.+?)\s*\)"
|
|
)
|
|
|
|
|
|
def role_id_from_path(file_path: Path) -> str | None:
|
|
"""Return the role id when ``file_path`` lives under ``roles/<role>/...``."""
|
|
parts = file_path.parts
|
|
try:
|
|
idx = parts.index("roles")
|
|
except ValueError:
|
|
return None
|
|
if idx + 1 >= len(parts):
|
|
return None
|
|
return parts[idx + 1]
|
|
|
|
|
|
def expr_to_wildcard_path(expr: str) -> str | None:
|
|
"""Convert a `~`-concatenated lookup-path expression into a dotted
|
|
path with `*` placeholders for every Jinja variable segment.
|
|
|
|
Examples:
|
|
``'services.' ~ entity_name ~ '.ports.internal.http'`` -> ``services.*.ports.internal.http``
|
|
``'services.openldap.ports.local.' ~ _ldap_protocol`` -> ``services.openldap.ports.local.*``
|
|
|
|
Returns ``None`` when the expression cannot be parsed (mismatched
|
|
quotes, unrecognised operators, etc.) so the caller can skip it.
|
|
"""
|
|
parts: list[str] = []
|
|
i = 0
|
|
n = len(expr)
|
|
expecting_value = True
|
|
while i < n:
|
|
ch = expr[i]
|
|
if ch.isspace():
|
|
i += 1
|
|
continue
|
|
if expecting_value:
|
|
if ch in ("'", '"'):
|
|
quote = ch
|
|
j = expr.find(quote, i + 1)
|
|
if j == -1:
|
|
return None
|
|
parts.append(expr[i + 1 : j])
|
|
i = j + 1
|
|
else:
|
|
j = i
|
|
while j < n and not expr[j].isspace() and expr[j] != "~":
|
|
j += 1
|
|
token = expr[i:j].strip()
|
|
if not token:
|
|
return None
|
|
parts.append("*")
|
|
i = j
|
|
expecting_value = False
|
|
elif ch == "~":
|
|
expecting_value = True
|
|
i += 1
|
|
else:
|
|
return None
|
|
if expecting_value:
|
|
return None
|
|
raw = "".join(parts)
|
|
segments = [seg for seg in raw.split(".") if seg != ""]
|
|
return ".".join(segments) if segments else None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LookupMatch:
|
|
"""One qualifying `lookup('config', <app>, <path>)` occurrence.
|
|
|
|
`kind` distinguishes the regex that produced the match:
|
|
* ``"literal"`` — `path_arg` is the FIRST quoted string between the
|
|
app argument and any subsequent comma. May end with ``"."`` for
|
|
partial paths that get a `~ var` suffix elsewhere on the line.
|
|
* ``"concat"`` — `path_arg` is the full expression up to the closing
|
|
paren and is guaranteed to contain at least one ``~``.
|
|
|
|
`app_literal` is set iff `app_arg` is a quoted string literal; it
|
|
is the unquoted application id. When `app_literal is None`, the
|
|
application argument is a variable (typically ``application_id``).
|
|
"""
|
|
|
|
file: Path
|
|
lineno: int
|
|
app_arg: str
|
|
app_literal: str | None
|
|
path_arg: str
|
|
kind: str # "literal" | "concat"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScanContext:
|
|
"""Repo-derived context the four classifications validate against."""
|
|
|
|
root: Path
|
|
application_defaults: dict[str, Any]
|
|
user_defaults: dict[str, Any]
|
|
role_schemas: dict[str, dict[str, Any]]
|
|
role_for_app: dict[str, str]
|
|
roles_with_application_id: frozenset[str] = field(default_factory=frozenset)
|
|
|
|
|
|
def _is_quoted(token: str) -> bool:
|
|
return (token.startswith("'") and token.endswith("'")) or (
|
|
token.startswith('"') and token.endswith('"')
|
|
)
|
|
|
|
|
|
def _line_is_commented(text: str, match_start: int) -> bool:
|
|
"""Return True when the lookup match sits on a comment-only line or
|
|
is preceded on the same line by a `#` inline comment marker."""
|
|
start = text.rfind("\n", 0, match_start) + 1
|
|
end = text.find("\n", start)
|
|
line = text[start:end] if end != -1 else text[start:]
|
|
if line.lstrip().startswith("#"):
|
|
return True
|
|
idx_call = line.find("lookup")
|
|
idx_hash = line.find("#")
|
|
return 0 <= idx_hash < idx_call
|
|
|
|
|
|
def _has_default_arg(text: str, scan_from: int) -> bool:
|
|
"""Return True when the `lookup('config', app, path[, default])` call
|
|
being parsed carries an explicit default value (4th positional arg).
|
|
|
|
Mirrors the runtime semantics of `plugins/lookup/config.py`: a third
|
|
`terms` element flips the call to `strict=False`, so a missing path
|
|
silently falls through to the default. Such calls are explicitly
|
|
defensive and out of scope for the path-existence lints — they
|
|
cannot fail at runtime even when the static scan would mark them.
|
|
|
|
Starting from ``scan_from`` (the index right after the path literal
|
|
or expression), this skips whitespace and reports whether the next
|
|
significant character inside the same `lookup(...)` call is a
|
|
comma. Bounded at the call's closing paren so we never look past
|
|
it."""
|
|
i = scan_from
|
|
n = len(text)
|
|
while i < n:
|
|
ch = text[i]
|
|
if ch.isspace():
|
|
i += 1
|
|
continue
|
|
if ch == ",":
|
|
return True
|
|
if ch == ")":
|
|
return False
|
|
return False
|
|
return False
|
|
|
|
|
|
def _build_role_for_app_map(roles_root: Path) -> dict[str, str]:
|
|
"""Walk ``roles/`` once and return ``{application_id: role_dir_name}``.
|
|
|
|
Replaces the per-app ``plugins.filter.get.role.get_role`` call which
|
|
is O(n) per lookup (it scans every role's vars/main.yml). Calling
|
|
it once per application id makes role-schema preload O(n²); doing
|
|
the walk once here keeps it O(n) and routes every YAML read
|
|
through the process-wide ``utils.cache.yaml.load_yaml_any`` cache.
|
|
"""
|
|
mapping: dict[str, str] = {}
|
|
if not roles_root.is_dir():
|
|
return mapping
|
|
for role_dir in roles_root.iterdir():
|
|
if not role_dir.is_dir():
|
|
continue
|
|
vars_file = role_dir / ROLE_FILE_VARS_MAIN
|
|
if not vars_file.is_file():
|
|
continue
|
|
data = load_yaml_any(vars_file, default_if_missing={}) or {}
|
|
if not isinstance(data, dict):
|
|
continue
|
|
app_id = data.get("application_id")
|
|
if isinstance(app_id, str):
|
|
mapping[app_id] = role_dir.name
|
|
return mapping
|
|
|
|
|
|
def _build_role_schemas(
|
|
application_defaults: dict[str, Any],
|
|
role_for_app: dict[str, str],
|
|
roles_root: Path,
|
|
) -> dict[str, dict[str, Any]]:
|
|
role_schemas: dict[str, dict[str, Any]] = {}
|
|
for app_id in application_defaults:
|
|
role = role_for_app.get(app_id)
|
|
if role is None:
|
|
continue
|
|
schema_file = roles_root / role / ROLE_FILE_META_SECRETS
|
|
if not schema_file.is_file():
|
|
continue
|
|
schema = load_yaml_any(schema_file, default_if_missing={}) or {}
|
|
if isinstance(schema, dict):
|
|
role_schemas[app_id] = schema
|
|
return role_schemas
|
|
|
|
|
|
_SCANNED_EXTENSIONS: tuple[str, ...] = (".yml", ".yaml", ".j2")
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def get_context() -> ScanContext:
|
|
"""Return the cached :class:`ScanContext` for the current repo."""
|
|
root = PROJECT_ROOT
|
|
roles_root = root / "roles"
|
|
application_defaults = get_application_defaults(roles_dir=roles_root)
|
|
user_defaults = get_user_defaults(roles_dir=roles_root)
|
|
role_for_app = _build_role_for_app_map(roles_root)
|
|
role_schemas = _build_role_schemas(application_defaults, role_for_app, roles_root)
|
|
return ScanContext(
|
|
root=root,
|
|
application_defaults=application_defaults,
|
|
user_defaults=user_defaults,
|
|
role_schemas=role_schemas,
|
|
role_for_app=role_for_app,
|
|
roles_with_application_id=frozenset(role_for_app.values()),
|
|
)
|
|
|
|
|
|
def _emit_literal_match(
|
|
text: str, lines: list[str], file_path: Path, m: re.Match[str]
|
|
) -> LookupMatch | None:
|
|
if _line_is_commented(text, m.start()):
|
|
return None
|
|
lineno = text.count("\n", 0, m.start()) + 1
|
|
if is_suppressed_at(lines, lineno, SUPPRESS_RULE, mode="same-or-above"):
|
|
return None
|
|
if _has_default_arg(text, m.end()):
|
|
return None
|
|
app_arg = m.group(1).strip()
|
|
path_arg = m.group(2).strip()
|
|
if "{%" in path_arg:
|
|
return None
|
|
app_literal = app_arg.strip("'\"") if _is_quoted(app_arg) else None
|
|
return LookupMatch(
|
|
file=file_path,
|
|
lineno=lineno,
|
|
app_arg=app_arg,
|
|
app_literal=app_literal,
|
|
path_arg=path_arg,
|
|
kind="literal",
|
|
)
|
|
|
|
|
|
def _emit_concat_match(
|
|
text: str, lines: list[str], file_path: Path, m: re.Match[str]
|
|
) -> LookupMatch | None:
|
|
if _line_is_commented(text, m.start()):
|
|
return None
|
|
lineno = text.count("\n", 0, m.start()) + 1
|
|
if is_suppressed_at(lines, lineno, SUPPRESS_RULE, mode="same-or-above"):
|
|
return None
|
|
app_arg = m.group(1).strip()
|
|
expr = m.group(2).strip()
|
|
if "~" not in expr or "{%" in expr:
|
|
return None
|
|
app_literal = app_arg.strip("'\"") if _is_quoted(app_arg) else None
|
|
return LookupMatch(
|
|
file=file_path,
|
|
lineno=lineno,
|
|
app_arg=app_arg,
|
|
app_literal=app_literal,
|
|
path_arg=expr,
|
|
kind="concat",
|
|
)
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def iter_matches() -> tuple[LookupMatch, ...]:
|
|
"""Return the deduplicated tuple of qualifying lookup matches.
|
|
|
|
Each project file is read at most once (via the
|
|
``utils.cache.files`` content cache). The substring pre-filter
|
|
(``"lookup" in text``) and the extension allow-list
|
|
(``_SCANNED_EXTENSIONS``) skip the bulk of files before the regex
|
|
passes ever run.
|
|
"""
|
|
matches: list[LookupMatch] = []
|
|
for path_str, text in iter_project_files_with_content(
|
|
extensions=_SCANNED_EXTENSIONS,
|
|
exclude_tests=True,
|
|
exclude_dirs=("docs",),
|
|
):
|
|
if "lookup" not in text:
|
|
continue
|
|
file_path = Path(path_str)
|
|
lines = text.splitlines()
|
|
for m in PATTERN.finditer(text):
|
|
match = _emit_literal_match(text, lines, file_path, m)
|
|
if match is not None:
|
|
matches.append(match)
|
|
for m in CONCAT_PATTERN.finditer(text):
|
|
match = _emit_concat_match(text, lines, file_path, m)
|
|
if match is not None:
|
|
matches.append(match)
|
|
return tuple(matches)
|