Files
computer-playbook/utils/cleanup/nginx_vhosts.py
T
kevinveenbirkenbachandClaude Opus 4.7 0effa1670a fix(purge): remove www.<domain>.conf redirect vhosts with entity purge
`web-opt-rdr-www` generates a redirect vhost
`<NGINX_DIR>/conf.d/servers/{http,https}/www.<domain>.conf` for every
deployed app's canonical and alias domains. Those redirect vhosts are
not part of any app's `server.domains.{canonical,aliases}` list, so the
entity-keyed purge primitive in `utils/cleanup/nginx_vhosts.py` did
not yield them and `iter_vhost_files_for_entity` left them on disk.

Between matrix-deploy rounds this leaked the redirect vhosts of the
previous round's broader app set into the next round's narrower one.
On run 26451828677 job 77879571696 (web-opt-rdr-www on debian) the
round 1 -> round 2 transition

    round 1/2 inv=github-0 apps=[15 apps incl. web-svc-cdn,
                                web-svc-simpleicons, web-svc-css, ...]
    round 2/2 inv=github-1 apps=[web-opt-rdr-www]

dropped the bare `cdn.infinito.example.conf` etc. via the existing
purge but kept the matching `www.cdn.infinito.example.conf`,
`www.icon.infinito.example.conf`, ... behind. Round 2 only redeploys
web-opt-rdr-www, whose `www_redirect_domains` lookup now only emits
its own `www.w3redirect.<primary>` redirect, so nothing reissues the
stale www-prefixed vhosts.

`sys-ctl-hlth-csp` discovers domains by scanning .conf filenames in
the nginx servers dir, so it still probed the stale `www.<x>.conf`
hosts. nginx served them with a cert whose SAN no longer covered the
hostname (the per-domain cert path baked into the conf had been
removed alongside the bare vhost), the TLS handshake returned
`net::ERR_SSL_UNRECOGNIZED_NAME_ALERT`, the CSP probe exited 11, and
`sys-service-terminator : Apply state` failed after 10 retries.

Extend `iter_vhost_files_for_entity` to also yield the
`www.<domain>.conf` variant for every domain it already yields,
skipping domains that already begin with `www.` so a role that
declares its canonical with a leading `www.` (web-opt-rdr-www's own
`www.w3redirect.<primary>`) does not produce `www.www.<x>.conf`. The
existing two- and three-step purge primitives (db / compose / dir /
nginx) and the orchestrator in `scripts/container/purge/apps.sh` keep
their entity-keyed contract; only the per-entity yield set widens.

Coverage:
- test_yields_www_redirect_variant: per-app www-prefixed file is
  yielded alongside the bare vhost.
- test_www_prefixed_domain_not_double_prefixed: a www-prefixed
  canonical does not synthesize `www.www.<x>` even when such a file
  exists on disk (defense in depth against a future regression that
  prepends unconditionally).
- test_removes_www_redirect_variant: end-to-end purge removes both
  bare and www variant, leaves an unrelated www-prefixed file alone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:24:17 +02:00

144 lines
4.6 KiB
Python

"""nginx vhost-file purge helper (entity-keyed).
Matrix-deploy variant transitions can leave per-domain nginx vhost
configs behind: when a service drops out of a round's include set,
``sys-svc-compose``'s aggressive docker prune removes the container,
but the vhost file under
``<NGINX_DIR>/conf.d/servers/{http,https}/<domain>.conf`` survives.
The next round's probe then gets HTTP 502 (nginx serves the vhost with
no upstream) instead of the expected TLS-error 000.
The entity-keyed purge primitive
``scripts/container/purge/entity/nginx.sh`` (driven by the app-keyed
orchestrator ``apps.sh`` as a sibling of ``db.sh``, ``compose.sh``,
``dir.sh``) invokes this module to remove the vhost files belonging to
a given compose entity. The mapping entity -> apps -> domains is
resolved via
``utils.roles.entity_apps.apps_for_entity`` plus
``utils.domains.list.build_applications_from_roles`` /
``utils.domains.application_domain_index.iter_app_domains`` so the
helper stays ansible-free and runnable inside the infinito container
without a templar context.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from utils.domains.application_domain_index import iter_app_domains
from utils.domains.list import ROLES_DIR, build_applications_from_roles
from utils.roles.entity_apps import apps_for_entity
if TYPE_CHECKING:
from collections.abc import Iterable
DEFAULT_NGINX_DIR = Path("/etc/nginx")
DEFAULT_DOMAIN_PRIMARY = "infinito.example"
_PROTOCOLS: tuple[str, ...] = ("http", "https")
def _resolve_nginx_dir(nginx_dir: Path | None) -> Path:
if nginx_dir is not None:
return nginx_dir
env_value = os.environ.get("NGINX_DIR")
return Path(env_value) if env_value else DEFAULT_NGINX_DIR
def _resolve_domain_primary(domain_primary: str | None) -> str:
if domain_primary is not None:
return domain_primary
return os.environ.get("DOMAIN", DEFAULT_DOMAIN_PRIMARY)
def iter_vhost_files_for_entity(
entity: str,
*,
nginx_dir: Path,
domain_primary: str,
roles_dir: Path = ROLES_DIR,
) -> Iterable[Path]:
"""Yield existing vhost files (http + https) for every app belonging
to *entity*, plus the ``www.<domain>.conf`` redirect variant
generated by ``web-opt-rdr-www``. Only yields files that exist.
"""
apps = apps_for_entity(entity, roles_dir=roles_dir)
if not apps:
return
applications = build_applications_from_roles(roles_dir, domain_primary)
servers_dir = nginx_dir / "conf.d" / "servers"
for app_id in apps:
app_conf = applications.get(app_id)
if not app_conf:
continue
for domain in iter_app_domains(app_conf, include_aliases=True):
domain_variants = [domain]
if not domain.startswith("www."):
domain_variants.append(f"www.{domain}")
for variant in domain_variants:
for proto in _PROTOCOLS:
conf_file = servers_dir / proto / f"{variant}.conf"
if conf_file.is_file():
yield conf_file
def purge_vhost_files_for_entities(
entities: list[str],
*,
nginx_dir: Path | None = None,
domain_primary: str | None = None,
roles_dir: Path = ROLES_DIR,
) -> list[Path]:
"""Remove vhost files for every entity in *entities*. Returns the
list of paths that were actually removed (existed and unlink
succeeded), in iteration order.
"""
nd = _resolve_nginx_dir(nginx_dir)
dp = _resolve_domain_primary(domain_primary)
removed: list[Path] = []
for entity in entities:
for path in iter_vhost_files_for_entity(
entity,
nginx_dir=nd,
domain_primary=dp,
roles_dir=roles_dir,
):
try:
path.unlink()
except FileNotFoundError:
continue
except OSError as exc:
print(
f"!!! WARNING: failed to remove {path}: {exc}",
file=sys.stderr,
)
continue
removed.append(path)
return removed
def main(argv: list[str]) -> int:
if not argv:
print(
"usage: python -m utils.cleanup.nginx_vhosts <ENTITY> [ENTITY ...]",
file=sys.stderr,
)
return 2
removed = purge_vhost_files_for_entities(argv)
if removed:
for path in removed:
print(f">>> removed {path}")
else:
print(">>> No nginx vhost files to remove for entities: " + ", ".join(argv))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))