Files
ServerKit/backend/tests/api_controller_boundary_scan.py
Juan Denis 166e9acabf Split servers.py into area modules
The 3,000-line file mixed core server routes with Docker, agent
install/update/fleet and remote host operations. Those sections move
verbatim into servers_docker, servers_agents and servers_remote on the
same blueprint; the registered route map (127 rules, methods and
endpoint names) is identical before and after.

The controller-boundary scan only treated files that create a
Blueprint as controllers, so an area module would have escaped it; it
now also counts files that register routes.
2026-10-10 18:49:59 -04:00

290 lines
10 KiB
Python

"""Inventory architectural boundary crossings in Flask API controllers.
The scanner deliberately uses the Python AST instead of text matching so
comments and strings do not become debt. A call-site fingerprint excludes line
numbers, which keeps the baseline stable when surrounding code moves, while
still recording the controller function and normalized call expression.
Run this file with ``--write-baseline`` after intentionally removing boundary
crossings. Any newly discovered call site should first be moved behind a
service; accepting one into the baseline requires an explicit reviewed diff.
"""
from __future__ import annotations
import argparse
import ast
import json
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = BACKEND_ROOT / 'app' / 'api'
BASELINE_PATH = Path(__file__).with_name('api_controller_boundary_baseline.json')
SUBPROCESS_CALLS = {
'subprocess.call',
'subprocess.check_call',
'subprocess.check_output',
'subprocess.Popen',
'subprocess.run',
}
OS_MUTATIONS = {
'chmod', 'chown', 'lchmod', 'link', 'makedirs', 'mkdir', 'mknod',
'remove', 'removedirs', 'rename', 'renames', 'replace', 'rmdir',
'symlink', 'truncate', 'unlink',
}
SHUTIL_MUTATIONS = {
'copy', 'copy2', 'copyfile', 'copymode', 'copystat', 'copytree',
'move', 'rmtree',
}
PATH_MUTATIONS = {
'chmod', 'hardlink_to', 'link_to', 'mkdir', 'rename', 'replace',
'rmdir', 'symlink_to', 'touch', 'unlink', 'write_bytes', 'write_text',
}
WRITE_OPEN_MODES = {'a', 'w', 'x', '+'}
@dataclass(frozen=True)
class BoundaryCall:
fingerprint: str
category: str
file: str
function: str
expression: str
def _dotted_name(node: ast.AST, aliases: dict[str, str] | None = None) -> str | None:
if isinstance(node, ast.Name):
return (aliases or {}).get(node.id, node.id)
if isinstance(node, ast.Attribute):
parent = _dotted_name(node.value, aliases)
return f'{parent}.{node.attr}' if parent else None
return None
def _import_aliases(tree: ast.Module) -> dict[str, str]:
aliases = {}
for node in tree.body:
if isinstance(node, ast.Import):
for imported in node.names:
local = imported.asname or imported.name.split('.')[0]
aliases[local] = imported.name
elif isinstance(node, ast.ImportFrom) and node.module:
for imported in node.names:
if imported.name == '*':
continue
local = imported.asname or imported.name
aliases[local] = f'{node.module}.{imported.name}'
return aliases
def _is_controller(tree: ast.Module, aliases: dict[str, str]) -> bool:
"""A module that creates a Blueprint or registers routes on one. The
second half matters: an area module that adds routes to a blueprint
imported from elsewhere would otherwise escape the scan entirely."""
return any(
(isinstance(node, ast.Call)
and _dotted_name(node.func, aliases) in {'Blueprint', 'flask.Blueprint'})
or (isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and any(
isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)
and dec.func.attr == 'route'
for dec in node.decorator_list))
for node in ast.walk(tree)
)
def _contains_model_query(node: ast.AST) -> bool:
"""Recognize Flask-SQLAlchemy ``Model.query*`` entry points."""
return any(
isinstance(child, ast.Attribute)
and isinstance(child.value, ast.Name)
and child.value.id[:1].isupper()
and (child.attr == 'query' or child.attr.startswith('query_'))
for child in ast.walk(node)
)
def _open_writes(call: ast.Call, aliases: dict[str, str]) -> bool:
if _dotted_name(call.func, aliases) not in {'open', 'Path.open', 'pathlib.Path.open'}:
return False
mode_node = call.args[1] if len(call.args) > 1 else next(
(keyword.value for keyword in call.keywords if keyword.arg == 'mode'),
None,
)
return (
isinstance(mode_node, ast.Constant)
and isinstance(mode_node.value, str)
and any(marker in mode_node.value for marker in WRITE_OPEN_MODES)
)
def _path_constructor_mutation(call: ast.Call, aliases: dict[str, str]) -> bool:
if not isinstance(call.func, ast.Attribute) or call.func.attr not in PATH_MUTATIONS:
return False
receiver = call.func.value
return (
isinstance(receiver, ast.Call)
and _dotted_name(receiver.func, aliases) in {'Path', 'pathlib.Path'}
)
def _category(call: ast.Call, aliases: dict[str, str]) -> str | None:
dotted = _dotted_name(call.func, aliases)
direct_session = dotted and (
dotted.startswith('db.session.') or '.db.session.' in dotted
)
if direct_session or _contains_model_query(call.func):
return 'persistence'
if dotted in SUBPROCESS_CALLS:
return 'subprocess'
if dotted and (
(dotted.startswith('os.') and dotted.rsplit('.', 1)[-1] in OS_MUTATIONS)
or (dotted.startswith('shutil.') and dotted.rsplit('.', 1)[-1] in SHUTIL_MUTATIONS)
):
return 'filesystem'
if _path_constructor_mutation(call, aliases) or _open_writes(call, aliases):
return 'filesystem'
return None
def _enclosing_function(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> str:
names = []
current = parents.get(node)
while current is not None:
if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)):
names.append(current.name)
current = parents.get(current)
return '.'.join(reversed(names)) if names else '<module>'
def _normalized_expression(call: ast.Call) -> str:
return re.sub(r'\s+', ' ', ast.unparse(call)).strip()
def scan_source(source: str, relative_path: str = 'api/example.py') -> list[BoundaryCall]:
"""Scan one controller source string; primarily useful for detector tests."""
tree = ast.parse(source, filename=relative_path)
aliases = _import_aliases(tree)
if not _is_controller(tree, aliases):
return []
parents: dict[ast.AST, ast.AST] = {}
for parent in ast.walk(tree):
for child in ast.iter_child_nodes(parent):
parents[child] = parent
categorized = {
node: category
for node in ast.walk(tree)
if isinstance(node, ast.Call)
if (category := _category(node, aliases)) is not None
}
def nested_in_same_call_chain(node: ast.Call, category: str) -> bool:
current: ast.AST = node
parent = parents.get(current)
while parent is not None:
follows_receiver = (
isinstance(parent, ast.Attribute) and parent.value is current
)
follows_function = isinstance(parent, ast.Call) and parent.func is current
if not (follows_receiver or follows_function):
return False
if isinstance(parent, ast.Call) and categorized.get(parent) == category:
return True
current, parent = parent, parents.get(parent)
return False
discovered = []
occurrences: Counter[tuple[str, str, str]] = Counter()
for node, category in categorized.items():
if nested_in_same_call_chain(node, category):
continue
function = _enclosing_function(node, parents)
expression = _normalized_expression(node)
occurrence_key = (function, category, expression)
occurrences[occurrence_key] += 1
ordinal = occurrences[occurrence_key]
fingerprint = (
f'{relative_path}::{function}::{category}::{expression}::{ordinal}'
)
discovered.append(BoundaryCall(
fingerprint=fingerprint,
category=category,
file=relative_path,
function=function,
expression=expression,
))
return sorted(discovered, key=lambda item: item.fingerprint)
def scan_api_controllers() -> list[BoundaryCall]:
discovered = []
for path in sorted(API_ROOT.glob('*.py')):
relative = path.relative_to(BACKEND_ROOT / 'app').as_posix()
discovered.extend(scan_source(path.read_text(encoding='utf-8'), relative))
return sorted(discovered, key=lambda item: item.fingerprint)
def _default_owner(relative_path: str) -> dict[str, str]:
domain = Path(relative_path).stem.replace('_', ' ')
return {
'owner': f'ServerKit backend / {domain} domain',
'rationale': (
f'Legacy {domain} controllers still cross a persistence or host-operation '
'boundary; move each call into an app.services use case before removing it.'
),
}
def write_baseline(path: Path = BASELINE_PATH) -> dict:
calls = scan_api_controllers()
previous = {}
if path.exists():
previous = json.loads(path.read_text(encoding='utf-8')).get('owners', {})
files = sorted({call.file for call in calls})
payload = {
'schema_version': 1,
'policy': (
'Exact reviewed inventory of direct persistence, subprocess, and filesystem '
'mutation calls in Flask API controllers. New calls are forbidden; remove '
'entries as controllers delegate to services.'
),
'counts': dict(sorted(Counter(call.category for call in calls).items())),
'owners': {
file: previous.get(file, _default_owner(file))
for file in files
},
# The fingerprint itself contains the file, controller function,
# category, normalized expression, and duplicate ordinal. Keeping the
# compact string makes a 600-call legacy inventory reviewable in git.
'violations': [call.fingerprint for call in calls],
}
path.write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
return payload
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'--write-baseline',
action='store_true',
help='replace the reviewed baseline with the current exact inventory',
)
args = parser.parse_args()
if args.write_baseline:
payload = write_baseline()
print(f'wrote {len(payload["violations"])} calls to {BASELINE_PATH}')
return 0
calls = scan_api_controllers()
print(json.dumps(dict(sorted(Counter(call.category for call in calls).items())), indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())