Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: update the tls certificate in ingress-per-unit integration #90

Merged
merged 1 commit into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
provider: microk8s
channel: 1.31-strict/stable
juju-channel: 3.6
microk8s-addons: "dns hostpath-storage metallb:10.64.140.43-10.64.140.49"

- name: Run integration tests
run: tox -e build-prerequisites,integration -- --model testing
Expand Down
26 changes: 25 additions & 1 deletion src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
from charms.observability_libs.v1.cert_handler import CertChanged
from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider
from charms.traefik_k8s.v1.ingress_per_unit import IngressPerUnitRequirer
from charms.traefik_k8s.v1.ingress_per_unit import (
IngressPerUnitReadyForUnitEvent,
IngressPerUnitRequirer,
IngressPerUnitRevokedForUnitEvent,
)
from lightkube import Client
from ops import main
from ops.charm import (
Expand Down Expand Up @@ -161,6 +165,8 @@ def __init__(self, *args: Any):
self.framework.observe(
self.database_requirer.on.endpoints_changed, self._on_database_changed
)
self.framework.observe(self.ingress_per_unit.on.ready_for_unit, self._on_ingress_changed)
self.framework.observe(self.ingress_per_unit.on.revoked_for_unit, self._on_ingress_changed)

self.config_file = ConfigFile(
base_dn=self.config.get("base_dn"),
Expand Down Expand Up @@ -300,6 +306,24 @@ def _on_auxiliary_requested(self, event: AuxiliaryRequestedEvent) -> None:
data=self._auxiliary_integration.auxiliary_data,
)

@wait_when(container_not_connected)
def _on_ingress_changed(
self, event: IngressPerUnitReadyForUnitEvent | IngressPerUnitRevokedForUnitEvent
) -> None:
try:
self._certs_integration.update_certificates()
except CertificatesError:
self.unit.status = BlockedStatus(
"Failed to update the TLS certificates, please check the logs"
)
return

if not self._certs_integration.certs_ready():
return

self._handle_event_update(event)
self._certs_transfer_integration.transfer_certificates(self._certs_integration.cert_data)

@wait_when(container_not_connected)
def _on_cert_changed(self, event: CertChanged) -> None:
try:
Expand Down
11 changes: 7 additions & 4 deletions src/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,17 @@ def __init__(self, charm: CharmBase) -> None:
self._container = charm._container

hostname = charm.config.get("hostname")
sans = [hostname, f"{charm.app.name}.{charm.model.name}.svc.cluster.local"]

if ingress := charm.ingress_per_unit.url:
ingress_domain, *_ = ingress.rsplit(sep=":", maxsplit=1)
sans.append(ingress_domain)

self.cert_handler = CertHandler(
charm,
key="glauth-server-cert",
cert_subject=hostname,
sans=[
hostname,
f"{charm.app.name}.{charm.model.name}.svc.cluster.local",
],
sans=sans,
)

@property
Expand Down
46 changes: 38 additions & 8 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@
from cryptography.hazmat.backends import default_backend
from pytest_operator.plugin import OpsTest

from constants import GLAUTH_LDAP_PORT

METADATA = yaml.safe_load(Path("./charmcraft.yaml").read_text())
TRAEFIK_CHARM = "traefik-k8s"
CERTIFICATE_PROVIDER_APP = "self-signed-certificates"
DB_APP = "postgresql-k8s"
GLAUTH_PROXY = "ldap-proxy"
GLAUTH_APP = METADATA["name"]
GLAUTH_IMAGE = METADATA["resources"]["oci-image"]["upstream-source"]
GLAUTH_CLIENT_APP = "any-charm"
INGRESS_APP = "ingress"
JUJU_SECRET_ID_REGEX = re.compile(r"secret:(?://[a-f0-9-]+/)?(?P<secret_id>[a-zA-Z0-9]+)")
INGRESS_URL_REGEX = re.compile(r"url:\s*(?P<ingress_url>\d{1,3}(?:\.\d{1,3}){3}:\d+)")


@contextmanager
Expand All @@ -47,6 +48,15 @@ def extract_certificate_common_name(certificate: str) -> Optional[str]:
return rdns[0].rfc4514_string()


def extract_certificate_sans(certificate: str) -> list[str]:
cert_data = certificate.encode()
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
sans = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
domains = sans.value.get_values_for_type(x509.DNSName)
ips = [str(ip) for ip in sans.value.get_values_for_type(x509.IPAddress)]
return domains + ips


async def get_secret(ops_test: OpsTest, secret_id: str) -> dict:
show_secret_cmd = f"show-secret {secret_id} --reveal".split()
_, stdout, _ = await ops_test.juju(*show_secret_cmd)
Expand Down Expand Up @@ -120,6 +130,32 @@ async def certificate_integration_data(app_integration_data: Callable) -> Option
return await app_integration_data(GLAUTH_APP, "certificates")


@pytest_asyncio.fixture
async def ingress_per_unit_integration_data(app_integration_data: Callable) -> Optional[dict]:
return await app_integration_data(GLAUTH_APP, "ingress")


@pytest_asyncio.fixture
async def ingress_url(ingress_per_unit_integration_data: Optional[dict]) -> Optional[str]:
if not ingress_per_unit_integration_data:
return None

ingress = ingress_per_unit_integration_data["ingress"]
matched = INGRESS_URL_REGEX.search(ingress)
assert matched is not None, "ingress url not found in ingress per unit integration data"

return matched.group("ingress_url")


@pytest_asyncio.fixture
async def ingress_ip(ingress_url: Optional[str]) -> Optional[str]:
if not ingress_url:
return None

ingress_ip, *_ = ingress_url.rsplit(sep=":", maxsplit=1)
return ingress_ip


@pytest_asyncio.fixture
async def ldap_configurations(
ops_test: OpsTest, ldap_integration_data: Optional[dict]
Expand All @@ -144,12 +180,6 @@ async def unit_address(ops_test: OpsTest, *, app_name: str, unit_num: int = 0) -
return status["applications"][app_name]["units"][f"{app_name}/{unit_num}"]["address"]


@pytest_asyncio.fixture
async def ldap_uri(ops_test: OpsTest) -> str:
address = await unit_address(ops_test, app_name=GLAUTH_APP)
return f"ldap://{address}:{GLAUTH_LDAP_PORT}"


@pytest_asyncio.fixture
async def database_address(ops_test: OpsTest) -> str:
return await unit_address(ops_test, app_name=DB_APP)
Expand Down
48 changes: 37 additions & 11 deletions tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
GLAUTH_CLIENT_APP,
GLAUTH_IMAGE,
GLAUTH_PROXY,
INGRESS_APP,
TRAEFIK_CHARM,
extract_certificate_common_name,
extract_certificate_sans,
ldap_connection,
)
from pytest_operator.plugin import OpsTest
Expand Down Expand Up @@ -57,6 +60,12 @@ async def test_build_and_deploy(ops_test: OpsTest) -> None:
"python-packages": "pydantic ~= 2.0\njsonschema",
},
),
ops_test.model.deploy(
TRAEFIK_CHARM,
application_name=INGRESS_APP,
channel="latest/edge",
trust=True,
),
)

charm_path = await ops_test.build_charm(".")
Expand All @@ -81,9 +90,17 @@ async def test_build_and_deploy(ops_test: OpsTest) -> None:
await ops_test.model.integrate(GLAUTH_PROXY, CERTIFICATE_PROVIDER_APP)
await ops_test.model.integrate(GLAUTH_APP, DB_APP)
await ops_test.model.integrate(f"{GLAUTH_PROXY}:ldap-client", f"{GLAUTH_APP}:ldap")
await ops_test.model.integrate(f"{GLAUTH_APP}:ingress", f"{INGRESS_APP}:ingress-per-unit")

await ops_test.model.wait_for_idle(
apps=[CERTIFICATE_PROVIDER_APP, DB_APP, GLAUTH_CLIENT_APP, GLAUTH_APP, GLAUTH_PROXY],
apps=[
CERTIFICATE_PROVIDER_APP,
DB_APP,
GLAUTH_CLIENT_APP,
GLAUTH_APP,
GLAUTH_PROXY,
INGRESS_APP,
],
status="active",
raise_on_blocked=False,
timeout=5 * 60,
Expand All @@ -99,13 +116,19 @@ async def test_database_integration(
assert database_integration_data["password"]


async def test_ingress_per_unit_integration(ingress_url: Optional[str]) -> None:
assert ingress_url, "Ingress url not found in the ingress-per-unit integration"


async def test_certification_integration(
certificate_integration_data: Optional[dict],
ingress_ip: Optional[str],
) -> None:
assert certificate_integration_data
certificates = json.loads(certificate_integration_data["certificates"])
certificate = certificates[0]["certificate"]
assert "CN=ldap.glauth.com" == extract_certificate_common_name(certificate)
assert ingress_ip in extract_certificate_sans(certificate)


async def test_ldap_integration(
Expand Down Expand Up @@ -138,12 +161,6 @@ async def test_ldap_client_integration(
ops_test: OpsTest,
app_integration_data: Callable,
) -> None:
await ops_test.model.wait_for_idle(
apps=[GLAUTH_APP, GLAUTH_PROXY],
status="active",
timeout=5 * 60,
)

ldap_client_integration_data = await app_integration_data(
GLAUTH_PROXY,
"ldap-client",
Expand All @@ -158,6 +175,7 @@ async def test_ldap_client_integration(
async def test_certificate_transfer_integration(
ops_test: OpsTest,
unit_integration_data: Callable,
ingress_ip: Optional[str],
) -> None:
await ops_test.model.integrate(
f"{GLAUTH_CLIENT_APP}:send-ca-cert",
Expand Down Expand Up @@ -185,7 +203,14 @@ async def test_certificate_transfer_integration(
chain = certificate_transfer_integration_data["chain"]
assert isinstance(json.loads(chain), list), "Invalid certificate chain."

certificate = certificate_transfer_integration_data["certificate"]
assert "CN=ldap.glauth.com" == extract_certificate_common_name(certificate)
assert ingress_ip in extract_certificate_sans(certificate)


@pytest.mark.skip(
reason="glauth cannot scale up due to the traefik-k8s issue: https://github.com/canonical/traefik-k8s-operator/issues/406",
)
async def test_glauth_scale_up(ops_test: OpsTest) -> None:
app, target_unit_num = ops_test.model.applications[GLAUTH_APP], 2

Expand Down Expand Up @@ -215,20 +240,21 @@ async def test_glauth_scale_down(ops_test: OpsTest) -> None:

async def test_ldap_search_operation(
initialize_database: None,
ldap_uri: str,
ldap_configurations: Optional[tuple[str, ...]],
ingress_url: Optional[str],
) -> None:
assert ldap_configurations, "LDAP configuration should be ready"
base_dn, bind_dn, bind_password = ldap_configurations

ldap_uri = f"ldap://{ingress_url}"
with ldap_connection(uri=ldap_uri, bind_dn=bind_dn, bind_password=bind_password) as conn:
res = conn.search_s(
base=base_dn,
scope=ldap.SCOPE_SUBTREE,
filterstr="(cn=hackers)",
)

assert res[0], "User 'hackers' can be found"
assert res[0], "Can't find user 'hackers'"
dn, _ = res[0]
assert dn == f"cn=hackers,ou=superheros,ou=users,{base_dn}"

Expand All @@ -241,7 +267,7 @@ async def test_ldap_search_operation(
filterstr="(cn=johndoe)",
)

assert res[0], "User 'johndoe' can be found by using 'serviceuser' as bind DN"
assert res[0], "User 'johndoe' can't be found by using 'serviceuser' as bind DN"
dn, _ = res[0]
assert dn == f"cn=johndoe,ou=svcaccts,ou=users,{base_dn}"

Expand All @@ -252,7 +278,7 @@ async def test_ldap_search_operation(
base=f"ou=superheros,{base_dn}", scope=ldap.SCOPE_SUBTREE, filterstr="(cn=user4)"
)

assert user4[0], "User 'user4' can be found by using 'hackers' as bind DN"
assert user4[0], "User 'user4' can't be found by using 'hackers' as bind DN"
dn, _ = user4[0]
assert dn == f"cn=user4,ou=superheros,{base_dn}"

Expand Down