From 639db0f4570f54fc6225eb4c65df68dc81089e9c Mon Sep 17 00:00:00 2001 From: Daniela Plascencia Date: Mon, 16 Oct 2023 12:03:04 +0200 Subject: [PATCH] skip: add integration tests --- .../observability_libs/v0/cert_handler.py | 38 ++- tests/test_bundle.py | 2 +- tests/test_bundle_tls.py | 223 ++++-------------- tox.ini | 10 +- 4 files changed, 82 insertions(+), 191 deletions(-) diff --git a/charms/istio-pilot/lib/charms/observability_libs/v0/cert_handler.py b/charms/istio-pilot/lib/charms/observability_libs/v0/cert_handler.py index 88a8374e..db14e00f 100644 --- a/charms/istio-pilot/lib/charms/observability_libs/v0/cert_handler.py +++ b/charms/istio-pilot/lib/charms/observability_libs/v0/cert_handler.py @@ -64,7 +64,7 @@ LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a" LIBAPI = 0 -LIBPATCH = 8 +LIBPATCH = 9 def is_ip_address(value: str) -> bool: @@ -181,33 +181,40 @@ def _peer_relation(self) -> Optional[Relation]: return self.charm.model.get_relation(self.peer_relation_name, None) def _on_peer_relation_created(self, _): - """Generate the private key and store it in a peer relation.""" - # We're in "relation-created", so the relation should be there + """Generate the CSR if the certificates relation is ready.""" + self._generate_privkey() - # Just in case we already have a private key, do not overwrite it. - # Not sure how this could happen. - # TODO figure out how to go about key rotation. - if not self._private_key: - private_key = generate_private_key() - self._private_key = private_key.decode() - - # Generate CSR here, in case peer events fired after tls-certificate relation events + # check cert relation is ready if not (self.charm.model.get_relation(self.certificates_relation_name)): # peer relation event happened to fire before tls-certificates events. # Abort, and let the "certificates joined" observer create the CSR. + logger.info("certhandler waiting on certificates relation") return + logger.debug("certhandler has peer and certs relation: proceeding to generate csr") self._generate_csr() def _on_certificates_relation_joined(self, _) -> None: - """Generate the CSR and request the certificate creation.""" + """Generate the CSR if the peer relation is ready.""" + self._generate_privkey() + + # check peer relation is there if not self._peer_relation: # tls-certificates relation event happened to fire before peer events. # Abort, and let the "peer joined" relation create the CSR. + logger.info("certhandler waiting on peer relation") return + logger.debug("certhandler has peer and certs relation: proceeding to generate csr") self._generate_csr() + def _generate_privkey(self): + # Generate priv key unless done already + # TODO figure out how to go about key rotation. + if not self._private_key: + private_key = generate_private_key() + self._private_key = private_key.decode() + def _on_config_changed(self, _): # FIXME on config changed, the web_external_url may or may not change. But because every # call to `generate_csr` appends a uuid, CSRs cannot be easily compared to one another. @@ -237,7 +244,12 @@ def _generate_csr( # In case we already have a csr, do not overwrite it by default. if overwrite or renew or not self._csr: private_key = self._private_key - assert private_key is not None # for type checker + if private_key is None: + # FIXME: raise this in a less nested scope by + # generating privkey and csr in the same method. + raise RuntimeError( + "private key unset. call _generate_privkey() before you call this method." + ) csr = generate_csr( private_key=private_key.encode(), subject=self.cert_subject, diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 052a5047..b9063fbc 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -147,7 +147,7 @@ async def test_deploy_bookinfo_example(ops_test: OpsTest): "kubectl", "create", "namespace", - bookinfo_namespace, + "bookinfo-namespace-2", ) await ops_test.run( diff --git a/tests/test_bundle_tls.py b/tests/test_bundle_tls.py index 4f709f29..968d6ead 100644 --- a/tests/test_bundle_tls.py +++ b/tests/test_bundle_tls.py @@ -1,84 +1,43 @@ -import json -import logging -from pathlib import Path -from time import sleep - -import aiohttp import lightkube import pytest -import requests import tenacity -import yaml -from bs4 import BeautifulSoup -from lightkube import codecs -from lightkube.generic_resource import ( - create_namespaced_resource, - load_in_cluster_generic_resources, -) +from lightkube.generic_resource import create_namespaced_resource +from lightkube.resources.core_v1 import Secret from pytest_operator.plugin import OpsTest -log = logging.getLogger(__name__) - -DEX_AUTH = "dex-auth" -OIDC_GATEKEEPER = "oidc-gatekeeper" ISTIO_PILOT = "istio-pilot" ISTIO_GATEWAY_APP_NAME = "istio-ingressgateway" -TENSORBOARD_CONTROLLER = "tensorboard-controller" -KUBEFLOW_VOLUMES = "kubeflow-volumes" - -USERNAME = "user123" -PASSWORD = "user123" - -VIRTUAL_SERVICE_LIGHTKUBE_RESOURCE = create_namespaced_resource( +DEFAULT_GATEWAY_NAME = "test-gateway" +GATEWAY_RESOURCE = create_namespaced_resource( group="networking.istio.io", version="v1alpha3", - kind="VirtualService", - plural="virtualservices", + kind="Gateway", + plural="gateways", ) -@pytest.mark.abort_on_fail -async def test_kubectl_access(ops_test: OpsTest): - """Fails if kubectl not available or if no cluster context exists""" - _, stdout, _ = await ops_test.run( - "kubectl", - "config", - "view", - check=True, - fail_msg="Failed to execute kubectl - is kubectl installed?", - ) - - # Check if kubectl has a context, failing if it does not - kubectl_config = yaml.safe_load(stdout) - error_message = ( - "Found no kubectl contexts - did you populate KUBECONFIG? Ex:" - " 'KUBECONFIG=/home/runner/.kube/config tox ...' or" - " 'KUBECONFIG=/home/runner/.kube/config tox ...'" - ) - assert kubectl_config["contexts"] is not None, error_message - - await ops_test.run( - "kubectl", - "get", - "pods", - check=True, - fail_msg="Failed to do a simple kubectl task - is KUBECONFIG properly configured?", - ) +@pytest.fixture(scope="session") +def lightkube_client() -> lightkube.Client: + client = lightkube.Client(field_manager="kserve") + return client @pytest.mark.abort_on_fail async def test_build_and_deploy_istio_charms(ops_test: OpsTest): - # Build, deploy, and relate istio charms + """Build and deploy istio-operators with TLS configuration.""" charms_path = "./charms/istio" istio_charms = await ops_test.build_charms(f"{charms_path}-gateway", f"{charms_path}-pilot") await ops_test.model.deploy( - istio_charms["istio-pilot"], application_name=ISTIO_PILOT, series="focal", trust=True + istio_charms["istio-pilot"], + application_name=ISTIO_PILOT, + config={"default-gateway": DEFAULT_GATEWAY_NAME}, + trust=True, ) + await ops_test.model.deploy( istio_charms["istio-gateway"], application_name=ISTIO_GATEWAY_APP_NAME, - series="focal", config={"kind": "ingress"}, trust=True, ) @@ -93,138 +52,50 @@ async def test_build_and_deploy_istio_charms(ops_test: OpsTest): timeout=90 * 10, ) -@pytest.mark.abort_on_fail -async def test_deploy_bookinfo_example(ops_test: OpsTest): - root_url = "https://raw.githubusercontent.com/istio/istio/release-1.11/samples/bookinfo" - bookinfo_namespace = f"{ops_test.model_name}-bookinfo" - - await ops_test.run( - "kubectl", - "create", - "namespace", - bookinfo_namespace, - ) - - await ops_test.run( - "kubectl", - "label", - "namespace", - bookinfo_namespace, - "istio-injection=enabled", - "--overwrite=true", - check=True, - ) - await ops_test.run( - "kubectl", - "apply", - "-f", - f"{root_url}/platform/kube/bookinfo.yaml", - "-f", - f"{root_url}/networking/bookinfo-gateway.yaml", - "--namespace", - bookinfo_namespace, - check=True, - ) - - await ops_test.run( - "kubectl", - "wait", - "--for=condition=available", - "deployment", - "--all", - "--all-namespaces", - "--timeout=5m", - check=True, - ) - - # Wait for the pods as well, since the Deployment can be considered - # "complete" while the pods are still starting. - await ops_test.run( - "kubectl", - "wait", - "--for=condition=ready", - "pod", - "--all", - f"-n={bookinfo_namespace}", - "--timeout=5m", - check=True, + await ops_test.model.deploy( + "self-signed-certificates", + channel="edge", ) - gateway_ip = await get_gateway_ip(ops_test) - await assert_page_reachable( - url=f"https://{gateway_ip}/productpage", title="Simple Bookstore App" + await ops_test.model.add_relation( + f"{ISTIO_PILOT}:certificates", "self-signed-certificates:certificates" ) -# TODO: Change this to use lightkube -async def get_gateway_ip(ops_test: OpsTest, service_name: str = "istio-ingressgateway-workload"): - gateway_json = await ops_test.run( - "kubectl", - "get", - f"services/{service_name}", - "-n", - ops_test.model_name, - "-ojson", - check=True, + await ops_test.model.wait_for_idle( + status="active", + raise_on_blocked=False, + timeout=90 * 10, ) - gateway_obj = json.loads(gateway_json[1]) - return gateway_obj["status"]["loadBalancer"]["ingress"][0]["ip"] - @tenacity.retry( - stop=tenacity.stop_after_delay(60), + stop=tenacity.stop_after_delay(200), wait=tenacity.wait_exponential(multiplier=1, min=1, max=10), reraise=True, ) -def assert_url_get(url, allowed_statuses: list, disallowed_statuses: list): - """Asserts that we receive one of a list of allowed status when we `get` an url, or raises. - - Raises after max number of attempts or if you receive a disallowed status code - """ - i = 0 - max_attempts = 20 - while i < max_attempts: - # Test that traffic over the restricted port (8080, the regular ingress) - # is redirected to dex - r = requests.get(url, allow_redirects=False) - if r.status_code in allowed_statuses: - return - elif r.status_code in disallowed_statuses: - raise ValueError( - f"Got disallowed status code {r.status_code}. Communication not as expected" - ) - sleep(5) - - raise ValueError( - "Timed out before getting an allowed status code. Communication not as expected" +@pytest.mark.abort_on_fail +def test_tls_configuration(lightkube_client, ops_test: OpsTest): + """Check the Gateway and Secret are configured with TLS.""" + secret = lightkube_client.get( + Secret, f"{DEFAULT_GATEWAY_NAME}-gateway-secret", namespace=ops_test.model_name + ) + gateway = lightkube_client.get( + GATEWAY_RESOURCE, DEFAULT_GATEWAY_NAME, namespace=ops_test.model_name ) + # Assert the Secret is not None and has correct values + assert secret is not None + assert secret.data["tls.crt"] is not None + assert secret.data["tls.key"] is not None + assert secret.type == "kubernetes.io/tls" -# Use a long stop_after_delay period because wait_for_idle is not reliable. -@tenacity.retry( - stop=tenacity.stop_after_delay(600), - wait=tenacity.wait_exponential(multiplier=1, min=1, max=10), - reraise=True, -) -async def assert_page_reachable(url, title): - """Asserts that a page with a specific title is reachable at a given url.""" - log.info(f"Attempting to access url '{url}' to assert it has title '{title}'") - async with aiohttp.ClientSession(raise_for_status=True) as client: - results = await client.get(url) - soup = BeautifulSoup(await results.text()) - - assert soup.title.string == title - log.info(f"url '{url}' exists with title '{title}'.") + # Assert the Gateway is correctly configured + servers_dict = gateway.spec["servers"][0] + servers_dict_port = servers_dict["port"] + servers_dict_tls = servers_dict["tls"] + assert servers_dict_port["name"] == "https" + assert servers_dict_port["protocol"] == "HTTPS" -@tenacity.retry( - stop=tenacity.stop_after_delay(600), - wait=tenacity.wait_exponential(multiplier=1, min=1, max=10), - reraise=True, -) -def assert_virtualservice_exists(name: str, namespace: str): - """Will raise a ApiError(404) if the virtualservice does not exist.""" - log.info(f"Attempting to assert that VirtualService '{name}' exists.") - lightkube_client = lightkube.Client() - lightkube_client.get(VIRTUAL_SERVICE_LIGHTKUBE_RESOURCE, name, namespace=namespace) - log.info(f"VirtualService '{name}' exists.") + assert servers_dict_tls["mode"] == "SIMPLE" + assert servers_dict_tls["credentialName"] == secret.metadata["name"] diff --git a/tox.ini b/tox.ini index dc787021..e5b43982 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ max-line-length = 100 [tox] skipsdist = True -envlist = {pilot,gateway}-{unit,lint},integration +envlist = {pilot,gateway}-{unit,lint},integration, integration-tls [vars] all_path = {[vars]src_path} {[vars]tst_path} @@ -46,6 +46,7 @@ deps = description = Apply coding style standards to code [testenv:lint] +allowlist_externals = black commands = # uncomment the following line if this charm owns a lib # codespell {[vars]lib_path} @@ -68,6 +69,13 @@ commands = deps = -r requirements-integration.txt +[testenv:integration-tls] +allowlist_externals = rm +commands = + pytest --show-capture=no --log-cli-level=INFO -vvs --tb=native {posargs} tests/test_bundle_tls.py +deps = + -r requirements-integration.txt + [testenv:cos-integration] allowlist_externals = rm deps =