Files
ServerKit/backend/tests/test_ingress_plane.py
Juan DenisandClaude Opus 4.8 0e71ab346a feat: make the ingress-plane boundary explicit (host Nginx vs proxy stack)
Both reverse proxies bind 80/443, so pointing the same domain at host Nginx and
a Dockerized proxy stack is an operational footgun. This tags each app with the
plane it expects and surfaces disagreements instead of leaving them implicit.

- Each app carries an `ingress_plane` ('nginx' | 'proxy_stack'); NULL reads as
  the host-Nginx default. New pure helpers in app/utils/ingress.py: only
  container-based services are proxy-stack eligible, and a requested proxy_stack
  is forced back to nginx for PHP/WordPress/static/Python apps. to_dict() also
  exposes `ingress_proxy_eligible`.
- ProxyStackService.ingress_audit(server) reports which of a server's apps
  disagree with its active proxy mode; fleet_overview now includes per-server
  app_count + mismatch_count. New GET /servers/<id>/proxy/ingress-audit.
- Create wizard offers the proxy stack only for Docker/Compose services and
  keeps the default on Nginx; app detail shows an Ingress badge; the fleet proxy
  dashboard flags mismatch counts; the server proxy panel warns and lists the
  specific mismatched apps with reasons.

Migration 035 adds applications.ingress_plane. Backend suite green (incl. 8 new
ingress tests); frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:57:40 -04:00

122 lines
4.9 KiB
Python

"""Ingress-plane boundary: defaults, eligibility, and proxy/app mismatch audit."""
import pytest
from app.utils.ingress import (
INGRESS_NGINX, INGRESS_PROXY,
proxy_eligible, default_ingress_plane, normalize_ingress_plane,
expected_plane_for_proxy,
)
# ---- pure helpers -------------------------------------------------------
def test_default_plane_is_always_nginx():
for t in ('php', 'wordpress', 'static', 'flask', 'django', 'docker'):
assert default_ingress_plane(t) == INGRESS_NGINX
def test_only_container_apps_are_proxy_eligible():
assert proxy_eligible('docker') is True
assert proxy_eligible('php') is False
assert proxy_eligible('wordpress') is False
assert proxy_eligible('static') is False
assert proxy_eligible('flask') is False
# compose-managed non-docker app type is still eligible
assert proxy_eligible('generic', managed_by='docker_compose') is True
def test_normalize_rejects_proxy_for_ineligible_types():
# WordPress/PHP/static can never be tagged for the proxy stack
assert normalize_ingress_plane('proxy_stack', 'wordpress') == INGRESS_NGINX
assert normalize_ingress_plane('proxy_stack', 'php') == INGRESS_NGINX
assert normalize_ingress_plane('proxy_stack', 'static') == INGRESS_NGINX
# Docker can opt in
assert normalize_ingress_plane('proxy_stack', 'docker') == INGRESS_PROXY
# Unknown / empty -> default nginx
assert normalize_ingress_plane('', 'docker') == INGRESS_NGINX
assert normalize_ingress_plane('bogus', 'docker') == INGRESS_NGINX
assert normalize_ingress_plane(None, 'docker') == INGRESS_NGINX
def test_expected_plane_for_proxy():
assert expected_plane_for_proxy('nginx') == INGRESS_NGINX
assert expected_plane_for_proxy(None) == INGRESS_NGINX
assert expected_plane_for_proxy('traefik') == INGRESS_PROXY
assert expected_plane_for_proxy('caddy') == INGRESS_PROXY
# ---- DB-backed audit ----------------------------------------------------
def _make_server(app, name='srv'):
from app import db
from app.models.server import Server
s = Server(name=name, status='online')
db.session.add(s)
db.session.commit()
return s
def _make_app(server_id, app_type='docker', ingress_plane=None, name='a'):
from app import db
from app.models.application import Application
a = Application(
name=name, app_type=app_type, status='running',
server_id=server_id, user_id=1, ingress_plane=ingress_plane,
)
db.session.add(a)
db.session.commit()
return a
def test_audit_no_mismatch_on_default_nginx_server(app):
from app.services.proxy_stack_service import ProxyStackService
with app.app_context():
s = _make_server(app, 'nginx-srv')
_make_app(s.id, 'wordpress') # nginx plane (default)
_make_app(s.id, 'docker') # nginx plane (default — opt-in only)
audit = ProxyStackService.ingress_audit(s.id)
assert audit['proxy_type'] == 'nginx'
assert audit['expected_plane'] == INGRESS_NGINX
assert audit['app_count'] == 2
assert audit['mismatch_count'] == 0
def test_audit_flags_nginx_app_on_proxy_server(app):
from app.services.proxy_stack_service import ProxyStackService
with app.app_context():
s = _make_server(app, 'traefik-srv')
ProxyStackService.switch(s.id, 'traefik') # server now expects proxy_stack
wp = _make_app(s.id, 'wordpress', name='wp') # expects nginx -> mismatch
svc = _make_app(s.id, 'docker', ingress_plane='proxy_stack', name='svc') # matches
audit = ProxyStackService.ingress_audit(s.id)
assert audit['expected_plane'] == INGRESS_PROXY
assert audit['app_count'] == 2
assert audit['mismatch_count'] == 1
by_name = {r['name']: r for r in audit['apps']}
assert by_name['wp']['mismatch'] is True
assert by_name['wp']['reason']
assert by_name['svc']['mismatch'] is False
def test_fleet_overview_reports_mismatch_counts(app):
from app.services.proxy_stack_service import ProxyStackService
with app.app_context():
s = _make_server(app, 'fleet-srv')
ProxyStackService.switch(s.id, 'caddy')
_make_app(s.id, 'php', name='php-site') # nginx plane on a caddy server -> mismatch
rows = {r['server_id']: r for r in ProxyStackService.fleet_overview()}
row = rows[s.id]
assert row['proxy_type'] == 'caddy'
assert row['app_count'] == 1
assert row['mismatch_count'] == 1
def test_audit_endpoint_resolves(app, client, auth_headers):
with app.app_context():
s = _make_server(app, 'ep-srv')
sid = s.id # capture before the context (and the row) detaches
res = client.get(f'/api/v1/servers/{sid}/proxy/ingress-audit', headers=auth_headers)
assert res.status_code == 200
body = res.get_json()
assert 'expected_plane' in body and 'apps' in body