mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-25 04:28:00 +09:00
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.
290 lines
10 KiB
Python
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())
|