From 37f676835f86de4facfc02d296866caaa8496dc6 Mon Sep 17 00:00:00 2001 From: Arturo Seijas Date: Mon, 12 Feb 2024 17:19:00 +0100 Subject: [PATCH] Deploy dex --- tests/integration/conftest.py | 49 ++++++- tests/integration/dex.py | 216 +++++++++++++++++++++++++++++ tests/integration/dex.yaml | 129 +++++++++++++++++ tests/integration/requirements.txt | 3 +- tox.ini | 1 + 5 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 tests/integration/dex.py create mode 100644 tests/integration/dex.yaml diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f7a73451..029f7acd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,13 +3,13 @@ """Fixtures for Jenkins-k8s-operator charm integration tests.""" +import os import random import secrets import string import typing import jenkinsapi.jenkins -import kubernetes.client import kubernetes.config import kubernetes.stream import pytest @@ -20,6 +20,8 @@ from juju.model import Controller, Model from juju.unit import Unit from keycloak import KeycloakAdmin, KeycloakOpenIDConnection +from lightkube import Client, KubeConfig +from lightkube.core.exceptions import ApiError from pytest import FixtureRequest from pytest_operator.plugin import OpsTest @@ -27,9 +29,12 @@ import state from .constants import ALLOWED_PLUGINS +from .dex import apply_dex_resources, create_dex_resources, get_dex_manifest, get_dex_service_url from .helpers import get_pod_ip from .types_ import KeycloakOIDCMetadata, LDAPSettings, ModelAppUnit, UnitWebClient +KUBECONFIG = os.environ.get("TESTING_KUBECONFIG", "~/.kube/config") + @pytest.fixture(scope="module", name="model") def model_fixture(ops_test: OpsTest) -> Model: @@ -843,6 +848,15 @@ async def oathkeeper_application_related_fixture(application: Application): "traefik-public:receive-ca-cert", "self_signed_certificates" ) await application.model.add_relation(f"{oathkeeper.name}:kratos-endpoint-info", "kratos") + await application.model.applications["kratos-external-idp-integrator"].set_config( + { + "client_id": "client_id", + "client_secret": "client_secret", + "provider": "generic", + "issuer_url": "https://path/to/dex", + "scope": "profile email", + } + ) await application.model.wait_for_idle( status="active", apps=[ @@ -857,6 +871,39 @@ async def oathkeeper_application_related_fixture(application: Application): return oathkeeper +@pytest.fixture(scope="session", name="client") +def client_fixture() -> Client: + """k8s client.""" + return Client(config=KubeConfig.from_file(KUBECONFIG), field_manager="dex-test") + + +@pytest.fixture(scope="module") +def ext_idp_service(ops_test: OpsTest, client: Client) -> typing.Generator[str, None, None]: + """Deploy a DEX service on top of k8s for authentication.""" + # Use ops-lib-manifests? + try: + create_dex_resources(client) + + # We need to set the dex issuer_url to be the IP that was assigned to + # the dex service by metallb. We can't know that before hand, so we + # reapply the dex manifests. + apply_dex_resources(client) + + yield get_dex_service_url(client) + finally: + if not ops_test.keep_model: + for obj in get_dex_manifest(): + try: + # mypy doesn't work well with lightkube + client.delete( + type(obj), + obj.metadata.name, # type: ignore + namespace=obj.metadata.namespace, # type: ignore + ) + except ApiError: + pass + + @pytest.fixture() def external_user_email() -> str: """Username for testing proxy authentication.""" diff --git a/tests/integration/dex.py b/tests/integration/dex.py new file mode 100644 index 00000000..555d5426 --- /dev/null +++ b/tests/integration/dex.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""DEX deployment and utilities for testing.""" + +import logging +from os.path import join +from pathlib import Path +from time import sleep +from typing import List, Optional + +import requests +from lightkube import Client, codecs +from lightkube.core.exceptions import ApiError, ObjectDeleted +from lightkube.resources.apps_v1 import Deployment +from lightkube.resources.core_v1 import Pod, Service +from requests.exceptions import RequestException + +logger = logging.getLogger(__name__) + + +DEX_MANIFESTS = Path(__file__).parent / "dex.yaml" + + +def get_dex_manifest( + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + redirect_uri: Optional[str] = None, + issuer_url: Optional[str] = None, +) -> List[codecs.AnyResource]: + """Get the DEX manifest interpolating the needed variables. + + Args: + client_id: client ID. + client_secret: client secret. + redirect_uri: redirect URI. + issuer_url: issuer URL. + + Returns: + the list of created resources. + """ + with open(DEX_MANIFESTS, "r", encoding="utf-8") as file: + return codecs.load_all_yaml( + file, + context={ + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + "issuer_url": issuer_url, + }, + ) + + +def _restart_dex(client: Client) -> None: + """Restart the DEX pods. + + Args: + client: k8s client. + """ + for pod in client.list(Pod, namespace="dex", labels={"app": "dex"}): + # mypy doesn't work well with lightkube + client.delete(Pod, pod.metadata.name, namespace="dex") # type: ignore + + +def _wait_until_dex_is_ready(client: Client, issuer_url: Optional[str] = None) -> None: + """Wait for DEX to be up. + + Args: + client: k8s client. + issuer_url: issuer URL. + + Raises: + RuntimeError: if DEX fails to start. + """ + for pod in client.list(Pod, namespace="dex", labels={"app": "dex"}): + # Some pods may be deleted, if we are restarting + try: + # mypy doesn't work well with lightkube + client.wait( + Pod, + pod.metadata.name, # type: ignore + for_conditions=["Ready", "Deleted"], + namespace="dex", + ) + except ObjectDeleted: + pass + client.wait(Deployment, "dex", namespace="dex", for_conditions=["Available"]) + if not issuer_url: + issuer_url = get_dex_service_url(client) + + resp = requests.get(join(issuer_url, ".well-known/openid-configuration"), timeout=5) + if resp.status_code != 200: + raise RuntimeError("Failed to deploy dex") + + +def wait_until_dex_is_ready(client: Client, issuer_url: Optional[str] = None) -> None: + """Wait for DEX to be up. + + Args: + client: k8s client. + issuer_url: issuer URL. + """ + try: + _wait_until_dex_is_ready(client, issuer_url) + except (RuntimeError, RequestException): + # It may take some time for dex to restart, so we sleep a little + # and try again + sleep(3) + _wait_until_dex_is_ready(client, issuer_url) + + +def _apply_dex_manifests( + client: Client, + client_id: str = "client_id", + client_secret: str = "client_secret", + redirect_uri: str = "", + issuer_url: Optional[str] = None, +) -> None: + """Apply the DEX manifest definitions. + + Args: + client: k8s client. + client_id: client ID. + client_secret: client secret. + redirect_uri: redirect URI. + issuer_url: issuer URL. + """ + objs = get_dex_manifest( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + issuer_url=issuer_url, + ) + + for obj in objs: + client.apply(obj, force=True) + + +def create_dex_resources( + client: Client, + client_id: str = "client_id", + client_secret: str = "client_secret", + redirect_uri: str = "", + issuer_url: Optional[str] = None, +): + """Apply the DEX manifest definitions and wait for DEX to be up. + + Args: + client: k8s client. + client_id: client ID. + client_secret: client secret. + redirect_uri: redirect URI. + issuer_url: issuer URL. + """ + _apply_dex_manifests( + client, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + issuer_url=issuer_url, + ) + + logger.info("Waiting for dex to be ready") + wait_until_dex_is_ready(client, issuer_url) + + +def apply_dex_resources( + client: Client, + client_id: str = "client_id", + client_secret: str = "client_secret", + redirect_uri: str = "", + issuer_url: Optional[str] = None, +) -> None: + """Apply the DEX manifest definitions and wait for DEX to start up. + + Args: + client: k8s client. + client_id: client ID. + client_secret: client secret. + redirect_uri: redirect URI. + issuer_url: issuer URL. + """ + if not issuer_url: + try: + issuer_url = get_dex_service_url(client) + except ApiError: + logger.info("No service found for dex") + + _apply_dex_manifests( + client, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + issuer_url=issuer_url, + ) + + logger.info("Restarting dex") + _restart_dex(client) + + logger.info("Waiting for dex to be ready") + wait_until_dex_is_ready(client, issuer_url) + + +def get_dex_service_url(client: Client) -> str: + """Get the DEX service URL. + + Args: + client: k8s client. + + Returns: + the service URL. + """ + service = client.get(Service, "dex", namespace="dex") + # mypy doesn't work well with lightkube + return f"http://{service.status.loadBalancer.ingress[0].ip}:5556/" # type: ignore diff --git a/tests/integration/dex.yaml b/tests/integration/dex.yaml new file mode 100644 index 00000000..cb74cbc8 --- /dev/null +++ b/tests/integration/dex.yaml @@ -0,0 +1,129 @@ +# Taken from https://github.com/dexidp/dex/blob/master/examples/k8s/dex.yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: dex +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: dex + name: dex + namespace: dex +spec: + replicas: 1 + selector: + matchLabels: + app: dex + template: + metadata: + labels: + app: dex + spec: + serviceAccountName: dex # This is created below + containers: + - image: ghcr.io/dexidp/dex:v2.32.0 + name: dex + command: ["/usr/local/bin/dex", "serve", "/etc/dex/cfg/config.yaml"] + + ports: + - name: http + containerPort: 5556 + + volumeMounts: + - name: config + mountPath: /etc/dex/cfg + + readinessProbe: + httpGet: + path: /healthz + port: 5556 + scheme: HTTP + volumes: + - name: config + configMap: + name: dex + items: + - key: config.yaml + path: config.yaml +--- +kind: ConfigMap +apiVersion: v1 +metadata: + name: dex + namespace: dex +data: + config.yaml: | + issuer: {{ issuer_url | d("http://dex.dex.svc.cluster.local:5556", true) }} + storage: + type: kubernetes + config: + inCluster: true + web: + http: 0.0.0.0:5556 + oauth2: + skipApprovalScreen: true + + staticClients: + - id: {{ client_id }} + redirectURIs: + - '{{ redirect_uri | d("http://example.com/redirect", true) }}' + name: 'Test App' + secret: {{ client_secret }} + + enablePasswordDB: true + staticPasswords: + - email: {{ email | d("admin@example.com", true) }} + # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: {{ username | d("admin", true) }} +--- +apiVersion: v1 +kind: Service +metadata: + name: dex + namespace: dex +spec: + type: LoadBalancer + ports: + - name: dex + port: 5556 + protocol: TCP + targetPort: 5556 + selector: + app: dex +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app: dex + name: dex + namespace: dex +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: dex +rules: +- apiGroups: ["dex.coreos.com"] # API group created by dex + resources: ["*"] + verbs: ["*"] +- apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["create"] # To manage its own resources, dex must be able to create customresourcedefinitions +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: dex +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: dex +subjects: +- kind: ServiceAccount + name: dex # Service account assigned to the dex pod, created above + namespace: dex # The namespace dex is running in diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt index cd32e088..774b9993 100644 --- a/tests/integration/requirements.txt +++ b/tests/integration/requirements.txt @@ -1,2 +1,3 @@ Jinja2>=3,<4 -python-keycloak>=3,<4 \ No newline at end of file +lightkube==0.15.1 +python-keycloak>=3,<4 diff --git a/tox.ini b/tox.ini index b94d9106..17821e12 100644 --- a/tox.ini +++ b/tox.ini @@ -65,6 +65,7 @@ deps = pep8-naming isort codespell + lightkube toml mypy pylint