Skip to content

Commit

Permalink
Merge branch 'main' into OPENG-2973
Browse files Browse the repository at this point in the history
  • Loading branch information
IbraAoad authored Dec 3, 2024
2 parents 2b43f15 + 3698ce9 commit 431bdf1
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 52 deletions.
5 changes: 2 additions & 3 deletions .github/.jira_sync_config.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
settings:
jira_project_key: "OPENG"
jira_project_key: "SMS"
status_mapping:
opened: Untriaged
closed: done
not_planned: rejected

components:
- traefik
- team-mesh


add_gh_comment: false
sync_description: false
sync_comments: false
Expand Down
85 changes: 36 additions & 49 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -939,60 +939,47 @@ def _provide_routed_ingress(self, relation: Relation):
self._update_dynamic_config_route(relation, dct)

def _update_dynamic_config_route(self, relation: Relation, config: dict):
if "http" in config.keys():
route_config = config["http"].get("routers", {})
# we want to generate and add a new router with TLS config for each routed path.
# as we mutate the dict, we need to work on a copy
for router_name in route_config.copy().keys():
route_rule = route_config.get(router_name, {}).get("rule", "")
service_name = route_config.get(router_name, {}).get("service", "")
entrypoints = route_config.get(router_name, {}).get("entryPoints", [])
if len(entrypoints) > 0:
# if entrypoint exists, we check if it's a custom entrypoint to pass it to generated TLS config
entrypoint = entrypoints[0] if entrypoints[0] != "web" else None
else:
entrypoint = None
def _process_routes(route_config, protocol):
for router_name in list(route_config.keys()): # Work on a copy of the keys
router_details = route_config[router_name]
route_rule = router_details.get("rule", "")
service_name = router_details.get("service", "")
entrypoints = router_details.get("entryPoints", [])
tls_config = router_details.get("tls", {})

# Skip generating new routes if passthrough is True
if tls_config.get("passthrough", False):
logger.debug(
f"Skipping TLS generation for {protocol} router {router_name} (passthrough True)."
)
continue

entrypoint = entrypoints[0] if entrypoints else None
if protocol == "http" and entrypoint == "web":
entrypoint = None # Ignore "web" entrypoint for HTTP

if not all([router_name, route_rule, service_name]):
logger.debug("Not enough information to generate a TLS config!")
else:
config["http"]["routers"].update(
self.traefik.generate_tls_config_for_route(
router_name,
route_rule,
service_name,
# we're behind an is_ready guard, so this is guaranteed not to raise
self.external_host,
entrypoint,
)
logger.debug(
f"Not enough information to generate a TLS config for {protocol} router {router_name}!"
)
if "tcp" in config.keys():
route_config = config["tcp"].get("routers", {})
# we want to generate and add a new router with TLS config for each routed path.
# as we mutate the dict, we need to work on a copy
for router_name in route_config.copy().keys():
route_rule = route_config.get(router_name, {}).get("rule", "")
service_name = route_config.get(router_name, {}).get("service", "")
entrypoints = route_config.get(router_name, {}).get("entryPoints", [])
if len(entrypoints) > 0:
# for grpc, all entrypoints are custom
entrypoint = entrypoints[0]
else:
entrypoint = None
continue

if not all([router_name, route_rule, service_name]):
logger.debug("Not enough information to generate a TLS config!")
else:
config["tcp"]["routers"].update(
self.traefik.generate_tls_config_for_route(
router_name,
route_rule,
service_name,
# we're behind an is_ready guard, so this is guaranteed not to raise
self.external_host,
entrypoint,
)
config[protocol]["routers"].update(
self.traefik.generate_tls_config_for_route(
router_name,
route_rule,
service_name,
self.external_host,
entrypoint,
)
)

if "http" in config:
_process_routes(config["http"].get("routers", {}), protocol="http")

if "tcp" in config:
_process_routes(config["tcp"].get("routers", {}), protocol="tcp")

self._push_configurations(relation, config)

def _provide_ingress(
Expand Down
105 changes: 105 additions & 0 deletions tests/integration/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@
# See LICENSE file for licensing details.

import asyncio
import json
import logging
from typing import Optional

import requests
import sh
from juju.application import Application
from juju.unit import Unit
from minio import Minio
from pytest_operator.plugin import OpsTest
from tenacity import retry, stop_after_attempt, wait_exponential

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -94,3 +100,102 @@ def dequote(s: str):
if isinstance(s, str) and s.startswith('"') and s.endswith('"'):
s = s[1:-1]
return s


async def deploy_and_configure_minio(ops_test: OpsTest) -> None:
"""Deploy and set up minio and s3-integrator needed for s3-like storage backend in the HA charms."""
config = {
"access-key": "accesskey",
"secret-key": "secretkey",
}
await ops_test.model.deploy("minio", channel="edge", trust=True, config=config)
await ops_test.model.wait_for_idle(apps=["minio"], status="active", timeout=2000)
minio_addr = await get_address(ops_test, "minio", 0)

mc_client = Minio(
f"{minio_addr}:9000",
access_key="accesskey",
secret_key="secretkey",
secure=False,
)

# create tempo bucket
found = mc_client.bucket_exists("tempo")
if not found:
mc_client.make_bucket("tempo")

# configure s3-integrator
s3_integrator_app: Application = ops_test.model.applications["s3-integrator"]
s3_integrator_leader: Unit = s3_integrator_app.units[0]

await s3_integrator_app.set_config(
{
"endpoint": f"minio-0.minio-endpoints.{ops_test.model.name}.svc.cluster.local:9000",
"bucket": "tempo",
}
)

action = await s3_integrator_leader.run_action("sync-s3-credentials", **config)
action_result = await action.wait()
assert action_result.status == "completed"


async def deploy_tempo_cluster(ops_test: OpsTest):
"""Deploys tempo in its HA version together with minio and s3-integrator."""
tempo_app = "tempo"
worker_app = "tempo-worker"
tempo_worker_charm_url, worker_channel = "tempo-worker-k8s", "edge"
tempo_coordinator_charm_url, coordinator_channel = "tempo-coordinator-k8s", "edge"
await ops_test.model.deploy(
tempo_worker_charm_url, application_name=worker_app, channel=worker_channel, trust=True
)
await ops_test.model.deploy(
tempo_coordinator_charm_url,
application_name=tempo_app,
channel=coordinator_channel,
trust=True,
)
await ops_test.model.deploy("s3-integrator", channel="edge")

await ops_test.model.integrate(tempo_app + ":s3", "s3-integrator" + ":s3-credentials")
await ops_test.model.integrate(tempo_app + ":tempo-cluster", worker_app + ":tempo-cluster")

await deploy_and_configure_minio(ops_test)
async with ops_test.fast_forward():
await ops_test.model.wait_for_idle(
apps=[tempo_app, worker_app, "s3-integrator"],
status="active",
timeout=2000,
idle_period=30,
)


def get_traces(tempo_host: str, service_name="tracegen-otlp_http", tls=True):
"""Get traces directly from Tempo REST API."""
url = f"{'https' if tls else 'http'}://{tempo_host}:3200/api/search?tags=service.name={service_name}"
req = requests.get(
url,
verify=False,
)
assert req.status_code == 200
traces = json.loads(req.text)["traces"]
return traces


@retry(stop=stop_after_attempt(15), wait=wait_exponential(multiplier=1, min=4, max=10))
async def get_traces_patiently(tempo_host, service_name="tracegen-otlp_http", tls=True):
"""Get traces directly from Tempo REST API, but also try multiple times.
Useful for cases when Tempo might not return the traces immediately (its API is known for returning data in
random order).
"""
traces = get_traces(tempo_host, service_name=service_name, tls=tls)
assert len(traces) > 0
return traces


async def get_application_ip(ops_test: OpsTest, app_name: str) -> str:
"""Get the application IP address."""
status = await ops_test.model.get_status()
app = status["applications"][app_name]
return app.public_address
54 changes: 54 additions & 0 deletions tests/integration/test_workload_tracing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

import logging
from pathlib import Path

import pytest
import yaml
from helpers import deploy_tempo_cluster, get_application_ip, get_traces_patiently

logger = logging.getLogger(__name__)

METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
APP_NAME = "traefik"
TEMPO_APP_NAME = "tempo"
RESOURCES = {
"traefik-image": METADATA["resources"]["traefik-image"]["upstream-source"],
}


async def test_setup_env(ops_test):
await ops_test.model.set_config({"logging-config": "<root>=WARNING; unit=DEBUG"})


@pytest.mark.abort_on_fail
async def test_workload_tracing_is_present(ops_test, traefik_charm):
logger.info("deploying tempo cluster")
await deploy_tempo_cluster(ops_test)

logger.info("deploying local charm")
await ops_test.model.deploy(
traefik_charm, resources=RESOURCES, application_name=APP_NAME, trust=True
)
await ops_test.model.wait_for_idle(
apps=[APP_NAME], status="active", timeout=300, wait_for_exact_units=1
)

# we relate _only_ workload tracing not to confuse with charm traces
await ops_test.model.add_relation(
"{}:workload-tracing".format(APP_NAME), "{}:tracing".format(TEMPO_APP_NAME)
)
# but we also relate tempo to route through traefik so there's any traffic to generate traces from
await ops_test.model.add_relation(
"{}:ingress".format(TEMPO_APP_NAME), "{}:traefik-route".format(APP_NAME)
)
await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active")

# Verify workload traces are ingested into Tempo
assert await get_traces_patiently(
await get_application_ip(ops_test, TEMPO_APP_NAME),
service_name=f"{APP_NAME}",
tls=False,
)
84 changes: 84 additions & 0 deletions tests/unit/test_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,42 @@
}
}

TCP_CONFIG_WITH_PASSTHROUGH = {
"tcp": {
"routers": {
"juju-foo-router": {
"entryPoints": ["websecure"],
"rule": "HostSNI(`*`)",
"service": "juju-foo-service",
"tls": {"passthrough": True}, # Passthrough enabled
}
},
"services": {
"juju-foo-service": {
"loadBalancer": {"servers": [{"address": "foo.testmodel-endpoints.local:8080"}]}
}
},
}
}

HTTP_CONFIG_WITH_PASSTHROUGH = {
"http": {
"routers": {
"juju-foo-router": {
"entryPoints": ["web"],
"rule": "PathPrefix(`/path`)",
"service": "juju-foo-service",
"tls": {"passthrough": True}, # Passthrough enabled
}
},
"services": {
"juju-foo-service": {
"loadBalancer": {"servers": [{"url": "http://foo.testmodel-endpoints.local:8080"}]}
}
},
}
}

CONFIG_WITH_TLS = {
"http": {
"routers": {
Expand Down Expand Up @@ -339,3 +375,51 @@ def test_static_config_updates_tcp_entrypoints(

# AND that shows up in the service ports
assert [p for p in charm._service_ports if p.port == 6767][0]


def test_tls_http_passthrough_no_tls_added(harness: Harness[TraefikIngressCharm]):
"""Ensure no TLS configuration is generated for routes with tls.passthrough."""
tr_relation_id, relation = initialize_and_setup_tr_relation(harness)
charm = harness.charm

# Update relation with the passthrough configuration
config = yaml.dump(HTTP_CONFIG_WITH_PASSTHROUGH)
harness.update_relation_data(tr_relation_id, REMOTE_APP_NAME, {"config": config})

# Verify the relation is ready and the configuration is loaded
assert charm.traefik_route.is_ready(relation)
assert charm.traefik_route.get_config(relation) == config

# Check the dynamic configuration written to the container
file = f"/opt/traefik/juju/juju_ingress_{relation.name}_{relation.id}_{relation.app.name}.yaml"
dynamic_config = yaml.safe_load(charm.container.pull(file).read())

# Ensure the passthrough configuration is preserved
assert dynamic_config == HTTP_CONFIG_WITH_PASSTHROUGH

# Check no additional TLS configurations are added
assert "juju-foo-router-tls" not in dynamic_config["http"]["routers"]


def test_tls_tcp_passthrough_no_tls_added(harness: Harness[TraefikIngressCharm]):
"""Ensure no TLS configuration is generated for routes with tls.passthrough."""
tr_relation_id, relation = initialize_and_setup_tr_relation(harness)
charm = harness.charm

# Update relation with the passthrough configuration
config = yaml.dump(TCP_CONFIG_WITH_PASSTHROUGH)
harness.update_relation_data(tr_relation_id, REMOTE_APP_NAME, {"config": config})

# Verify the relation is ready and the configuration is loaded
assert charm.traefik_route.is_ready(relation)
assert charm.traefik_route.get_config(relation) == config

# Check the dynamic configuration written to the container
file = f"/opt/traefik/juju/juju_ingress_{relation.name}_{relation.id}_{relation.app.name}.yaml"
dynamic_config = yaml.safe_load(charm.container.pull(file).read())

# Ensure the passthrough configuration is preserved
assert dynamic_config == TCP_CONFIG_WITH_PASSTHROUGH

# Check no additional TLS configurations are added
assert "juju-foo-router-tls" not in dynamic_config["tcp"]["routers"]
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ deps =
# fix for https://github.com/jd/tenacity/issues/471
tenacity==8.3.0
sh
minio
-r{toxinidir}/requirements.txt
commands =
pytest -v --tb native --log-cli-level=INFO -s {[vars]tst_path}/integration {posargs}
Expand Down
Binary file removed traefik-k8s
Binary file not shown.

0 comments on commit 431bdf1

Please sign in to comment.