Skip to content

Commit e6b1d7c

Browse files
authored
fix: handle multiple istio-ingress-route relations (#336)
* fix: handle multiple istio-ingress-route relations Use model.relations[...] (a list) instead of model.get_relation(...) when checking ambient vs. sidecar ingress, so the charm no longer raises TooManyRelatedAppsError when more than one istio-ingress-route relation is present. submit_config already publishes the same config to every related ingress provider. Add unit and integration coverage for multiple ambient ingress relations. * test: widen metallb CIDR for multiple Istio ingress gateways Integration tests now deploy a second Istio ingress gateway, which needs its own load-balancer IP. Provide at least two IPs via a /31 CIDR. * test: strengthen istio-ingress-route unit tests Assert every istio-ingress-route relation receives a valid HTTPRoute config, assert the charm reconciles to active (rather than just 'not blocked') with multiple ambient relations, and factor the relation/app names into constants (reusing the charmed-kubeflow-chisme testing constants).
1 parent 31b1d6b commit e6b1d7c

6 files changed

Lines changed: 208 additions & 12 deletions

File tree

concierge.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ providers:
1313
load-balancer:
1414
enabled: true
1515
l2-mode: true
16-
cidrs: 10.64.140.43/32
16+
cidrs: 10.64.140.42/31 # NOTE: at least two IPs required for integration tests: one for each of the two Istio ingress gateways
1717
bootstrap-constraints:
1818
root-disk: 2G
1919

poetry.lock

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ optional = true
9898
juju = "<4.0"
9999
charmed-kubeflow-chisme = ">=0.4.18"
100100
aiohttp = "^3.10.11"
101+
tenacity = "^9.0.0"
101102
lightkube = "^0.15.6"
102103
ops = "^2.17.1"
103104
pytest = "^8.3.4"

src/charm.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,26 @@ def _ambient_mesh_ingress(self):
289289
],
290290
)
291291

292+
# submit_config publishes this same config to every istio-ingress-route
293+
# relation, so all related ingress providers are (re)configured at once.
292294
if self.unit.is_leader():
293295
self.ingress.submit_config(config)
294296

295297
def _check_istio_relations(self):
296-
"""Check that both ambient and sidecar relations are not present simultaneously."""
297-
ambient_relation = self.model.get_relation("istio-ingress-route")
298-
sidecar_relation = self.model.get_relation("ingress")
298+
"""Validate the charm's ingress relations are mutually exclusive.
299299
300-
if ambient_relation and sidecar_relation:
300+
The charm supports both ambient ingress (via the 'istio-ingress-route'
301+
endpoint) and sidecar ingress (via the 'ingress' endpoint), but the two
302+
cannot be used at the same time. Each endpoint may hold any number of relations,
303+
so this inspects the full list of relations on each endpoint.
304+
305+
Raises:
306+
CheckFailed: with BlockedStatus if relations exist on both endpoints.
307+
"""
308+
ambient_relations = self.model.relations["istio-ingress-route"]
309+
sidecar_relations = self.model.relations["ingress"]
310+
311+
if ambient_relations and sidecar_relations:
301312
self.logger.error(
302313
"Both 'istio-ingress-route' and 'ingress' relations are present, "
303314
"remove one to unblock."

tests/integration/test_charm_ambient.py

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88

99
import pytest
1010
import pytest_asyncio
11+
import tenacity
1112
import yaml
1213
from charmed_kubeflow_chisme.testing import (
1314
GRAFANA_AGENT_APP,
15+
ISTIO_INGRESS_K8S_APP,
16+
ISTIO_INGRESS_ROUTE_ENDPOINT,
1417
assert_grafana_dashboards,
1518
assert_logging,
1619
assert_metrics_endpoint,
@@ -26,6 +29,7 @@
2629
from charms_dependencies import KUBEFLOW_PROFILES
2730
from dashboard_links_requirer_tester_charm.src.charm import generate_links_for_location
2831
from lightkube import Client
32+
from lightkube.generic_resource import create_namespaced_resource
2933
from lightkube.resources.core_v1 import ConfigMap
3034
from pytest_operator.plugin import OpsTest
3135

@@ -53,6 +57,28 @@
5357
HTTP_PATH = "/volumes/"
5458
KUBEFLOW_PROFILES_RELATION_NAME = "kubeflow-profiles"
5559

60+
# A second istio-ingress-k8s instance used to verify multiple-ingress support.
61+
SECOND_INGRESS_APP = "istio-ingress-k8s-alt"
62+
INGRESS_CHANNEL = "2/stable"
63+
# Name of the HTTPRoute submitted by kubeflow-dashboard (see charm._ambient_mesh_ingress).
64+
INGRESS_ROUTE_NAME = "http-ingress"
65+
# Gateway listener section for cleartext HTTP on port 80.
66+
HTTP_SECTION_NAME = "http-80"
67+
# Path matched by the dashboard HTTPRoute.
68+
INGRESS_ROUTE_PATH = "/"
69+
# Gateway API generic resources, resolved at runtime via lightkube.
70+
HTTPROUTE_RESOURCE = create_namespaced_resource(
71+
"gateway.networking.k8s.io", "v1", "HTTPRoute", "httproutes"
72+
)
73+
GATEWAY_RESOURCE = create_namespaced_resource(
74+
"gateway.networking.k8s.io", "v1", "Gateway", "gateways"
75+
)
76+
RETRY_120_SECONDS = tenacity.Retrying(
77+
stop=tenacity.stop_after_delay(120),
78+
wait=tenacity.wait_fixed(2),
79+
reraise=True,
80+
)
81+
5682
log = logging.getLogger(__name__)
5783

5884

@@ -366,8 +392,7 @@ async def assert_links_in_configmap_by_text_value(
366392
return links_texts
367393

368394

369-
@pytest.mark.abort_on_fail
370-
async def test_ui_is_accessible(ops_test: OpsTest):
395+
async def assert_ui_is_accessible(ops_test: OpsTest):
371396
"""Verify that UI is accessible through the ingress gateway."""
372397
await assert_path_reachable_through_ingress(
373398
http_path=HTTP_PATH,
@@ -378,6 +403,93 @@ async def test_ui_is_accessible(ops_test: OpsTest):
378403
)
379404

380405

406+
@pytest.mark.abort_on_fail
407+
async def test_ui_is_accessible(ops_test: OpsTest):
408+
"""Verify that UI is accessible through the ingress gateway before the second ingress."""
409+
await assert_ui_is_accessible(ops_test)
410+
411+
412+
@pytest.mark.abort_on_fail
413+
async def test_deploy_and_relate_second_ingress(ops_test: OpsTest):
414+
"""Deploy a second istio-ingress-k8s and relate it to kubeflow-dashboard.
415+
416+
kubeflow-dashboard must accept more than one istio-ingress-route relation without
417+
erroring, so it should remain active after the second ingress is related.
418+
"""
419+
await ops_test.model.deploy(
420+
ISTIO_INGRESS_K8S_APP,
421+
application_name=SECOND_INGRESS_APP,
422+
channel=INGRESS_CHANNEL,
423+
trust=True,
424+
)
425+
await ops_test.model.wait_for_idle(
426+
[SECOND_INGRESS_APP],
427+
raise_on_blocked=False,
428+
raise_on_error=False,
429+
wait_for_active=True,
430+
timeout=60 * 15,
431+
)
432+
433+
await ops_test.model.integrate(
434+
f"{SECOND_INGRESS_APP}:{ISTIO_INGRESS_ROUTE_ENDPOINT}",
435+
f"{CHARM_NAME}:{ISTIO_INGRESS_ROUTE_ENDPOINT}",
436+
)
437+
await ops_test.model.wait_for_idle(
438+
[CHARM_NAME, SECOND_INGRESS_APP],
439+
status="active",
440+
raise_on_blocked=False,
441+
raise_on_error=False,
442+
timeout=60 * 10,
443+
idle_period=30,
444+
)
445+
446+
assert ops_test.model.applications[CHARM_NAME].units[0].workload_status == "active"
447+
448+
449+
async def test_httproute_attached_to_second_gateway(ops_test: OpsTest, lightkube_client: Client):
450+
"""Verify the HTTPRoute for the second ingress is created and bound to its Gateway.
451+
452+
The istio-ingress-k8s charm names each route
453+
``{source_app}-{route_name}-httproute-{section}-{ingress_app}`` and binds it to a
454+
Gateway named after the ingress application via ``parentRefs``. We assert that the
455+
route created for the second ingress is attached to the *second* Gateway (not the
456+
first) and routes the dashboard path to the dashboard backend.
457+
"""
458+
namespace = ops_test.model_name
459+
460+
expected_route_name = (
461+
f"{CHARM_NAME}-{INGRESS_ROUTE_NAME}-httproute-{HTTP_SECTION_NAME}-{SECOND_INGRESS_APP}"
462+
)
463+
464+
# The second Gateway should exist, named after the second ingress application.
465+
lightkube_client.get(GATEWAY_RESOURCE, name=SECOND_INGRESS_APP, namespace=namespace)
466+
467+
# Retry to give the ingress charm time to reconcile the HTTPRoute resources.
468+
httproute = None
469+
for attempt in RETRY_120_SECONDS:
470+
with attempt:
471+
httproute = lightkube_client.get(
472+
HTTPROUTE_RESOURCE, name=expected_route_name, namespace=namespace
473+
)
474+
475+
parent_refs = httproute.spec["parentRefs"]
476+
assert len(parent_refs) == 1
477+
# The route must be attached to the SECOND gateway, not the first.
478+
assert parent_refs[0]["name"] == SECOND_INGRESS_APP
479+
assert parent_refs[0]["sectionName"] == HTTP_SECTION_NAME
480+
481+
# And it must route the dashboard path to the dashboard backend.
482+
rule = httproute.spec["rules"][0]
483+
assert rule["matches"][0]["path"]["value"] == INGRESS_ROUTE_PATH
484+
assert rule["backendRefs"][0]["name"] == CHARM_NAME
485+
486+
487+
@pytest.mark.abort_on_fail
488+
async def test_ui_is_accessible_after_second_ingress(ops_test: OpsTest):
489+
"""Verify that UI is still accessible through the ingress gateway after the second ingress."""
490+
await assert_ui_is_accessible(ops_test)
491+
492+
381493
async def test_metrics_enpoint(ops_test: OpsTest):
382494
"""Test metrics_endpoints are defined in relation data bag and their accessibility.
383495
This function gets all the metrics_endpoints from the relation data bag, checks if

tests/unit/test_operator.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
import pytest
1010
import yaml
1111
from charmed_kubeflow_chisme.exceptions import GenericCharmRuntimeError
12-
from charms.istio_ingress_k8s.v0.istio_ingress_route import ProtocolType
12+
from charmed_kubeflow_chisme.testing import ISTIO_INGRESS_K8S_APP, ISTIO_INGRESS_ROUTE_ENDPOINT
13+
from charms.istio_ingress_k8s.v0.istio_ingress_route import (
14+
HTTPPathMatchType,
15+
IstioIngressRouteConfig,
16+
ProtocolType,
17+
)
1318
from charms.kubeflow_dashboard.v0.kubeflow_dashboard_links import (
1419
DASHBOARD_LINKS_FIELD,
1520
DashboardLink,
@@ -28,6 +33,10 @@
2833

2934
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
3035
CHARM_NAME = METADATA["name"]
36+
# Ingress relation endpoints and the apps used to exercise them in tests.
37+
SIDECAR_INGRESS_ENDPOINT = "ingress"
38+
SIDECAR_INGRESS_APP = "istio-pilot"
39+
SECOND_ISTIO_INGRESS_K8S_APP = f"{ISTIO_INGRESS_K8S_APP}-2"
3140
RELATION_DATA = [
3241
{
3342
"app": "tensorboards-web-app",
@@ -386,9 +395,9 @@ def test_sidecar_and_ambient_relations_added(
386395
):
387396
"""Test the charm is in BlockedStatus when both sidecar and ambient relations are added."""
388397
# Arrange
389-
harness.add_relation("ingress", "istio-pilot")
398+
harness.add_relation(SIDECAR_INGRESS_ENDPOINT, SIDECAR_INGRESS_APP)
390399

391-
harness.add_relation("istio-ingress-route", "istio-ingress-k8s")
400+
harness.add_relation(ISTIO_INGRESS_ROUTE_ENDPOINT, ISTIO_INGRESS_K8S_APP)
392401

393402
harness.set_leader(True)
394403

@@ -402,6 +411,62 @@ def test_sidecar_and_ambient_relations_added(
402411
BlockedStatus,
403412
)
404413

414+
@patch("charm.KubernetesServicePatch", lambda x, y: None)
415+
@patch("charm.KubeflowDashboardOperator.configmap_handler")
416+
@patch("charm.KubeflowDashboardOperator.k8s_resource_handler")
417+
def test_multiple_ambient_relations_added(
418+
self,
419+
k8s_resource_handler: MagicMock,
420+
configmap_handler: MagicMock,
421+
harness_with_profiles: Harness,
422+
):
423+
"""Test the charm reconciles to active with more than one istio-ingress-route relation."""
424+
# Arrange
425+
harness = harness_with_profiles
426+
harness.add_relation(ISTIO_INGRESS_ROUTE_ENDPOINT, ISTIO_INGRESS_K8S_APP)
427+
harness.add_relation(ISTIO_INGRESS_ROUTE_ENDPOINT, SECOND_ISTIO_INGRESS_K8S_APP)
428+
429+
# Act
430+
harness.begin_with_initial_hooks()
431+
harness.container_pebble_ready(harness.charm._container_name)
432+
433+
# Assert
434+
# More than one relation on the istio-ingress-route endpoint must not block
435+
# the charm; it should reconcile all the way to active.
436+
assert isinstance(harness.charm.model.unit.status, ActiveStatus)
437+
438+
@patch("charm.KubernetesServicePatch", lambda x, y: None)
439+
@patch("charm.KubeflowDashboardOperator.k8s_resource_handler")
440+
def test_each_istio_ingress_route_relation_receives_config(
441+
self, k8s_resource_handler: MagicMock, harness: Harness
442+
):
443+
"""Test that an HTTPRoute config is submitted to every istio-ingress-route relation."""
444+
# Arrange
445+
harness.set_leader(True)
446+
rel_id_1 = harness.add_relation(ISTIO_INGRESS_ROUTE_ENDPOINT, ISTIO_INGRESS_K8S_APP)
447+
rel_id_2 = harness.add_relation(ISTIO_INGRESS_ROUTE_ENDPOINT, SECOND_ISTIO_INGRESS_K8S_APP)
448+
449+
# Act
450+
harness.begin()
451+
452+
# Assert
453+
# Each relation's application databag should contain a valid config that
454+
# defines the kubeflow-dashboard HTTPRoute, proving the lib handles every ingress.
455+
for rel_id in (rel_id_1, rel_id_2):
456+
app_data = harness.get_relation_data(rel_id, harness.charm.app.name)
457+
assert "config" in app_data
458+
459+
config = IstioIngressRouteConfig.model_validate_json(app_data["config"])
460+
assert len(config.http_routes) == 1
461+
http_route = config.http_routes[0]
462+
assert http_route.matches[0].path.type == HTTPPathMatchType.PathPrefix
463+
assert http_route.matches[0].path.value == "/"
464+
assert http_route.backends[0].service == harness.charm.app.name
465+
assert http_route.backends[0].port == harness.charm._port
466+
# The route's parent (the Gateway listener referenced under parentRefs in
467+
# the HTTPRouteSpec) should be the expected HTTP listener.
468+
assert http_route.listener.name == "http-80"
469+
405470
@pytest.mark.parametrize("tls_enabled, expected_port", [(False, 80), (True, 443)])
406471
@patch("charm.KubernetesServicePatch", lambda x, y: None)
407472
@patch("charm.IstioIngressRouteRequirer")
@@ -419,7 +484,7 @@ def test_ambient_mesh_ingress(
419484
mock_ingress.tls_enabled = tls_enabled
420485
mock_ingress_cls.return_value = mock_ingress
421486

422-
harness.add_relation("istio-ingress-route", "istio-ingress-k8s")
487+
harness.add_relation(ISTIO_INGRESS_ROUTE_ENDPOINT, ISTIO_INGRESS_K8S_APP)
423488
harness.set_leader(True)
424489
harness.begin()
425490

0 commit comments

Comments
 (0)