diff --git a/CHANGES.rst b/CHANGES.rst index 342626be..df0ccd1a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog Unreleased ---------- +* Fixed the grand-central hostname being corrupted when the cluster name recurs + later in its external DNS name. + 2.63.0 (2026-08-12) ------------------- diff --git a/crate/operator/grand_central.py b/crate/operator/grand_central.py index 4f37358d..a14e1037 100644 --- a/crate/operator/grand_central.py +++ b/crate/operator/grand_central.py @@ -766,6 +766,20 @@ def grand_central_uses_traefik(spec: kopf.Spec) -> bool: return get_grand_central_exposure(spec) == "traefik" +def _grand_central_hostname(cluster_name: str, external_dns: str) -> str: + """ + Derive the grand-central hostname from a cluster's external DNS name. + + ``external_dns`` is built as ``...`` so the + cluster name is always its leading label. The grand-central hostname is the + same name with a ``.gc`` label inserted after that leading label. + + :param cluster_name: The CrateDB cluster name (the leading DNS label). + :param external_dns: The cluster's external DNS name (may have a trailing dot). + """ + return external_dns.replace(cluster_name, f"{cluster_name}.gc", 1).rstrip(".") + + async def create_grand_central_exposure( namespace: str, name: str, @@ -794,7 +808,7 @@ async def create_grand_central_exposure( owner_references = get_owner_references(name, meta) cluster_name = spec["cluster"]["name"] external_dns = spec["cluster"]["externalDNS"] - hostname = external_dns.replace(cluster_name, f"{cluster_name}.gc").rstrip(".") + hostname = _grand_central_hostname(cluster_name, external_dns) labels = get_grand_central_labels(name, meta) cidrs = spec["cluster"].get("allowedCIDRs", None) diff --git a/tests/test_grand_central_exposure.py b/tests/test_grand_central_exposure.py index ad35bf5e..4b72ee69 100644 --- a/tests/test_grand_central_exposure.py +++ b/tests/test_grand_central_exposure.py @@ -22,6 +22,7 @@ import pytest from crate.operator.grand_central import ( + _grand_central_hostname, get_grand_central_exposure, grand_central_uses_traefik, ) @@ -70,3 +71,38 @@ def test_explicit_grand_central_exposure_wins_over_cluster(): def test_grand_central_exposure_falls_back_to_cluster(): spec = {"cluster": {"exposure": "traefik"}} assert get_grand_central_exposure(spec) == "traefik" + + +@pytest.mark.parametrize( + "cluster_name, external_dns, expected", + [ + # Non-colliding: cluster name appears only as the leading label. + ( + "mycluster", + "mycluster.eks1.us-west-2.aws.cratedb.net.", + "mycluster.gc.eks1.us-west-2.aws.cratedb.net", + ), + # Collision with the region: 'us-west' also occurs in 'us-west-2'. + ( + "us-west", + "us-west.eks1.us-west-2.aws.cratedb.net.", + "us-west.gc.eks1.us-west-2.aws.cratedb.net", + ), + # Collision with the cloud suffix: 'aws' also occurs in '.aws.'. + ( + "aws", + "aws.eks1.us-west-2.aws.cratedb.net.", + "aws.gc.eks1.us-west-2.aws.cratedb.net", + ), + # Cluster literally named 'gc'. + ( + "gc", + "gc.eks1.eu-central-1.azure.cratedb.net.", + "gc.gc.eks1.eu-central-1.azure.cratedb.net", + ), + ], +) +def test_grand_central_hostname_only_replaces_leading_label( + cluster_name, external_dns, expected +): + assert _grand_central_hostname(cluster_name, external_dns) == expected