feat(pro): generalize pro tier to multi-surface extension points

Move the pro mount decnet/services/pro/ -> decnet/pro/ so the Professional tier
can contribute to more than honeypots. The core wires each surface only when
decnet/pro/ is present (absence stays the entitlement gate):

* services  — registry scans decnet/pro/services/ (was decnet/services/pro/)
* API routes — decnet/pro/routes.py exposes ROUTERS, mounted under /api/v1
* web pages  — Vite aliases @pro to the pro frontend (community -> empty stub),
               App.tsx maps proRoutes into <Route>s, Layout renders a
               PROFESSIONAL nav group; both tree-shake out of the community build

Frontend gate mirrors the existing VITE_DECNET_DEVELOPER tree-shake pattern.
Tests: registry + router seams (backend), empty-stub contract (frontend).
This commit is contained in:
2026-06-17 15:02:28 -04:00
parent 80c92a6f80
commit a47f99c449
13 changed files with 151 additions and 57 deletions

View File

@@ -1,22 +1,21 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Open-core tier split: the Professional build supplies advanced honeypots via the
optional decnet/services/pro/ subpackage (a separate private repo cloned into
this path; git-ignored here so it never enters the open-core tree). The
Community build simply omits it.
Open-core tier seams: the Professional tier is a separate private repo mounted at
decnet/pro/ (git-ignored here) that contributes to several core surfaces, each
wired only when the package is present:
The registry must auto-discover pro honeypots when present — including ones that
EXTEND a community service rather than subclassing BaseService directly (the
recursive-subclass walk). Absence of a pro module is the entitlement gate; there
is no licence check.
* decnet/pro/services/ — advanced honeypots, discovered by the service registry.
* decnet/pro/routes.py — ROUTERS, mounted under /api/v1 by the web router.
One test on purpose: it mutates decnet/services/pro/ and the process-global
registry, so it cannot race a sibling test under xdist. It tolerates a pro/ dir
that already exists (developer tree) and leaves the registry pristine.
Absence of decnet/pro/ is the entitlement gate; there is no licence check.
One test function on purpose: it mutates decnet/pro/services/, the process-global
service registry, and reloads decnet.web.router — doing that sequentially in one
worker avoids the xdist races that separate tests on shared state would hit.
"""
import gc
import shutil
import importlib
import sys
from pathlib import Path
@@ -26,50 +25,64 @@ _DEMO_MOD = "_demo_pro_tier_test"
_DEMO_NAME = "demo-pro-honeypot"
def _reload_clean():
def _reload_registry():
reg._loaded = False
reg._registry.clear()
reg._load_plugins()
def test_pro_tier_packaging_gate():
pkg_dir = Path(reg.__file__).parent
pro_dir = pkg_dir / "pro"
init = pro_dir / "__init__.py"
demo = pro_dir / f"{_DEMO_MOD}.py"
created_dir = not pro_dir.exists()
def test_pro_tier_seams():
# --- service-discovery seam -------------------------------------------
services_dir = Path(reg.__file__).parent.parent / "pro" / "services"
demo = services_dir / f"{_DEMO_MOD}.py"
created_dir = not services_dir.exists()
if created_dir:
pro_dir.mkdir()
created_init = not init.exists()
if created_init:
init.write_text("")
services_dir.mkdir(parents=True)
(services_dir / "__init__.py").write_text("")
try:
# Gate closed: our pro honeypot absent, community services present.
_reload_clean()
assert _DEMO_NAME not in reg.all_services()
assert "ssh" in reg.all_services()
_reload_registry()
assert _DEMO_NAME not in reg.all_services() # gate closed
assert "ssh" in reg.all_services() # community untouched
# Professional build: drop in a pro honeypot that EXTENDS a community
# service (only reachable via the recursive subclass walk).
# A pro honeypot that EXTENDS a community service — only reachable via
# the registry's recursive subclass walk.
demo.write_text(
"from decnet.services.ssh import SSHService\n"
"class DemoProHoneypot(SSHService):\n"
f" name = {_DEMO_NAME!r}\n"
)
_reload_clean()
svcs = reg.all_services()
assert _DEMO_NAME in svcs # pro discovered
assert "ssh" in svcs # community untouched
_reload_registry()
assert _DEMO_NAME in reg.all_services()
finally:
demo.unlink(missing_ok=True)
if created_init:
init.unlink(missing_ok=True)
if created_dir:
shutil.rmtree(pro_dir)
# Drop the dynamically-imported class so it can't pollute the registry
# for sibling tests sharing this worker.
sys.modules.pop(f"decnet.services.pro.{_DEMO_MOD}", None)
import shutil
shutil.rmtree(services_dir)
sys.modules.pop(f"decnet.pro.services.{_DEMO_MOD}", None)
gc.collect()
_reload_clean()
_reload_registry()
# --- API-router seam --------------------------------------------------
import decnet.pro.routes as pro_routes
import decnet.web.router as web_router
from fastapi import APIRouter, FastAPI
saved = pro_routes.ROUTERS
probe = APIRouter()
@probe.get("/pro/_probe_test")
async def _probe(): # pragma: no cover - never called, just registered
return {}
try:
pro_routes.ROUTERS = [probe]
importlib.reload(web_router)
# This FastAPI version defers route flattening (_IncludedRouter), so go
# through openapi() — it forces resolution and lists effective paths.
app = FastAPI()
app.include_router(web_router.api_router, prefix="/api/v1")
assert "/api/v1/pro/_probe_test" in app.openapi()["paths"]
finally:
pro_routes.ROUTERS = saved
importlib.reload(web_router) # rebuild a pro-free api_router for others