mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
`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>
144 lines
4.6 KiB
Python
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:]))
|