From d019173170feff0b6e0e7c26d478f6bbfa77b417 Mon Sep 17 00:00:00 2001 From: Homayoon Alimohammadi Date: Wed, 29 Jan 2025 17:53:05 +0400 Subject: [PATCH] Add external-load-balancer relation (#234) * Add external-load-balancer relation * Ensure SANs from apiserver cert and refresh certs if needed --------- Co-authored-by: Adam Dyess --- charms/worker/k8s/charmcraft.yaml | 2 + .../k8s/lib/charms/k8s/v0/k8sd_api_manager.py | 96 +++++++++++- charms/worker/k8s/requirements.txt | 1 + charms/worker/k8s/src/charm.py | 143 +++++++++++++++++- charms/worker/k8s/src/endpoints.py | 94 ++++++++++++ charms/worker/k8s/src/kube_control.py | 3 +- charms/worker/k8s/src/literals.py | 7 + charms/worker/k8s/src/pki.py | 62 ++++++++ charms/worker/k8s/terraform/outputs.tf | 1 + charms/worker/k8s/tests/unit/test_base.py | 10 +- .../k8s/tests/unit/test_config_options.py | 13 +- .../worker/k8s/tests/unit/test_endpoints.py | 37 +++++ charms/worker/k8s/tests/unit/test_pki.py | 68 +++++++++ 13 files changed, 521 insertions(+), 16 deletions(-) create mode 100644 charms/worker/k8s/src/endpoints.py create mode 100644 charms/worker/k8s/src/pki.py create mode 100644 charms/worker/k8s/tests/unit/test_endpoints.py create mode 100644 charms/worker/k8s/tests/unit/test_pki.py diff --git a/charms/worker/k8s/charmcraft.yaml b/charms/worker/k8s/charmcraft.yaml index 137358f6..131dc105 100644 --- a/charms/worker/k8s/charmcraft.yaml +++ b/charms/worker/k8s/charmcraft.yaml @@ -458,3 +458,5 @@ requires: interface: external_cloud_provider gcp: interface: gcp-integration + external-load-balancer: + interface: loadbalancer diff --git a/charms/worker/k8s/lib/charms/k8s/v0/k8sd_api_manager.py b/charms/worker/k8s/lib/charms/k8s/v0/k8sd_api_manager.py index 2fbc6048..a2aca0aa 100644 --- a/charms/worker/k8s/lib/charms/k8s/v0/k8sd_api_manager.py +++ b/charms/worker/k8s/lib/charms/k8s/v0/k8sd_api_manager.py @@ -31,6 +31,7 @@ class utilises different connection factories (UnixSocketConnectionFactory import logging import socket from contextlib import contextmanager +from datetime import datetime from http.client import HTTPConnection, HTTPException from typing import Any, Dict, Generator, List, Optional, Type, TypeVar @@ -45,7 +46,7 @@ class utilises different connection factories (UnixSocketConnectionFactory # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 5 +LIBPATCH = 6 logger = logging.getLogger(__name__) @@ -598,6 +599,68 @@ class GetKubeConfigResponse(BaseRequestModel): metadata: KubeConfigMetadata +class RefreshCertificatesPlanMetadata(BaseModel, allow_population_by_field_name=True): + """Metadata for the certificates plan response. + + Attributes: + seed (int): The seed for the new certificates. + certificate_signing_requests (Optional[list[str]]): List of names + of the CertificateSigningRequests that need to be signed externally (for worker nodes). + """ + + # NOTE(Hue): Alias is because of a naming mismatch: + # https://github.com/canonical/k8s-snap-api/blob/6d4139295b37800fb2b3fcce9fc260e6caf284b9/api/v1/rpc_refresh_certificates_plan.go#L12 + seed: Optional[int] = Field(default=None, alias="seconds") + certificate_signing_requests: Optional[list[str]] = Field( + default=None, alias="certificate-signing-requests" + ) + + +class RefreshCertificatesPlanResponse(BaseRequestModel): + """Response model for the refresh certificates plan. + + Attributes: + metadata (RefreshCertificatesPlanMetadata): Metadata for the certificates plan response. + """ + + metadata: RefreshCertificatesPlanMetadata + + +class RefreshCertificatesRunRequest(BaseModel, allow_population_by_field_name=True): + """Request model for running the refresh certificates run. + + Attributes: + seed (int): The seed for the new certificates from plan response. + expiration_seconds (int): The duration of the new certificates. + extra_sans (list[str]): List of extra sans for the new certificates. + """ + + seed: int + expiration_seconds: int = Field(alias="expiration-seconds") + extra_sans: Optional[list[str]] = Field(alias="extra-sans") + + +class RefreshCertificatesRunMetadata(BaseModel, allow_population_by_field_name=True): + """Metadata for RefreshCertificatesRunResponse. + + Attributes: + expiration_seconds (int): The duration of the new certificates + (might not match the requested value). + """ + + expiration_seconds: int = Field(alias="expiration-seconds") + + +class RefreshCertificatesRunResponse(BaseRequestModel): + """Response model for the refresh certificates run. + + Attributes: + metadata (RefreshCertificatesRunMetadata): Metadata for the certificates run response. + """ + + metadata: RefreshCertificatesRunMetadata + + T = TypeVar("T", bound=BaseRequestModel) @@ -920,3 +983,34 @@ def get_kubeconfig(self, server: Optional[str]) -> str: body = {"server": server or ""} response = self._send_request(endpoint, "GET", GetKubeConfigResponse, body) return response.metadata.kubeconfig + + def refresh_certs( + self, extra_sans: list[str], expiration_seconds: Optional[int] = None + ) -> None: + """Refresh the certificates for the cluster. + + Args: + extra_sans (list[str]): List of extra SANs for the certificates. + expiration_seconds (Optional[int]): The duration of the new certificates. + """ + plan_endpoint = "/1.0/k8sd/refresh-certs/plan" + plan_resp = self._send_request(plan_endpoint, "POST", RefreshCertificatesPlanResponse, {}) + + # NOTE(Hue): Default certificate expiration is set to 20 years: + # https://github.com/canonical/k8s-snap/blob/32e35128394c0880bcc4ce87447f4247cc315ba5/src/k8s/pkg/k8sd/app/hooks_bootstrap.go#L331-L338 + if expiration_seconds is None: + now = datetime.now() + twenty_years_later = datetime( + now.year + 20, now.month, now.day, now.hour, now.minute, now.second + ) + expiration_seconds = int((twenty_years_later - now).total_seconds()) + + run_endpoint = "/1.0/k8sd/refresh-certs/run" + run_req = RefreshCertificatesRunRequest( # type: ignore + seed=plan_resp.metadata.seed, + expiration_seconds=expiration_seconds, + extra_sans=extra_sans, + ) + + body = run_req.dict(exclude_none=True, by_alias=True) + self._send_request(run_endpoint, "POST", RefreshCertificatesRunResponse, body) diff --git a/charms/worker/k8s/requirements.txt b/charms/worker/k8s/requirements.txt index 9563704c..d1de1bbf 100644 --- a/charms/worker/k8s/requirements.txt +++ b/charms/worker/k8s/requirements.txt @@ -17,3 +17,4 @@ websocket-client==1.8.0 poetry-core==1.9.1 lightkube==0.17.1 httpx==0.27.2 +loadbalancer_interface == 1.2.0 diff --git a/charms/worker/k8s/src/charm.py b/charms/worker/k8s/src/charm.py index 64948844..4490f163 100755 --- a/charms/worker/k8s/src/charm.py +++ b/charms/worker/k8s/src/charm.py @@ -64,10 +64,13 @@ from charms.reconciler import Reconciler from cloud_integration import CloudIntegration from cos_integration import COSIntegration +from endpoints import build_url from events import update_status from inspector import ClusterInspector from kube_control import configure as configure_kube_control from literals import ( + APISERVER_CERT, + APISERVER_PORT, CLUSTER_RELATION, CLUSTER_WORKER_RELATION, CONTAINERD_BASE_PATH, @@ -80,13 +83,19 @@ DEPENDENCIES, ETC_KUBERNETES, ETCD_RELATION, + EXTERNAL_LOAD_BALANCER_PORT, + EXTERNAL_LOAD_BALANCER_RELATION, + EXTERNAL_LOAD_BALANCER_REQUEST_NAME, + EXTERNAL_LOAD_BALANCER_RESPONSE_NAME, K8SD_PORT, K8SD_SNAP_SOCKET, KUBECONFIG, KUBECTL_PATH, SUPPORTED_DATASTORES, ) +from loadbalancer_interface import LBProvider from ops.interface_kube_control import KubeControlProvides +from pki import get_certificate_sans from pydantic import SecretStr from snap import management as snap_management from snap import version as snap_version @@ -98,7 +107,7 @@ log = logging.getLogger(__name__) -def _get_public_address() -> str: +def _get_juju_public_address() -> str: """Get public address from juju. Returns: @@ -178,6 +187,7 @@ def __init__(self, *args): ) self._upgrade_snap = False self._stored.set_default(is_dying=False, cluster_name=str(), upgrade_granted=False) + self._external_load_balancer_address = "" self.cos_agent = COSAgentProvider( self, @@ -196,6 +206,7 @@ def __init__(self, *args): self.etcd = EtcdReactiveRequires(self) self.kube_control = KubeControlProvides(self, endpoint="kube-control") self.framework.observe(self.on.get_kubeconfig_action, self._get_external_kubeconfig) + self.external_load_balancer = LBProvider(self, EXTERNAL_LOAD_BALANCER_RELATION) def _k8s_info(self, event: ops.EventBase): """Send cluster information on the kubernetes-info relation. @@ -377,10 +388,24 @@ def _check_k8sd_ready(self): def _get_extra_sans(self): """Retrieve the certificate extra SANs.""" + # Get the extra SANs from the configuration extra_sans_str = str(self.config.get("kube-apiserver-extra-sans") or "") - configured_sans = {san for san in extra_sans_str.strip().split() if san} - all_sans = configured_sans | set([_get_public_address()]) - return sorted(all_sans) + extra_sans = set(extra_sans_str.strip().split()) + + # Add the ingress addresses of all units + extra_sans.add(_get_juju_public_address()) + binding = self.model.get_binding(CLUSTER_RELATION) + addresses = binding and binding.network.ingress_addresses + if addresses: + log.info("Adding ingress addresses to extra SANs") + extra_sans |= {str(addr) for addr in addresses} + + # Add the external load balancer address + if self._external_load_balancer_address: + log.info("Adding external load balancer address to extra SANs") + extra_sans.add(self._external_load_balancer_address) + + return sorted(extra_sans) def _assemble_bootstrap_config(self): """Assemble the bootstrap configuration for the Kubernetes cluster. @@ -400,6 +425,45 @@ def _assemble_bootstrap_config(self): config.extra_args.craft(self.config, bootstrap_config, cluster_name) return bootstrap_config + def _configure_external_load_balancer(self) -> None: + """Configure the external load balancer for the application. + + This method checks if the external load balancer is available and then + proceeds to configure it by sending a request with the necessary parameters. + It waits for a response from the external load balancer and handles any errors that + may occur during the process. + """ + if not self.is_control_plane: + log.info("External load balancer is only configured for control-plane units.") + return + + if not self.external_load_balancer.is_available: + log.info("External load balancer relation is not available. Skipping setup.") + return + + status.add(ops.MaintenanceStatus("Configuring external loadBalancer")) + + req = self.external_load_balancer.get_request(EXTERNAL_LOAD_BALANCER_REQUEST_NAME) + req.protocol = req.protocols.tcp + req.port_mapping = {EXTERNAL_LOAD_BALANCER_PORT: APISERVER_PORT} + req.public = True + if not req.health_checks: + req.add_health_check(protocol=req.protocols.https, port=APISERVER_PORT, path="/livez") + self.external_load_balancer.send_request(req) + log.info("External load balancer request was sent") + + resp = self.external_load_balancer.get_response(EXTERNAL_LOAD_BALANCER_RESPONSE_NAME) + if not resp: + msg = "No response from external load balancer" + status.add(ops.WaitingStatus(msg)) + raise ReconcilerError(msg) + if resp.error: + msg = f"External load balancer error: {resp.error}" + status.add(ops.BlockedStatus(msg)) + raise ReconcilerError(msg) + + self._external_load_balancer_address = resp.address + @on_error( ops.WaitingStatus("Waiting to bootstrap k8s snap"), ReconcilerError, @@ -909,6 +973,7 @@ def _reconcile(self, event: ops.EventBase): self._update_kubernetes_version() if self.lead_control_plane: self._k8s_info(event) + self._configure_external_load_balancer() self._bootstrap_k8s_snap() self._ensure_cluster_config() self._create_cluster_tokens() @@ -924,6 +989,7 @@ def _reconcile(self, event: ops.EventBase): if self.is_control_plane: self._copy_internal_kubeconfig() self._expose_ports() + self._ensure_cert_sans() def _evaluate_removal(self, event: ops.EventBase) -> bool: """Determine if my unit is being removed. @@ -1081,14 +1147,79 @@ def _get_external_kubeconfig(self, event: ops.ActionEvent): try: server = event.params.get("server") if not server: - log.info("No server requested, use public-address") - server = f"{_get_public_address()}:6443" + log.info("No server requested, use public address") + + server = self._get_public_address() + if not server: + event.fail("Failed to get public address. Check logs for details.") + return + + port = ( + str(EXTERNAL_LOAD_BALANCER_PORT) + if self.external_load_balancer.is_available + else str(APISERVER_PORT) + ) + + server = build_url(server, port, "https") + log.info("Formatted server address: %s", server) log.info("Requesting kubeconfig for server=%s", server) resp = self.api_manager.get_kubeconfig(server) event.set_results({"kubeconfig": resp}) except (InvalidResponseError, K8sdConnectionError) as e: event.fail(f"Failed to retrieve kubeconfig: {e}") + def _get_public_address(self) -> str: + """Get the most public address either from external load balancer or from juju. + + If the external load balancer is available and the unit is a control-plane unit, + the external load balancer address will be used. Otherwise, the juju public address + will be used. + NOTE: Don't ignore the unit's IP in the extra SANs just because there's a load balancer. + + Returns: + str: The public ip address of the unit. + """ + if self._external_load_balancer_address: + log.info("Using external load balancer address as the public address") + return self._external_load_balancer_address + + log.info("Using juju public address as the public address") + return _get_juju_public_address() + + @on_error( + ops.WaitingStatus("Ensuring SANs are up-to-date"), + InvalidResponseError, + K8sdConnectionError, + ) + def _ensure_cert_sans(self): + """Ensure the certificate SANs are up-to-date. + + This method checks if the certificate SANs match the required extra SANs. + If they are not, the certificates are refreshed with the new SANs. + """ + if not self.is_control_plane: + return + + extra_sans = self._get_extra_sans() + if not extra_sans: + log.info("No extra SANs to update") + return + + dns_sans, ip_sans = get_certificate_sans(APISERVER_CERT) + ip_sans = [str(ip) for ip in ip_sans] + all_cert_sans = dns_sans + ip_sans + + missing_sans = [san for san in extra_sans if san not in all_cert_sans] + if missing_sans: + log.info( + "%s not in cert SANs. Refreshing certs with new SANs: %s", missing_sans, extra_sans + ) + status.add(ops.MaintenanceStatus("Refreshing Certificates")) + self.api_manager.refresh_certs(extra_sans) + log.info("Certificates have been refreshed") + + log.info("Certificate SANs are up-to-date") + if __name__ == "__main__": # pragma: nocover ops.main(K8sCharm) diff --git a/charms/worker/k8s/src/endpoints.py b/charms/worker/k8s/src/endpoints.py new file mode 100644 index 00000000..ea8a3000 --- /dev/null +++ b/charms/worker/k8s/src/endpoints.py @@ -0,0 +1,94 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# Learn more at: https://juju.is/docs/sdk + +"""Helper functions to handle endpoints for the charm.""" + +import logging +import re +from ipaddress import ip_address +from urllib.parse import urlparse, urlunsplit + +log = logging.getLogger(__name__) + + +def parse_endpoint(ep: str) -> tuple: + """Split the given endpoint into its components. + + Args: + ep (str): The endpoint to split. + + Returns: + tuple: A tuple containing the scheme, address, port, and is_ipv6 of the endpoint. + """ + ep = ep.strip() + + scheme = "" + if re.match(r"^[a-zA-Z]+://", ep): + scheme, _ = ep.split("://") + + parsed = urlparse(ep if scheme else f"placeholder://{ep}") + netloc = parsed.netloc + + ip, port, is_ipv6 = ep.split("://")[1] if scheme else ep, "", False + + if ":" in netloc: + # it's either ipv6 or has port or both + if netloc.startswith("["): + # ipv6 with braces (with or without port) + is_ipv6 = True + # fmt: off + ip = netloc[netloc.index("[") + 1: netloc.index("]")] + if netloc[netloc.index("]") + 1:].startswith(":"): + # ipv6 with braces and port + port = netloc[netloc.index("]") + 2:] + # fmt: on + else: + # either ipv6 without braces or ipv4+port + if netloc.count(":") > 1: + # ipv6 without braces and without port. + # an ipv6 without braces but with port is technically indiscriminable + # from another ipv6 without port so we don't consider it. + is_ipv6 = True + ip = netloc + else: + # ipv4+port + ip, port = netloc.split(":") + + try: + ipa = ip_address(ip) + if (ipa.version == 6) != is_ipv6: + log.warning( + "IP version mismatch for %s, ipa.version=%s, is_ipv6=%s", ip, ipa.version, is_ipv6 + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.warning("failed to validate %s: %s", ip, e) + + return scheme, ip, port, is_ipv6 + + +def build_url(addr: str, new_port: str, new_scheme: str) -> str: + """Construct a new URL by replacing the scheme and port of the given address. + + Args: + addr (str): The original address which may include a scheme and port. + new_port (str): The new port to be used in the constructed URL. + new_scheme (str): The new scheme to be used in the constructed URL. + + Returns: + str: The newly constructed URL with the specified scheme and port. + """ + addr, new_port, new_scheme = addr.strip(), new_port.strip(), new_scheme.strip() + scheme, ip, port, is_ipv6 = parse_endpoint(addr) + + if scheme: + log.info( + "replacing already available scheme %s in addr=%s with %s", scheme, addr, new_scheme + ) + if port: + log.info("replacing already available port %s in addr=%s with %s", port, addr, new_port) + if is_ipv6: + ip = f"[{ip}]" + + return urlunsplit((new_scheme, f"{ip}:{new_port}", "", "", "")) diff --git a/charms/worker/k8s/src/kube_control.py b/charms/worker/k8s/src/kube_control.py index 50e17fcf..27bc4bfd 100644 --- a/charms/worker/k8s/src/kube_control.py +++ b/charms/worker/k8s/src/kube_control.py @@ -9,6 +9,7 @@ import ops import yaml from charms.contextual_status import BlockedStatus, on_error +from literals import APISERVER_PORT from protocols import K8sCharmProtocol # Log messages can be retrieved using juju debug-log @@ -43,7 +44,7 @@ def configure(charm: K8sCharmProtocol): return status.add(ops.MaintenanceStatus("Configuring Kube Control")) - ca_cert, endpoints = "", [f"https://{binding.network.bind_address}:6443"] + ca_cert, endpoints = "", [f"https://{binding.network.bind_address}:{APISERVER_PORT}"] if charm._internal_kubeconfig.exists(): kubeconfig = yaml.safe_load(charm._internal_kubeconfig.read_text()) cluster = kubeconfig["clusters"][0]["cluster"] diff --git a/charms/worker/k8s/src/literals.py b/charms/worker/k8s/src/literals.py index 667b1d77..715b3471 100644 --- a/charms/worker/k8s/src/literals.py +++ b/charms/worker/k8s/src/literals.py @@ -16,12 +16,18 @@ CONTAINERD_SERVICE_NAME = "snap.k8s.containerd.service" CONTAINERD_HTTP_PROXY = Path(f"/etc/systemd/system/{CONTAINERD_SERVICE_NAME}.d/http-proxy.conf") ETC_KUBERNETES = Path("/etc/kubernetes") +PKI_DIR = ETC_KUBERNETES / "pki" +APISERVER_CERT = PKI_DIR / "apiserver.crt" HOSTSD_PATH = CONTAINERD_BASE_PATH / "hosts.d/" KUBECONFIG = Path.home() / ".kube/config" KUBECTL_PATH = Path("/snap/k8s/current/bin/kubectl") K8SD_SNAP_SOCKET = "/var/snap/k8s/common/var/lib/k8sd/state/control.socket" K8SD_PORT = 6400 SUPPORTED_DATASTORES = ["dqlite", "etcd"] +EXTERNAL_LOAD_BALANCER_REQUEST_NAME = "api-server-external" +EXTERNAL_LOAD_BALANCER_RESPONSE_NAME = EXTERNAL_LOAD_BALANCER_REQUEST_NAME +EXTERNAL_LOAD_BALANCER_PORT = 443 +APISERVER_PORT = 6443 # Features SUPPORT_SNAP_INSTALLATION_OVERRIDE = True @@ -35,6 +41,7 @@ COS_RELATION = "cos-agent" ETCD_RELATION = "etcd" UPGRADE_RELATION = "upgrade" +EXTERNAL_LOAD_BALANCER_RELATION = "external-load-balancer" # Kubernetes services K8S_COMMON_SERVICES = [ diff --git a/charms/worker/k8s/src/pki.py b/charms/worker/k8s/src/pki.py new file mode 100644 index 00000000..341542ed --- /dev/null +++ b/charms/worker/k8s/src/pki.py @@ -0,0 +1,62 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# Learn more at: https://juju.is/docs/sdk + +"""A module providing PKI related functionalities.""" + +import logging +import subprocess +from ipaddress import ip_address +from pathlib import Path +from typing import Union + +log = logging.getLogger(__name__) + + +def get_certificate_sans(cert_path: Union[str, Path]) -> tuple[list[str], list[str]]: + """Extract the DNS and IP Subject Alternative Names (SANs) from a given certificate file. + + This function uses the openssl command to extract the SANs from the certificate file. + + Args: + cert_path (Union[str, Path]): The path to the certificate file. + + Returns: + tuple[list[str], list[str]]: A tuple containing two lists: + - The first list contains DNS SANs. + - The second list contains IP SANs. + """ + try: + cmd = f"openssl x509 -noout -ext subjectAltName -in {cert_path}".split() + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + log.error("Failed to call openssl for certificate SANs: %s", e) + return [], [] + + lines = result.stdout.splitlines() + if len(lines) < 2: + log.info("No SANs found in %s", cert_path) + return [], [] + + # lines[0] == "X509v3 Subject Alternative Name: " + all_sans = [san.strip() for san in lines[1].split(",")] + dns_sans: set[str] = set() + ip_sans: set[str] = set() + + dns_prefix = "DNS:" + ip_prefix = "IP Address:" + for san in all_sans: + # fmt: off + if san.startswith(dns_prefix): + dns_sans.add(san[len(dns_prefix):]) + elif san.startswith(ip_prefix): + ip_str = san[len(ip_prefix):] + try: + ip = ip_address(ip_str) + ip_sans.add(str(ip)) + except ValueError: + log.warning("Invalid IP SAN: %s", ip_str) + # fmt: on + + return list(dns_sans), list(ip_sans) diff --git a/charms/worker/k8s/terraform/outputs.tf b/charms/worker/k8s/terraform/outputs.tf index 6c28c21c..2cbb510c 100644 --- a/charms/worker/k8s/terraform/outputs.tf +++ b/charms/worker/k8s/terraform/outputs.tf @@ -13,6 +13,7 @@ output "requires" { etcd = "etcd" external_cloud_provider = "external-cloud-provider" gcp = "gcp" + external_load_balancer = "external-load-balancer" } } diff --git a/charms/worker/k8s/tests/unit/test_base.py b/charms/worker/k8s/tests/unit/test_base.py index 5c8e5cc3..44d0d373 100644 --- a/charms/worker/k8s/tests/unit/test_base.py +++ b/charms/worker/k8s/tests/unit/test_base.py @@ -57,6 +57,7 @@ def mock_reconciler_handlers(harness): } if harness.charm.is_control_plane: handler_names |= { + "_configure_external_load_balancer", "_bootstrap_k8s_snap", "_create_cluster_tokens", "_create_cos_tokens", @@ -66,6 +67,7 @@ def mock_reconciler_handlers(harness): "_ensure_cluster_config", "_expose_ports", "_announce_kubernetes_version", + "_ensure_cert_sans", } mocked = [mock.patch(f"charm.K8sCharm.{name}") for name in handler_names] @@ -197,7 +199,7 @@ def test_configure_datastore_runtime_config_etcd(harness): assert uccr_config.datastore.type == "external" -def test_configure_boostrap_extra_sans(harness): +def test_configure_bootstrap_extra_sans(harness): """Test configuring kube-apiserver-extra-sans on bootstrap. Args: @@ -208,11 +210,11 @@ def test_configure_boostrap_extra_sans(harness): cfg_extra_sans = ["mykubernetes", "mykubernetes.local"] public_addr = "11.12.13.14" + harness.add_relation("cluster", "remote", unit_data={"ingress-address": public_addr}) harness.update_config({"kube-apiserver-extra-sans": " ".join(cfg_extra_sans)}) - with mock.patch("charm._get_public_address") as mock_get_public_addr: - mock_get_public_addr.return_value = public_addr - + with mock.patch("charm._get_juju_public_address") as m: + m.return_value = public_addr bs_config = harness.charm._assemble_bootstrap_config() # We expect the resulting SANs to include the configured addresses as well diff --git a/charms/worker/k8s/tests/unit/test_config_options.py b/charms/worker/k8s/tests/unit/test_config_options.py index 25115b2c..44eb6745 100644 --- a/charms/worker/k8s/tests/unit/test_config_options.py +++ b/charms/worker/k8s/tests/unit/test_config_options.py @@ -81,12 +81,16 @@ def test_configure_common_extra_args(harness): Args: harness: the harness under test """ - harness.disable_hooks() + if harness.charm.is_worker: + pytest.skip("Not applicable on workers") + harness.disable_hooks() + harness.add_relation("cluster", "remote", unit_data={"ingress-address": "1.2.3.4"}) harness.update_config({"kubelet-extra-args": "v=3 foo=bar flag"}) harness.update_config({"kube-proxy-extra-args": "v=4 foo=baz flog"}) - with mock.patch("charm._get_public_address"): + with mock.patch("charm._get_juju_public_address") as m: + m.return_value = "1.1.1.1" bootstrap_config = harness.charm._assemble_bootstrap_config() assert bootstrap_config.extra_node_kubelet_args == { "--v": "3", @@ -110,12 +114,13 @@ def test_configure_controller_extra_args(harness): pytest.skip("Not applicable on workers") harness.disable_hooks() - + harness.add_relation("cluster", "remote", unit_data={"ingress-address": "1.2.3.4"}) harness.update_config({"kube-apiserver-extra-args": "v=3 foo=bar flag"}) harness.update_config({"kube-controller-manager-extra-args": "v=4 foo=baz flog"}) harness.update_config({"kube-scheduler-extra-args": "v=5 foo=bat blog"}) - with mock.patch("charm._get_public_address"): + with mock.patch("charm._get_juju_public_address") as m: + m.return_value = "1.1.1.1" bootstrap_config = harness.charm._assemble_bootstrap_config() assert bootstrap_config.extra_node_kube_apiserver_args == { "--v": "3", diff --git a/charms/worker/k8s/tests/unit/test_endpoints.py b/charms/worker/k8s/tests/unit/test_endpoints.py new file mode 100644 index 00000000..213fea5c --- /dev/null +++ b/charms/worker/k8s/tests/unit/test_endpoints.py @@ -0,0 +1,37 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# Learn more about testing at: https://juju.is/docs/sdk/testing + +"""Unit tests for endpoints module.""" + +from endpoints import build_url + + +def test_build_url(): + """Test build_url function.""" + test_cases = [ + # In the format of (addr, port, scheme, expected) + # IPv4 + ("1.2.3.4", "12345", "https", "https://1.2.3.4:12345"), + ("1.2.3.4:80", "12345", "https", "https://1.2.3.4:12345"), + ("http://1.2.3.4", "12345", "https", "https://1.2.3.4:12345"), + ("http://1.2.3.4:80", "12345", "https", "https://1.2.3.4:12345"), + # IPv6 + ("::1", "12345", "https", "https://[::1]:12345"), + ("[::1]", "12345", "https", "https://[::1]:12345"), + ("http://[::1]:80", "12345", "https", "https://[::1]:12345"), + ("2001:db8::1", "12345", "https", "https://[2001:db8::1]:12345"), + ("[2001:db8::1]", "12345", "https", "https://[2001:db8::1]:12345"), + ("[2001:db8::1]:80", "12345", "https", "https://[2001:db8::1]:12345"), + ("http://[2001:db8::1]:80", "12345", "https", "https://[2001:db8::1]:12345"), + # Domain + ("example.com", "12345", "https", "https://example.com:12345"), + ("example.com:80", "12345", "https", "https://example.com:12345"), + ("http://example.com", "12345", "https", "https://example.com:12345"), + ("http://example.com:80", "12345", "https", "https://example.com:12345"), + ] + + for addr, port, scheme, expected in test_cases: + result = build_url(addr, port, scheme) + assert result == expected, f"Failed for {addr}: {result} != {expected}" diff --git a/charms/worker/k8s/tests/unit/test_pki.py b/charms/worker/k8s/tests/unit/test_pki.py new file mode 100644 index 00000000..9422f958 --- /dev/null +++ b/charms/worker/k8s/tests/unit/test_pki.py @@ -0,0 +1,68 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# Learn more about testing at: https://juju.is/docs/sdk/testing + +"""Unit tests for pki module.""" + +import os +import tempfile + +from pki import get_certificate_sans + + +def test_get_certificate_sans(): + """Test get_certificate_sans function.""" + exp_dns_sans = [ + "kubernetes", + "kubernetes.default", + "kubernetes.default.svc", + "kubernetes.default.svc.cluster", + "kubernetes.default.svc.cluster.local", + ] + exp_ip_sans = [ + "10.152.183.1", + "127.0.0.1", + "10.97.72.214", + "::1", + "fe80::216:3eff:fed6:9e71", + ] + + with tempfile.NamedTemporaryFile(suffix=".crt", delete=False) as cert_file: + cert_path = cert_file.name + cert_content = """-----BEGIN CERTIFICATE----- +MIID6DCCAtCgAwIBAgIRAJ5lxXXSPlQqLz6uJzreDF0wDQYJKoZIhvcNAQELBQAw +GDEWMBQGA1UEAxMNa3ViZXJuZXRlcy1jYTAeFw0yNTAxMTcwODIwMTVaFw00NTAx +MTcwODIwMTVaMBkxFzAVBgNVBAMTDmt1YmUtYXBpc2VydmVyMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzldHfBkxh4RVBr21EpYAi8pcap9LzqCsxvR2 +7kn/u3SVow5Z40p7aFEMDf9CCG/gx+5oyh55wXQ6QiypA2PLA2kyZDK0kCtSpWPa +yGWLjCyejdRFWa7LU3aKxzlza6Kluy0sPXRBRoL7YZ105mUkQOa5ioMJuKB9xJ8A +MFHNdss29VVE6XaB7ndZtHiEwTZcWXNJ9i0YFVJs2kouakHCxt0qldRrLsugltWo +hrb31GsayBIAb/JSPbH8Hky26G/8RvMkykpGNC9CrEbaPj0JOZApd79xmNngc6Kb +U7KkcuirAeCE8Uji6k82ah2jKsExC4LR72F0YTeMNMpRVVcOMQIDAQABo4IBKjCC +ASYwDgYDVR0PAQH/BAQDAgSwMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD +ATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFO9Oss4CtGTt2bo6jYNXzZ7eZu6x +MIHFBgNVHREEgb0wgbqCCmt1YmVybmV0ZXOCEmt1YmVybmV0ZXMuZGVmYXVsdIIW +a3ViZXJuZXRlcy5kZWZhdWx0LnN2Y4Iea3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5j +bHVzdGVygiRrdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyHBAph +SNaHBAqYtwGHBH8AAAGHBAphSNaHEAAAAAAAAAAAAAAAAAAAAAGHEP6AAAAAAAAA +AhY+//7WnnEwDQYJKoZIhvcNAQELBQADggEBACJNIU9CRSO8yXpXCpn5roFKf9YW +BpfYe3c0A6aqhm6dVqHs6NEpH5T2KCYp1Tg4HSaawNxLS2BImCqKVNc/PlOyehoY +FnWE4Kli2C4zUv272peJb2wRcZjnZjHV9+Xh3rSI3tbrEJHVK1tkjAfLaAffk6KB +jqaO1we99UxeuhkRh6W8t8ARY9BasQRloe53c/+bDw6WtftaWuHlXbb4s4gUh0Un +GMLPA6dh7pJFo4uolAtbYc4oE0FRUySPxoZzw5p/Mzt9Kj8omPgmP4Hb3D+Uml8P +Kryj6dPJQjiDEqlfZC/n0aR98onWgb1O4Xdkm4HT20/R4gUNTS0rM/k4wTY= +-----END CERTIFICATE-----""" + cert_file.write(cert_content.encode()) + + try: + dns_sans, ip_sans = get_certificate_sans(cert_path) + + assert len(dns_sans) == len(set(dns_sans)) + assert len(ip_sans) == len(set(ip_sans)) + assert len(dns_sans) == len(exp_dns_sans) + assert len(ip_sans) == len(exp_ip_sans) + assert all(dns_name in exp_dns_sans for dns_name in dns_sans) + assert all(ip_addr in exp_ip_sans for ip_addr in ip_sans) + finally: + os.remove(cert_path)