test(tor): let onion-surfaceless domain roles opt out of the tor contract

test_tor_contract gains a '# nocheck: tor-contract' opt-out (it previously only excluded svc-net-tor by name). web-opt-rdr-domains, a redirect-only role with no onion surface, drops its tor block and marks the exemption; its variant-coverage stub is removed with it. The new rule is registered in docs/contributing/actions/testing/suppression.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-07-06 23:53:17 +02:00
1 parent 3f7face0aa
commit c4e64656be
3 files changed
+145 -5

No files matched your search

@@ -79,6 +79,7 @@ The placement rule is per check. The catalog "Position" column uses these labels
| `pkgmgr-deprecated` | same or above | [test_pkgmgr_deprecated.py](../../../../tests/lint/repository/dependencies/test_pkgmgr_deprecated.py) | Suppresses the warn-only deprecation notice for `include_role` / `import_role` of `pkgmgr` / `pkgmgr-install`. The check emits a `DeprecationWarning` locally and a GitHub Actions `::warning` annotation in CI. Prefer `pip install <name>` or `pip install git+<repo>`. |
| `deprecated-http-header` | same or above | [test_no_deprecated_http_security_headers.py](../../../../tests/lint/repository/network/test_no_deprecated_http_security_headers.py) | Exempts a deprecated `add_header` / `Header set/add` line for `X-Frame-Options` / `X-XSS-Protection` / `X-Download-Options` / `X-Permitted-Cross-Domain-Policies`. Prefer Content-Security-Policy via the role's `meta/server.yml` (`csp.flags` / `csp.whitelist`). |
| `seaweedfs-required` | head (first 30 lines) | [test_database_requires_seaweedfs.py](../../../../tests/integration/roles/meta/services/test_database_requires_seaweedfs.py) | Exempts a database-backed `web-app-*` role from the requirement to declare a seaweedfs object-store service. Place in the head of `roles/<role>/meta/services.yml`. Use only when the application genuinely needs no object storage. |
| `tor-contract` | anywhere in file | [test_tor_contract.py](../../../../tests/lint/ansible/roles/meta/test_tor_contract.py) | Exempts a domain-serving role (`web-`/`svc-db-` prefix or explicit `domains.canonical`) from the "must declare a `tor:` block in `meta/services.yml`" contract. Reserved for roles with no onion surface, e.g. pure redirect helpers. |
## Tool-Native `noqa` Codes 🧹
+1 -5
View File
@@ -1,4 +1,5 @@
---
# nocheck: tor-contract -- redirect-only role, no onion surface
rdr-domains:
lifecycle: beta
min_storage: "50MB"
@@ -6,8 +7,3 @@ rdr-domains:
mem_reservation: "32m"
mem_limit: "64m"
pids_limit: 64
tor:
bond: 1
enabled: "{{ 'svc-net-tor' in group_names }}"
shared: true # nocheck: dynamic-flag
@@ -0,0 +1,143 @@
"""Lint guard for the per-role tor (onion) service-block contract.
Every role that serves a domain MUST wire up the shared tor provider by
declaring a ``tor:`` block in ``meta/services.yml``. "Serves a domain" mirrors
``plugins/filter/canonical_domains_map.py``: the role name starts with
``web-``/``svc-db-`` (auto-default ``<entity>.<DOMAIN_PRIMARY>`` domain) or it
declares an explicit ``domains.canonical`` in ``meta/server.yml``. The block
controls whether the app is additionally reachable over the node onion and how:
* ``enabled`` — present, gates onion routing for the app (typically
``"{{ 'svc-net-tor' in group_names }}"``).
* ``shared`` — present; the app rides the shared node onion as a subdomain.
``exclusive`` (drop the clearnet vhost, onion only) and ``primary`` (make the
onion the app's canonical domain) are NOT required per consumer: they default
from the ``svc-net-tor`` provider's own ``tor`` block and are resolved via
``resolve_service_config`` (consumer-override -> provider-native). A consumer
MAY still declare either field to override the provider default.
The block is a shared-service reference to the ``tor`` provider (svc-net-tor),
so ``test_service_shared_consistency`` and ``test_service_bond`` additionally
require ``shared`` next to ``enabled`` and a ``bond``.
A role that serves a domain but legitimately must not ride the node onion (e.g.
a pure redirect helper with no onion surface) MAY opt out with
``# nocheck: tor-contract`` anywhere in its ``meta/services.yml``.
"""
from __future__ import annotations
import unittest
from typing import TYPE_CHECKING, Any
import yaml
from utils.annotations.suppress import line_has_rule
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_str
from utils.roles.mapping import ROLE_FILE_META_SERVER, ROLE_FILE_META_SERVICES
from . import PROJECT_ROOT
if TYPE_CHECKING:
from pathlib import Path
ROLES_DIR = PROJECT_ROOT / "roles"
_REQUIRED_FIELDS = ("enabled", "shared")
_AUTO_DEFAULT_PREFIXES = ("web-", "svc-db-")
_RULE = "tor-contract"
def _load_yaml(path: Path) -> Any:
if not path.is_file():
return None
try:
text = read_text(str(path))
except UnicodeDecodeError:
return None
if not text.strip():
return None
try:
return load_yaml_str(text)
except yaml.YAMLError:
return None
def _has_canonical_domain(server: Any) -> bool:
if not isinstance(server, dict):
return False
domains = server.get("domains")
if not isinstance(domains, dict):
return False
canonical = domains.get("canonical")
return bool(canonical)
def _is_present(value: Any) -> bool:
if isinstance(value, bool):
return True
if isinstance(value, str):
return value.strip() != ""
return value is not None
class TestTorRoleContract(unittest.TestCase):
def test_domain_roles_declare_tor_block(self) -> None:
violations: list[str] = []
for role_dir in sorted(ROLES_DIR.iterdir()):
if not role_dir.is_dir():
continue
role_name = role_dir.name
server = _load_yaml(role_dir / ROLE_FILE_META_SERVER)
if not (
role_name.startswith(_AUTO_DEFAULT_PREFIXES)
or _has_canonical_domain(server)
):
continue
if role_name == "svc-net-tor":
continue
services_path = role_dir / ROLE_FILE_META_SERVICES
if services_path.is_file() and any(
line_has_rule(line, _RULE)
for line in read_text(str(services_path)).splitlines()
):
continue
services = _load_yaml(services_path)
if not isinstance(services, dict):
violations.append(
f"{role_name}: has domains.canonical but no readable "
f"meta/services.yml to declare the tor block."
)
continue
tor = services.get("tor")
if not isinstance(tor, dict):
violations.append(
f"{role_name}: has domains.canonical but no `tor:` block in "
f"meta/services.yml."
)
continue
violations.extend(
f"{role_name}: meta/services.yml.tor.{field} is missing or empty."
for field in _REQUIRED_FIELDS
if field not in tor or not _is_present(tor.get(field))
)
if violations:
self.fail(
"Roles serving a domain must declare a complete tor service "
"block:\n"
+ "\n".join(f" - {v}" for v in violations)
+ "\n\nAdd to the role's meta/services.yml (exclusive/primary "
+ "default from the svc-net-tor provider):\n"
+ " tor:\n"
+ " bond: 1\n"
+ " enabled: \"{{ 'svc-net-tor' in group_names }}\"\n"
+ " shared: true # nocheck: dynamic-flag\n"
)
if __name__ == "__main__":
unittest.main()