From 8474250de2f2950cdcab04e4e48e87af380e9206 Mon Sep 17 00:00:00 2001 From: Vasily Oleynikov Date: Tue, 20 Feb 2024 15:12:46 +0300 Subject: [PATCH] [controller] Add a mutating webhook to configure the correct scheduler for pods using PVC provisioned by lvm.csi.storage.deckhouse.io (#8) Signed-off-by: Vasily Oleynikov --- hooks/common.py | 34 ++ hooks/generate_webhook_certs.py | 42 ++ hooks/lib/__init__.py | 0 hooks/lib/certificate/__init__.py | 0 hooks/lib/certificate/certificate.py | 265 +++++++++++ hooks/lib/certificate/parse.py | 31 ++ hooks/lib/hooks/__init__.py | 0 hooks/lib/hooks/copy_custom_certificate.py | 84 ++++ hooks/lib/hooks/hook.py | 56 +++ hooks/lib/hooks/internal_tls.py | 434 ++++++++++++++++++ hooks/lib/hooks/manage_tenant_secrets.py | 140 ++++++ hooks/lib/module/__init__.py | 0 hooks/lib/module/module.py | 59 +++ hooks/lib/module/values.py | 60 +++ hooks/lib/password_generator/__init__.py | 0 .../password_generator/password_generator.py | 77 ++++ hooks/lib/tests/__init__.py | 0 .../lib/tests/test_copy_custom_certificate.py | 110 +++++ hooks/lib/tests/test_internal_tls_test.py | 159 +++++++ hooks/lib/tests/test_manage_tenant_secrets.py | 102 ++++ hooks/lib/tests/testing.py | 57 +++ hooks/lib/utils.py | 54 +++ images/webhooks/src/go.mod | 50 ++ images/webhooks/src/go.sum | 172 +++++++ images/webhooks/src/main.go | 95 ++++ .../src/validators/podSchedulerMutation.go | 94 ++++ images/webhooks/werf.inc.yaml | 16 + openapi/values.yaml | 17 + templates/webhooks/deployment.yaml | 96 ++++ templates/webhooks/rbac-for-us.yaml | 49 ++ templates/webhooks/secret.yaml | 12 + templates/webhooks/service.yaml | 16 + templates/webhooks/webhook.yaml | 31 ++ 33 files changed, 2412 insertions(+) create mode 100644 hooks/common.py create mode 100755 hooks/generate_webhook_certs.py create mode 100644 hooks/lib/__init__.py create mode 100644 hooks/lib/certificate/__init__.py create mode 100644 hooks/lib/certificate/certificate.py create mode 100644 hooks/lib/certificate/parse.py create mode 100644 hooks/lib/hooks/__init__.py create mode 100644 hooks/lib/hooks/copy_custom_certificate.py create mode 100644 hooks/lib/hooks/hook.py create mode 100644 hooks/lib/hooks/internal_tls.py create mode 100644 hooks/lib/hooks/manage_tenant_secrets.py create mode 100644 hooks/lib/module/__init__.py create mode 100644 hooks/lib/module/module.py create mode 100644 hooks/lib/module/values.py create mode 100644 hooks/lib/password_generator/__init__.py create mode 100644 hooks/lib/password_generator/password_generator.py create mode 100644 hooks/lib/tests/__init__.py create mode 100644 hooks/lib/tests/test_copy_custom_certificate.py create mode 100644 hooks/lib/tests/test_internal_tls_test.py create mode 100644 hooks/lib/tests/test_manage_tenant_secrets.py create mode 100644 hooks/lib/tests/testing.py create mode 100644 hooks/lib/utils.py create mode 100644 images/webhooks/src/go.mod create mode 100644 images/webhooks/src/go.sum create mode 100644 images/webhooks/src/main.go create mode 100644 images/webhooks/src/validators/podSchedulerMutation.go create mode 100644 images/webhooks/werf.inc.yaml create mode 100644 templates/webhooks/deployment.yaml create mode 100644 templates/webhooks/rbac-for-us.yaml create mode 100644 templates/webhooks/secret.yaml create mode 100644 templates/webhooks/service.yaml create mode 100644 templates/webhooks/webhook.yaml diff --git a/hooks/common.py b/hooks/common.py new file mode 100644 index 00000000..cacf39c9 --- /dev/null +++ b/hooks/common.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from deckhouse import hook +from lib.module import module +from typing import Callable +import json +import os +import unittest + + +NAMESPACE = "d8-sds-lvm" +MODULE_NAME = "sdsLvm" + +def json_load(path: str): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data + +def get_dir_path() -> str: + return os.path.dirname(os.path.abspath(__file__)) diff --git a/hooks/generate_webhook_certs.py b/hooks/generate_webhook_certs.py new file mode 100755 index 00000000..de68a3ab --- /dev/null +++ b/hooks/generate_webhook_certs.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from lib.hooks.internal_tls import GenerateCertificateHook, TlsSecret, default_sans +from lib.module import values as module_values +from deckhouse import hook +from typing import Callable +import common + +def main(): + hook = GenerateCertificateHook( + TlsSecret( + cn="webhooks", + name="webhooks-https-certs", + sansGenerator=default_sans([ + "webhooks", + f"webhooks.{common.NAMESPACE}", + f"webhooks.{common.NAMESPACE}.svc"]), + values_path_prefix=f"{common.MODULE_NAME}.internal.customWebhookCert" + ), + cn="sds-lvm-webhooks", + common_ca=True, + namespace=common.NAMESPACE) + + hook.run() + +if __name__ == "__main__": + main() diff --git a/hooks/lib/__init__.py b/hooks/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hooks/lib/certificate/__init__.py b/hooks/lib/certificate/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hooks/lib/certificate/certificate.py b/hooks/lib/certificate/certificate.py new file mode 100644 index 00000000..10a06619 --- /dev/null +++ b/hooks/lib/certificate/certificate.py @@ -0,0 +1,265 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import re +from OpenSSL import crypto +from ipaddress import ip_address +from datetime import datetime, timedelta +from lib.certificate.parse import parse_certificate, get_certificate_san + +class Certificate: + def __init__(self, cn: str, expire: int, key_size: int, algo: str) -> None: + self.key = crypto.PKey() + self.__with_key(algo=algo, size=key_size) + self.cert = crypto.X509() + self.cert.set_version(version=2) + self.cert.get_subject().CN = cn + self.cert.set_serial_number(random.getrandbits(64)) + self.cert.gmtime_adj_notBefore(0) + self.cert.gmtime_adj_notAfter(expire) + + def get_subject(self) -> crypto.X509Name: + return self.cert.get_subject() + + def __with_key(self, algo: str, size: int) -> None: + if algo == "rsa": + self.key.generate_key(crypto.TYPE_RSA, size) + elif algo == "dsa": + self.key.generate_key(crypto.TYPE_DSA, size) + else: + raise Exception(f"Algo {algo} is not support. Only [rsa, dsa]") + + def with_metadata(self, country: str = None, + state: str = None, + locality: str = None, + organisation_name: str = None, + organisational_unit_name: str = None): + """ + Adds subjects to certificate. + + :param country: Optional. The country of the entity. + :type country: :py:class:`str` + + :param state: Optional. The state or province of the entity. + :type state: :py:class:`str` + + :param locality: Optional. The locality of the entity + :type locality: :py:class:`str` + + :param organisation_name: Optional. The organization name of the entity. + :type organisation_name: :py:class:`str` + + :param organisational_unit_name: Optional. The organizational unit of the entity. + :type organisational_unit_name: :py:class:`str` + """ + + if country is not None: + self.cert.get_subject().C = country + if state is not None: + self.cert.get_subject().ST = state + if locality is not None: + self.cert.get_subject().L = locality + if organisation_name is not None: + self.cert.get_subject().O = organisation_name + if organisational_unit_name is not None: + self.cert.get_subject().OU = organisational_unit_name + return self + + def add_extension(self, type_name: str, + critical: bool, + value: str, + subject: crypto.X509 = None, + issuer: crypto.X509 = None): + """ + Adds extensions to certificate. + :param type_name: The name of the type of extension_ to create. + :type type_name: :py:class:`str` + + :param critical: A flag indicating whether this is a critical + extension. + :type critical: :py:class:`bool` + + :param value: The OpenSSL textual representation of the extension's + value. + :type value: :py:class:`str` + + :param subject: Optional X509 certificate to use as subject. + :type subject: :py:class:`crypto.X509` + + :param issuer: Optional X509 certificate to use as issuer. + :type issuer: :py:class:`crypto.X509` + """ + ext = crypto.X509Extension(type_name=str.encode(type_name), + critical=critical, + value=str.encode(value), + subject=subject, + issuer=issuer) + self.cert.add_extensions(extensions=[ext]) + return self + + def generate(self) -> (bytes, bytes): + """ + Generate certificate. + :return: (certificate, key) + :rtype: (:py:data:`bytes`, :py:data:`bytes`) + """ + pub = crypto.dump_certificate(crypto.FILETYPE_PEM, self.cert) + priv = crypto.dump_privatekey(crypto.FILETYPE_PEM, self.key) + return pub, priv + + +class CACertificateGenerator(Certificate): + """ + A class representing a generator CA certificate. + """ + def __sign(self) -> None: + self.cert.set_issuer(self.get_subject()) + self.cert.set_pubkey(self.key) + self.cert.sign(self.key, 'sha256') + + def generate(self) -> (bytes, bytes): + """ + Generate CA certificate. + :return: (ca crt, ca key) + :rtype: (:py:data:`bytes`, :py:data:`bytes`) + """ + self.add_extension(type_name="subjectKeyIdentifier", + critical=False, value="hash", subject=self.cert) + self.add_extension(type_name="authorityKeyIdentifier", + critical=False, value="keyid:always", issuer=self.cert) + self.add_extension(type_name="basicConstraints", + critical=False, value="CA:TRUE") + self.add_extension(type_name="keyUsage", critical=False, + value="keyCertSign, cRLSign, keyEncipherment") + self.__sign() + return super().generate() + + +class CertificateGenerator(Certificate): + """ + A class representing a generator certificate. + """ + def with_hosts(self, *hosts: str): + """ + This function is used to add subject alternative names to a certificate. + It takes a variable number of hosts as parameters, and based on the type of host (IP or DNS). + + :param hosts: Variable number of hosts to be added as subject alternative names to the certificate. + :type hosts: :py:class:`tuple` + """ + alt_names = [] + for h in hosts: + try: + ip_address(h) + alt_names.append(f"IP:{h}") + except ValueError: + if not is_valid_hostname(h): + continue + alt_names.append(f"DNS:{h}") + self.add_extension("subjectAltName", False, ", ".join(alt_names)) + return self + + def __sign(self, ca_subj: crypto.X509Name, ca_key: crypto.PKey) -> None: + self.cert.set_issuer(ca_subj) + self.cert.set_pubkey(self.key) + self.cert.sign(ca_key, 'sha256') + + def generate(self, ca_subj: crypto.X509Name, ca_key: crypto.PKey) -> (bytes, bytes): + """ + Generate certificate. + :param ca_subj: CA subject. + :type ca_subj: :py:class:`crypto.X509Name` + :param ca_key: CA Key. + :type ca_key: :py:class:`crypto.PKey` + :return: (certificate, key) + :rtype: (:py:data:`bytes`, :py:data:`bytes`) + """ + self.__sign(ca_subj, ca_key) + return super().generate() + +def is_valid_hostname(hostname: str) -> bool: + if len(hostname) > 255: + return False + hostname.rstrip(".") + allowed = re.compile("(?!-)[A-Z\d-]{1,63}(? bool: + """ + Check certificate + :param crt: Certificate + :type crt: :py:class:`crypto.X509` + :param cert_outdated_duration: certificate outdated duration + :type cert_outdated_duration: :py:class:`timedelta` + :return: + if timeNow > expire - cert_outdated_duration: + return True + return False + :rtype: :py:class:`bool` + """ + not_after = datetime.strptime( + crt.get_notAfter().decode('ascii'), '%Y%m%d%H%M%SZ') + if datetime.now() > not_after - cert_outdated_duration: + return True + return False + + +def is_outdated_ca(ca: str, cert_outdated_duration: timedelta) -> bool: + """ + Issue a new certificate if there is no CA in the secret. Without CA it is not possible to validate the certificate. + Check CA duration. + :param ca: Raw CA + :type ca: :py:class:`str` + :param cert_outdated_duration: certificate outdated duration + :type cert_outdated_duration: :py:class:`timedelta` + :rtype: :py:class:`bool` + """ + if len(ca) == 0: + return True + crt = parse_certificate(ca) + return cert_renew_deadline_exceeded(crt, cert_outdated_duration) + + +def is_irrelevant_cert(crt_data: str, sans: list, cert_outdated_duration: timedelta) -> bool: + """ + Check certificate duration and SANs list + :param crt_data: Raw certificate + :type crt_data: :py:class:`str` + :param sans: List of sans. + :type sans: :py:class:`list` + :param cert_outdated_duration: certificate outdated duration + :type cert_outdated_duration: :py:class:`timedelta` + :rtype: :py:class:`bool` + """ + if len(crt_data) == 0: + return True + crt = parse_certificate(crt_data) + if cert_renew_deadline_exceeded(crt, cert_outdated_duration): + return True + alt_names = [] + for san in sans: + try: + ip_address(san) + alt_names.append(f"IP Address:{san}") + except ValueError: + alt_names.append(f"DNS:{san}") + cert_sans = get_certificate_san(crt) + cert_sans.sort() + alt_names.sort() + if cert_sans != alt_names: + return True + return False \ No newline at end of file diff --git a/hooks/lib/certificate/parse.py b/hooks/lib/certificate/parse.py new file mode 100644 index 00000000..fd88c0d8 --- /dev/null +++ b/hooks/lib/certificate/parse.py @@ -0,0 +1,31 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from OpenSSL import crypto +from pprint import pprint + + +def parse_certificate(crt: str) -> crypto.X509: + return crypto.load_certificate(crypto.FILETYPE_PEM, crt) + + +def get_certificate_san(crt: crypto.X509) -> list[str]: + san = '' + ext_count = crt.get_extension_count() + for i in range(0, ext_count): + ext = crt.get_extension(i) + if 'subjectAltName' in str(ext.get_short_name()): + san = ext.__str__() + return san.split(', ') diff --git a/hooks/lib/hooks/__init__.py b/hooks/lib/hooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hooks/lib/hooks/copy_custom_certificate.py b/hooks/lib/hooks/copy_custom_certificate.py new file mode 100644 index 00000000..b3f3e835 --- /dev/null +++ b/hooks/lib/hooks/copy_custom_certificate.py @@ -0,0 +1,84 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from deckhouse import hook +from typing import Callable +from lib.module import module +from lib.hooks.hook import Hook + +class CopyCustomCertificatesHook(Hook): + CUSTOM_CERTIFICATES_SNAPSHOT_NAME = "custom_certificates" + def __init__(self, + module_name: str = None): + super().__init__(module_name=module_name) + self.queue = f"/modules/{self.module_name}/copy-custom-certificates" + + def generate_config(self) -> dict: + return { + "configVersion": "v1", + "beforeHelm": 10, + "kubernetes": [ + { + "name": self.CUSTOM_CERTIFICATES_SNAPSHOT_NAME, + "apiVersion": "v1", + "kind": "Secret", + "labelSelector": { + "matchExpressions": [ + { + "key": "owner", + "operator": "NotIn", + "values": ["helm"] + } + ] + }, + "namespace": { + "nameSelector": { + "matchNames": ["d8-system"] + } + }, + "includeSnapshotsFrom": [self.CUSTOM_CERTIFICATES_SNAPSHOT_NAME], + "jqFilter": '{"name": .metadata.name, "data": .data}', + "queue": self.queue, + "keepFullObjectsInMemory": False + }, + ] + } + + def reconcile(self) -> Callable[[hook.Context], None]: + def r(ctx: hook.Context) -> None: + custom_certificates = {} + for s in ctx.snapshots.get(self.CUSTOM_CERTIFICATES_SNAPSHOT_NAME, []): + custom_certificates[s["filterResult"]["name"]] = s["filterResult"]["data"] + if len(custom_certificates) == 0: + return + + https_mode = module.get_https_mode(module_name=self.module_name, + values=ctx.values) + path = f"{self.module_name}.internal.customCertificateData" + if https_mode != "CustomCertificate": + self.delete_value(path, ctx.values) + return + + raw_secret_name = module.get_values_first_defined(ctx.values, + f"{self.module_name}.https.customCertificate.secretName", + "global.modules.https.customCertificate.secretName") + secret_name = str(raw_secret_name or "") + secret_data = custom_certificates.get(secret_name) + if secret_data is None: + print( + f"Custom certificate secret name is configured, but secret d8-system/{secret_name} doesn't exist") + return + self.set_value(path, ctx.values, secret_data) + return r \ No newline at end of file diff --git a/hooks/lib/hooks/hook.py b/hooks/lib/hooks/hook.py new file mode 100644 index 00000000..e412faec --- /dev/null +++ b/hooks/lib/hooks/hook.py @@ -0,0 +1,56 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from deckhouse import hook +from typing import Callable +from lib.module import module +from lib.module import values as module_values +import yaml + +class Hook: + def __init__(self, module_name: str = None) -> None: + self.module_name = self.get_module_name(module_name) + + def generate_config(self): + pass + + @staticmethod + def get_value(path: str, values: dict, default=None): + return module_values.get_value(path, values, default) + + @staticmethod + def set_value(path: str, values: dict, value: str) -> None: + return module_values.set_value(path, values, value) + + @staticmethod + def delete_value(path: str, values: dict) -> None: + return module_values.delete_value(path, values) + + @staticmethod + def get_module_name(module_name: str) -> str: + if module_name is not None: + return module_name + return module.get_module_name() + + def reconcile(self) -> Callable[[hook.Context], None]: + def r(ctx: hook.Context) -> None: + pass + return r + + def run(self) -> None: + conf = self.generate_config() + if isinstance(conf, dict): + conf = yaml.dump(conf) + hook.run(func=self.reconcile(), config=conf) diff --git a/hooks/lib/hooks/internal_tls.py b/hooks/lib/hooks/internal_tls.py new file mode 100644 index 00000000..3b351c65 --- /dev/null +++ b/hooks/lib/hooks/internal_tls.py @@ -0,0 +1,434 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from deckhouse import hook +from datetime import timedelta +from OpenSSL import crypto +from typing import Callable +from lib.hooks.hook import Hook +import lib.utils as utils +import lib.certificate.certificate as certificate + +PUBLIC_DOMAIN_PREFIX = "%PUBLIC_DOMAIN%://" +CLUSTER_DOMAIN_PREFIX = "%CLUSTER_DOMAIN%://" + + +KEY_USAGES = { + 0: "digitalSignature", + 1: "nonRepudiation", + 2: "keyEncipherment", + 3: "dataEncipherment", + 4: "keyAgreement", + 5: "keyCertSign", + 6: "cRLSign", + 7: "encipherOnly", + 8: "decipherOnly" +} + +EXTENDED_KEY_USAGES = { + 0: "serverAuth", + 1: "clientAuth", + 2: "codeSigning", + 3: "emailProtection", + 4: "OCSPSigning" +} + +class TlsSecret: + def __init__(self, + cn: str, + name: str, + sansGenerator: Callable[[list[str]], Callable[[hook.Context], list[str]]], + values_path_prefix: str, + key_usages: list[str] = [KEY_USAGES[2], KEY_USAGES[5]], + extended_key_usages: list[str] = [EXTENDED_KEY_USAGES[0]]): + self.cn = cn + self.name = name + self.sansGenerator = sansGenerator + self.values_path_prefix = values_path_prefix + self.key_usages = key_usages + self.extended_key_usages = extended_key_usages + +class GenerateCertificateHook(Hook): + """ + Config for the hook that generates certificates. + """ + SNAPSHOT_SECRETS_NAME = "secrets" + SNAPSHOT_SECRETS_CHECK_NAME = "secretsCheck" + + def __init__(self, *tls_secrets: TlsSecret, + cn: str, + namespace: str, + module_name: str = None, + common_ca: bool = False, + before_hook_check: Callable[[hook.Context], bool] = None, + expire: int = 31536000, + key_size: int = 4096, + algo: str = "rsa", + cert_outdated_duration: timedelta = timedelta(days=30), + country: str = None, + state: str = None, + locality: str = None, + organisation_name: str = None, + organisational_unit_name: str = None) -> None: + super().__init__(module_name=module_name) + self.cn = cn + self.tls_secrets = tls_secrets + self.namespace = namespace + self.common_ca = common_ca + self.before_hook_check = before_hook_check + self.expire = expire + self.key_size = key_size + self.algo = algo + self.cert_outdated_duration = cert_outdated_duration + self.country = country + self.state = state + self.locality = locality + self.organisation_name = organisation_name + self.organisational_unit_name = organisational_unit_name + self.secret_names = [secret.name for secret in self.tls_secrets] + self.queue = f"/modules/{self.module_name}/generate-certs" + """ + :param module_name: Module name + :type module_name: :py:class:`str` + + :param cn: Certificate common Name. often it is module name + :type cn: :py:class:`str` + + :param sansGenerator: Function which returns list of domain to include into cert. Use default_sans + :type sansGenerator: :py:class:`function` + + :param namespace: Namespace for TLS secret. + :type namespace: :py:class:`str` + + :param tls_secret_name: TLS secret name. + Secret must be TLS secret type https://kubernetes.io/docs/concepts/configuration/secret/#tls-secrets. + CA certificate MUST set to ca.crt key. + :type tls_secret_name: :py:class:`str` + + :param values_path_prefix: Prefix full path to store CA certificate TLS private key and cert. + full paths will be + values_path_prefix + .`ca` - CA certificate + values_path_prefix + .`crt` - TLS private key + values_path_prefix + .`key` - TLS certificate + Example: values_path_prefix = 'virtualization.internal.dvcrCert' + Data in values store as plain text + :type values_path_prefix: :py:class:`str` + + :param key_usages: Optional. key_usages specifies valid usage contexts for keys. + :type key_usages: :py:class:`list` + + :param extended_key_usages: Optional. extended_key_usages specifies valid usage contexts for keys. + :type extended_key_usages: :py:class:`list` + + :param before_hook_check: Optional. Runs check function before hook execution. Function should return boolean 'continue' value + if return value is false - hook will stop its execution + if return value is true - hook will continue + :type before_hook_check: :py:class:`function` + + :param expire: Optional. Validity period of SSL certificates. + :type expire: :py:class:`int` + + :param key_size: Optional. Key Size. + :type key_size: :py:class:`int` + + :param algo: Optional. Key generation algorithm. Supports only rsa and dsa. + :type algo: :py:class:`str` + + :param cert_outdated_duration: Optional. (expire - cert_outdated_duration) is time to regenerate the certificate. + :type cert_outdated_duration: :py:class:`timedelta` + """ + + def generate_config(self) -> dict: + return { + "configVersion": "v1", + "beforeHelm": 5, + "kubernetes": [ + { + "name": self.SNAPSHOT_SECRETS_NAME, + "apiVersion": "v1", + "kind": "Secret", + "nameSelector": { + "matchNames": self.secret_names + }, + "namespace": { + "nameSelector": { + "matchNames": [self.namespace] + } + }, + "includeSnapshotsFrom": [self.SNAPSHOT_SECRETS_NAME], + "jqFilter": '{"name": .metadata.name, "data": .data}', + "queue": self.queue, + "keepFullObjectsInMemory": False + }, + ], + "schedule": [ + { + "name": self.SNAPSHOT_SECRETS_CHECK_NAME, + "crontab": "42 4 * * *" + } + ] + } + + def reconcile(self) -> Callable[[hook.Context], None]: + def r(ctx: hook.Context) -> None: + if self.before_hook_check is not None: + passed = self.before_hook_check(ctx) + if not passed: + return + + regenerate_all = False + secrets_from_snaps = {} + diff_secrets = [] + if len(ctx.snapshots.get(self.SNAPSHOT_SECRETS_NAME, [])) == 0: + regenerate_all = True + else: + for snap in ctx.snapshots[self.SNAPSHOT_SECRETS_NAME]: + secrets_from_snaps[snap["filterResult"]["name"]] = snap["filterResult"]["data"] + for secret in self.tls_secrets: + if secrets_from_snaps.get(secret.name) is None: + diff_secrets.append(secret.name) + + if self.common_ca and not regenerate_all: + if len(diff_secrets) > 0: + regenerate_all = True + else: + for secret in self.tls_secrets: + data = secrets_from_snaps[secret.name] + if self.is_outdated_ca(utils.base64_decode(data.get("ca.crt", ""))): + regenerate_all = True + break + sans = secret.sansGenerator(ctx) + if self.is_irrelevant_cert(utils.base64_decode(data.get("tls.crt", "")), sans): + regenerate_all = True + break + + if regenerate_all: + if self.common_ca: + ca = self.__get_ca_generator() + ca_crt, _ = ca.generate() + for secret in self.tls_secrets: + sans = secret.sansGenerator(ctx) + print(f"Generate new certififcates for secret {secret.name}.") + tls_data = self.generate_selfsigned_tls_data_with_ca(cn=secret.cn, + ca=ca, + ca_crt=ca_crt, + sans=sans, + key_usages=secret.key_usages, + extended_key_usages=secret.extended_key_usages) + self.set_value(secret.values_path_prefix, ctx.values, tls_data) + return + + for secret in self.tls_secrets: + sans = secret.sansGenerator(ctx) + print(f"Generate new certififcates for secret {secret.name}.") + tls_data = self.generate_selfsigned_tls_data(cn=secret.cn, + sans=sans, + key_usages=secret.key_usages, + extended_key_usages=secret.extended_key_usages) + self.set_value(secret.values_path_prefix, ctx.values, tls_data) + return + + for secret in self.tls_secrets: + data = secrets_from_snaps[secret.name] + sans = secret.sansGenerator(ctx) + cert_outdated = self.is_irrelevant_cert( + utils.base64_decode(data.get("tls.crt", "")), sans) + + tls_data = {} + if cert_outdated or data.get("tls.key", "") == "": + print(f"Certificates from secret {secret.name} is invalid. Generate new certififcates.") + tls_data = self.generate_selfsigned_tls_data(cn=secret.cn, + sans=sans, + key_usages=secret.key_usages, + extended_key_usages=secret.extended_key_usages) + else: + tls_data = { + "ca": data["ca.crt"], + "crt": data["tls.crt"], + "key": data["tls.key"] + } + self.set_value(secret.values_path_prefix, ctx.values, tls_data) + return r + + def __get_ca_generator(self) -> certificate.CACertificateGenerator: + return certificate.CACertificateGenerator(cn=f"{self.cn}", + expire=self.expire, + key_size=self.key_size, + algo=self.algo) + + def generate_selfsigned_tls_data_with_ca(self, + cn: str, + ca: certificate.CACertificateGenerator, + ca_crt: bytes, + sans: list[str], + key_usages: list[str], + extended_key_usages: list[str]) -> dict[str, str]: + """ + Generate self signed certificate. + :param cn: certificate common name. + :param ca: Ca certificate generator. + :type ca: :py:class:`certificate.CACertificateGenerator` + :param ca_crt: bytes. + :type ca_crt: :py:class:`bytes` + :param sans: List of sans. + :type sans: :py:class:`list` + :param key_usages: List of key_usages. + :type key_usages: :py:class:`list` + :param extended_key_usages: List of extended_key_usages. + :type extended_key_usages: :py:class:`list` + Example: { + "ca": "encoded in base64", + "crt": "encoded in base64", + "key": "encoded in base64" + } + :rtype: :py:class:`dict[str, str]` + """ + cert = certificate.CertificateGenerator(cn=cn, + expire=self.expire, + key_size=self.key_size, + algo=self.algo) + if len(key_usages) > 0: + key_usages = ", ".join(key_usages) + cert.add_extension(type_name="keyUsage", + critical=False, value=key_usages) + if len(extended_key_usages) > 0: + extended_key_usages = ", ".join(extended_key_usages) + cert.add_extension(type_name="extendedKeyUsage", + critical=False, value=extended_key_usages) + crt, key = cert.with_metadata(country=self.country, + state=self.state, + locality=self.locality, + organisation_name=self.organisation_name, + organisational_unit_name=self.organisational_unit_name + ).with_hosts(*sans).generate(ca_subj=ca.get_subject(), + ca_key=ca.key) + return {"ca": utils.base64_encode(ca_crt), + "crt": utils.base64_encode(crt), + "key": utils.base64_encode(key)} + + def generate_selfsigned_tls_data(self, + cn: str, + sans: list[str], + key_usages: list[str], + extended_key_usages: list[str]) -> dict[str, str]: + """ + Generate self signed certificate. + :param cn: certificate common name. + :param sans: List of sans. + :type sans: :py:class:`list` + :param key_usages: List of key_usages. + :type key_usages: :py:class:`list` + :param extended_key_usages: List of extended_key_usages. + :type extended_key_usages: :py:class:`list` + Example: { + "ca": "encoded in base64", + "crt": "encoded in base64", + "key": "encoded in base64" + } + :rtype: :py:class:`dict[str, str]` + """ + ca = self.__get_ca_generator() + ca_crt, _ = ca.generate() + return self.generate_selfsigned_tls_data_with_ca(cn=cn, + ca=ca, + ca_crt=ca_crt, + sans=sans, + key_usages=key_usages, + extended_key_usages=extended_key_usages) + + def is_irrelevant_cert(self, crt_data: str, sans: list) -> bool: + """ + Check certificate duration and SANs list + :param crt_data: Raw certificate + :type crt_data: :py:class:`str` + :param sans: List of sans. + :type sans: :py:class:`list` + :rtype: :py:class:`bool` + """ + return certificate.is_irrelevant_cert(crt_data, sans, self.cert_outdated_duration) + + def is_outdated_ca(self, ca: str) -> bool: + """ + Issue a new certificate if there is no CA in the secret. Without CA it is not possible to validate the certificate. + Check CA duration. + :param ca: Raw CA + :type ca: :py:class:`str` + :rtype: :py:class:`bool` + """ + return certificate.is_outdated_ca(ca, self.cert_outdated_duration) + + def cert_renew_deadline_exceeded(self, crt: crypto.X509) -> bool: + """ + Check certificate + :param crt: Certificate + :type crt: :py:class:`crypto.X509` + :return: + if timeNow > expire - cert_outdated_duration: + return True + return False + :rtype: :py:class:`bool` + """ + return certificate.cert_renew_deadline_exceeded(crt, self.cert_outdated_duration) + +def default_sans(sans: list[str]) -> Callable[[hook.Context], list[str]]: + """ + Generate list of sans for certificate + :param sans: List of alt names. + :type sans: :py:class:`list[str]` + cluster_domain_san(san) to generate sans with respect of cluster domain (e.g.: "app.default.svc" with "cluster.local" value will give: app.default.svc.cluster.local + + public_domain_san(san) + """ + def generate_sans(ctx: hook.Context) -> list[str]: + res = ["localhost", "127.0.0.1"] + public_domain = str(ctx.values["global"]["modules"].get( + "publicDomainTemplate", "")) + cluster_domain = str( + ctx.values["global"]["discovery"].get("clusterDomain", "")) + for san in sans: + san.startswith(PUBLIC_DOMAIN_PREFIX) + if san.startswith(PUBLIC_DOMAIN_PREFIX) and public_domain != "": + san = get_public_domain_san(san, public_domain) + elif san.startswith(CLUSTER_DOMAIN_PREFIX) and cluster_domain != "": + san = get_cluster_domain_san(san, cluster_domain) + res.append(san) + return res + return generate_sans + + +def cluster_domain_san(san: str) -> str: + """ + Create template to enrich specified san with a cluster domain + :param san: San. + :type sans: :py:class:`str` + """ + return CLUSTER_DOMAIN_PREFIX + san.rstrip('.') + + +def public_domain_san(san: str) -> str: + """ + Create template to enrich specified san with a public domain + :param san: San. + :type sans: :py:class:`str` + """ + return PUBLIC_DOMAIN_PREFIX + san.rstrip('.') + + +def get_public_domain_san(san: str, public_domain: str) -> str: + return f"{san.lstrip(PUBLIC_DOMAIN_PREFIX)}.{public_domain}" + + +def get_cluster_domain_san(san: str, cluster_domain: str) -> str: + return f"{san.lstrip(CLUSTER_DOMAIN_PREFIX)}.{cluster_domain}" diff --git a/hooks/lib/hooks/manage_tenant_secrets.py b/hooks/lib/hooks/manage_tenant_secrets.py new file mode 100644 index 00000000..36d817b4 --- /dev/null +++ b/hooks/lib/hooks/manage_tenant_secrets.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from deckhouse import hook +from typing import Callable +from lib.hooks.hook import Hook + +class ManageTenantSecretsHook(Hook): + POD_SNAPSHOT_NAME = "pods" + SECRETS_SNAPSHOT_NAME = "secrets" + NAMESPACE_SNAPSHOT_NAME = "namespaces" + + def __init__(self, + source_namespace: str, + source_secret_name: str, + pod_labels_to_follow: dict, + destination_secret_labels: dict = {}, + module_name: str = None): + super().__init__(module_name=module_name) + self.source_namespace = source_namespace + self.source_secret_name = source_secret_name + self.pod_labels_to_follow = pod_labels_to_follow + self.destination_secret_labels = destination_secret_labels + self.module_name = module_name + self.queue = f"/modules/{module_name}/manage-tenant-secrets" + + def generate_config(self) -> dict: + return { + "configVersion": "v1", + "kubernetes": [ + { + "name": self.POD_SNAPSHOT_NAME, + "apiVersion": "v1", + "kind": "Pod", + "includeSnapshotsFrom": [ + self.POD_SNAPSHOT_NAME, + self.SECRETS_SNAPSHOT_NAME, + self.NAMESPACE_SNAPSHOT_NAME + ], + "labelSelector": { + "matchLabels": self.pod_labels_to_follow + }, + "jqFilter": '{"namespace": .metadata.namespace}', + "queue": self.queue, + "keepFullObjectsInMemory": False + }, + { + "name": self.SECRETS_SNAPSHOT_NAME, + "apiVersion": "v1", + "kind": "Secret", + "includeSnapshotsFrom": [ + self.POD_SNAPSHOT_NAME, + self.SECRETS_SNAPSHOT_NAME, + self.NAMESPACE_SNAPSHOT_NAME + ], + "nameSelector": { + "matchNames": [self.source_secret_name] + }, + "jqFilter": '{"data": .data, "namespace": .metadata.namespace, "type": .type}', + "queue": self.queue, + "keepFullObjectsInMemory": False + }, + { + "name": self.NAMESPACE_SNAPSHOT_NAME, + "apiVersion": "v1", + "kind": "Secret", + "includeSnapshotsFrom": [ + self.POD_SNAPSHOT_NAME, + self.SECRETS_SNAPSHOT_NAME, + self.NAMESPACE_SNAPSHOT_NAME + ], + "jqFilter": '{"name": .metadata.name, "isTerminating": any(.metadata; .deletionTimestamp != null)}', + "queue": self.queue, + "keepFullObjectsInMemory": False + } + ] + } + + def generate_secret(self, namespace: str, data: dict, secret_type: str) -> dict: + return { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": self.source_secret_name, + "namespace": namespace, + "labels": self.destination_secret_labels + }, + "data": data, + "type": secret_type + } + + def reconcile(self) -> Callable[[hook.Context], None]: + def r(ctx: hook.Context) -> None: + pod_namespaces = set([p["filterResult"]["namespace"] for p in ctx.snapshots.get(self.POD_SNAPSHOT_NAME, [])]) + secrets = ctx.snapshots.get(self.SECRETS_SNAPSHOT_NAME, []) + for ns in ctx.snapshots.get(self.NAMESPACE_SNAPSHOT_NAME, []): + if ns["filterResult"]["isTerminating"]: + pod_namespaces.discard(ns["filterResult"]["name"]) + data, secret_type, secrets_by_ns = "", "", {} + for s in secrets: + if s["filterResult"]["namespace"] == self.source_namespace: + data = s["filterResult"]["data"] + secret_type = s["filterResult"]["type"] + continue + secrets_by_ns[s["filterResult"]["namespace"]] = s["filterResult"]["data"] + + if len(data) == 0 or len(secret_type) == 0: + print(f"Registry secret {self.source_namespace}/{self.source_secret_name} not found. Skip") + return + + for ns in pod_namespaces: + secret_data = secrets_by_ns.get(ns, "") + if (secret_data != data) and (ns != self.source_namespace): + secret = self.generate_secret(namespace=ns, + data=data, + secret_type=secret_type) + print(f"Create secret {ns}/{self.source_secret_name}.") + ctx.kubernetes.create_or_update(secret) + for ns in secrets_by_ns: + if (ns in pod_namespaces) or (ns == self.source_namespace): + continue + print(f"Delete secret {ns}/{self.source_secret_name}.") + ctx.kubernetes.delete(kind="Secret", + namespace=ns, + name=self.source_secret_name) + return r + diff --git a/hooks/lib/module/__init__.py b/hooks/lib/module/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hooks/lib/module/module.py b/hooks/lib/module/module.py new file mode 100644 index 00000000..25e034de --- /dev/null +++ b/hooks/lib/module/module.py @@ -0,0 +1,59 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from lib.module import values as module_values +import re +import os + +def get_values_first_defined(values: dict, *keys): + return _get_first_defined(values, keys) + +def _get_first_defined(values: dict, keys: tuple): + for i in range(len(keys)): + if (val := module_values.get_value(path=keys[i], values=values)) is not None: + return val + return + +def get_https_mode(module_name: str, values: dict) -> str: + module_path = f"{module_name}.https.mode" + global_path = "global.modules.https.mode" + https_mode = get_values_first_defined(values, module_path, global_path) + if https_mode is not None: + return str(https_mode) + raise Exception("https mode is not defined") + +def get_module_name() -> str: + module = "" + file_path = os.path.abspath(__file__) + external_modules_dir = os.getenv("EXTERNAL_MODULES_DIR") + for dir in os.getenv("MODULES_DIR").split(":"): + if dir.startswith(external_modules_dir): + dir = external_modules_dir + if file_path.startswith(dir): + module = re.sub(f"{dir}/?\d?\d?\d?-?", "", file_path, 1).split("/")[0] + # /deckhouse/external-modules/virtualization/mr/hooks/hook_name.py + # {-------------------------- file_path --------------------------} + # {------ MODULES_DIR ------}{---------- regexp result ----------}} + # virtualization/mr/hooks/hook_name.py + # {-module-name-}{---------------------} + # or + # /deckhouse/modules/900-virtualization/hooks/hook_name.py + # {---------------------- file_path ----------------------} + # {-- MODULES_DIR --}{---{-------- regexp result --------}} + # virtualization/hooks/hook_name.py + # {-module-name-}{-----------------} + + break + return module \ No newline at end of file diff --git a/hooks/lib/module/values.py b/hooks/lib/module/values.py new file mode 100644 index 00000000..449e4441 --- /dev/null +++ b/hooks/lib/module/values.py @@ -0,0 +1,60 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +def get_value(path: str, values: dict, default=None): + def get(keys: list, values: dict, default): + if len(keys) == 1: + if not isinstance(values, dict): + return default + return values.get(keys[0], default) + if not isinstance(values, dict) or values.get(keys[0]) is None: + return default + if values.get(keys[0]) is None: + return default + return get(keys[1:], values[keys[0]], default) + keys = path.lstrip(".").split(".") + return get(keys, values, default) + +def set_value(path: str, values: dict, value) -> None: + """ + Functions for save value to dict. + + Example: + path = "virtualization.internal.dvcr.cert" + values = {"virtualization": {"internal": {}}} + value = "{"ca": "ca", "crt"="tlscrt", "key"="tlskey"}" + + result values = {"virtualization": {"internal": {"dvcr": {"cert": {"ca": "ca", "crt":"tlscrt", "key":"tlskey"}}}}} + """ + def set(keys: list, values: dict, value): + if len(keys) == 1: + values[keys[0]] = value + return + if values.get(keys[0]) is None: + values[keys[0]] = {} + set(keys[1:], values[keys[0]], value) + keys = path.lstrip(".").split(".") + return set(keys, values, value) + +def delete_value(path: str, values: dict) -> None: + if get_value(path, values) is None: + return + keys = path.lstrip(".").split(".") + def delete(keys: list, values: dict) -> None: + if len(keys) == 1: + values.pop(keys[0]) + return + delete(keys[1:], values[keys[0]]) + return delete(keys, values) \ No newline at end of file diff --git a/hooks/lib/password_generator/__init__.py b/hooks/lib/password_generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hooks/lib/password_generator/password_generator.py b/hooks/lib/password_generator/password_generator.py new file mode 100644 index 00000000..df63ea82 --- /dev/null +++ b/hooks/lib/password_generator/password_generator.py @@ -0,0 +1,77 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import string + +def generate_random_string(length: int, letters: str) -> str: + return ''.join(random.choice(letters) for i in range(length)) + +SYMBOLS = "[]{}<>()=-_!@#$%^&*.," + +def num(length: int) -> str: + """ + Generates a random string of the given length out of numeric characters. + :param length: length of generate string. + :type length: :py:class:`int` + :rtype: :py:class:`str` + """ + return generate_random_string(length, string.digits) + +def alpha(length: int) -> str: + """ + Generates a random string of the given length out of alphabetic characters. + :param length: length of generate string. + :type length: :py:class:`int` + :rtype: :py:class:`str` + """ + return generate_random_string(length, string.ascii_letters) + +def symbols(length: int) -> str: + """ + Generates a random string of the given length out of symbols. + :param length: length of generate string. + :type length: :py:class:`int` + :rtype: :py:class:`str` + """ + return generate_random_string(length, SYMBOLS) + + +def alpha_num(length: int) -> str: + """ + Generates a random string of the given length out of alphanumeric characters. + :param length: length of generate string. + :type length: :py:class:`int` + :rtype: :py:class:`str` + """ + return generate_random_string(length, string.ascii_letters + string.digits) + +def alpha_num_lower_case(length: int) -> str: + """ + Generates a random string of the given length out of alphanumeric characters without UpperCase letters. + :param length: length of generate string. + :type length: :py:class:`int` + :rtype: :py:class:`str` + """ + return generate_random_string(length, string.ascii_lowercase + string.digits) + +def alpha_num_symbols(length: int) -> str: + """ + Generates a random string of the given length out of alphanumeric characters and symbols. + :param length: length of generate string. + :type length: :py:class:`int` + :rtype: :py:class:`str` + """ + return generate_random_string(length, string.ascii_letters + string.digits + SYMBOLS) diff --git a/hooks/lib/tests/__init__.py b/hooks/lib/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hooks/lib/tests/test_copy_custom_certificate.py b/hooks/lib/tests/test_copy_custom_certificate.py new file mode 100644 index 00000000..c10e9c95 --- /dev/null +++ b/hooks/lib/tests/test_copy_custom_certificate.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from lib.tests import testing +from lib.hooks.copy_custom_certificate import CopyCustomCertificatesHook + + +MODULE_NAME = "test" +SECRET_NAME = "secretName" +SECRET_DATA = { + "ca.crt": "CACRT", + "tls.crt": "TLSCRT", + "tls.key": "TLSKEY" + } + +hook = CopyCustomCertificatesHook(module_name=MODULE_NAME) + +binding_context = [ + { + "binding": "binding", + "snapshots": { + hook.CUSTOM_CERTIFICATES_SNAPSHOT_NAME: [ + { + "filterResult": { + "name": SECRET_NAME, + "data": SECRET_DATA + } + }, + { + "filterResult": { + "name": "test", + "data": {} + } + } + ] + } + } +] + +values_add = { + "global": { + "modules": { + "https": { + "mode": "CustomCertificate", + "customCertificate": { + "secretName": "test" + } + } + } + }, + MODULE_NAME: { + "https": { + "customCertificate": { + "secretName": SECRET_NAME + } + }, + "internal": {} + } +} + + +values_delete = { + "global": { + "modules": { + "https": { + "mode": "CertManager" + } + } + }, + MODULE_NAME: { + "internal": { + "customCertificateData": SECRET_DATA + } + } +} + + +class TestCopyCustomCertificateAdd(testing.TestHook): + def setUp(self): + self.func = hook.reconcile() + self.bindind_context = binding_context + self.values = values_add + def test_copy_custom_certificate_adding(self): + self.hook_run() + self.assertGreater(len(self.values[MODULE_NAME]["internal"].get("customCertificateData", {})), 0) + self.assertEqual(self.values[MODULE_NAME]["internal"]["customCertificateData"], SECRET_DATA) + +class TestCopyCustomCertificateDelete(testing.TestHook): + def setUp(self): + self.func = hook.reconcile() + self.bindind_context = binding_context + self.values = values_delete + def test_copy_custom_certificate_deleting(self): + self.hook_run() + self.assertEqual(len(self.values[MODULE_NAME]["internal"].get("customCertificateData", {})), 0) + + diff --git a/hooks/lib/tests/test_internal_tls_test.py b/hooks/lib/tests/test_internal_tls_test.py new file mode 100644 index 00000000..de842cdd --- /dev/null +++ b/hooks/lib/tests/test_internal_tls_test.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from lib.tests import testing +from lib.hooks.internal_tls import GenerateCertificateHook, default_sans, TlsSecret +from lib.certificate import parse +import lib.utils as utils +from OpenSSL import crypto +from ipaddress import ip_address + +NAME = "test" +MODULE_NAME = NAME +NAMESPACE = NAME +SANS = [ + NAME, + f"{NAME}.{NAMESPACE}", + f"{NAME}.{NAMESPACE}.svc" +] + +hook_generate = GenerateCertificateHook( + TlsSecret( + name=NAME, + sansGenerator=default_sans(SANS), + values_path_prefix=f"{MODULE_NAME}.internal.dvcr.cert"), + module_name=MODULE_NAME, + cn=NAME, + namespace=NAMESPACE) + +hook_regenerate = GenerateCertificateHook( + TlsSecret( + name=NAME, + sansGenerator=default_sans(SANS), + values_path_prefix=f"{MODULE_NAME}.internal.dvcr.cert"), + module_name=MODULE_NAME, + cn=NAME, + namespace=NAMESPACE, + expire=0) + +binding_context = [ + { + "binding": "binding", + "snapshots": {} + } +] + +values = { + "global": { + "modules": { + "publicDomainTemplate": "example.com" + }, + "discovery": { + "clusterDomain": "cluster.local" + } + }, + MODULE_NAME: { + "internal": {} + } +} + +class TestCertificate(testing.TestHook): + secret_data = {} + sans_default = SANS + ["localhost", "127.0.0.1"] + + @staticmethod + def parse_certificate(crt: str) -> crypto.X509: + return parse.parse_certificate(utils.base64_decode(crt)) + + def check_data(self): + self.assertGreater(len(self.values[MODULE_NAME]["internal"].get("dvcr", {}).get("cert", {})), 0) + self.secret_data = self.values[MODULE_NAME]["internal"]["dvcr"]["cert"] + self.assertTrue(utils.is_base64(self.secret_data.get("ca", ""))) + self.assertTrue(utils.is_base64(self.secret_data.get("crt", ""))) + self.assertTrue(utils.is_base64(self.secret_data.get("key", ""))) + + def check_sans(self, crt: crypto.X509) -> bool: + sans_from_cert = parse.get_certificate_san(crt) + sans = [] + for san in self.sans_default: + try: + ip_address(san) + sans.append(f"IP Address:{san}") + except ValueError: + sans.append(f"DNS:{san}") + sans_from_cert.sort() + sans.sort() + self.assertEqual(sans_from_cert, sans) + + def verify_certificate(self, ca: crypto.X509, crt: crypto.X509) -> crypto.X509StoreContextError: + store = crypto.X509Store() + store.add_cert(ca) + ctx = crypto.X509StoreContext(store, crt) + try: + ctx.verify_certificate() + return None + except crypto.X509StoreContextError as e: + return e + +class TestGenerateCertificate(TestCertificate): + def setUp(self): + self.func = hook_generate.reconcile() + self.bindind_context = binding_context + self.values = values + def test_generate_certificate(self): + self.hook_run() + self.check_data() + ca = self.parse_certificate(self.secret_data["ca"]) + crt = self.parse_certificate(self.secret_data["crt"]) + if (e := self.verify_certificate(ca, crt)) is not None: + self.fail(f"Certificate is not verify. Raised an exception: {e} ") + self.check_sans(crt) + +class TestReGenerateCertificate(TestCertificate): + def setUp(self): + self.func = hook_regenerate.reconcile() + self.bindind_context = binding_context + self.values = values + self.hook_run() + self.bindind_context[0]["snapshots"] = { + hook_regenerate.SNAPSHOT_SECRETS_NAME : [ + { + "filterResult": { + "data": { + "ca.crt" : self.values[MODULE_NAME]["internal"]["dvcr"]["cert"]["ca"], + "tls.crt": self.values[MODULE_NAME]["internal"]["dvcr"]["cert"]["crt"], + "key.crt": self.values[MODULE_NAME]["internal"]["dvcr"]["cert"]["key"] + }, + "name": NAME + } + } + ] + } + self.func = hook_generate.reconcile() + + def test_regenerate_certificate(self): + self.check_data() + ca = self.parse_certificate(self.secret_data["ca"]) + crt = self.parse_certificate(self.secret_data["crt"]) + if self.verify_certificate(ca, crt) is None: + self.fail(f"certificate has not expired") + self.hook_run() + self.check_data() + ca = self.parse_certificate(self.secret_data["ca"]) + crt = self.parse_certificate(self.secret_data["crt"]) + if (e := self.verify_certificate(ca, crt)) is not None: + self.fail(f"Certificate is not verify. Raised an exception: {e} ") + self.check_sans(crt) diff --git a/hooks/lib/tests/test_manage_tenant_secrets.py b/hooks/lib/tests/test_manage_tenant_secrets.py new file mode 100644 index 00000000..8e770599 --- /dev/null +++ b/hooks/lib/tests/test_manage_tenant_secrets.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from lib.tests import testing +from lib.hooks.manage_tenant_secrets import ManageTenantSecretsHook + +hook = ManageTenantSecretsHook(source_namespace="source_namespace", + source_secret_name="secret_name", + pod_labels_to_follow={"app": "test"}, + destination_secret_labels={"test":"test"}, + module_name="test") + +binding_context = [ + { + "binding": "binding", + "snapshots": { + hook.POD_SNAPSHOT_NAME: [ + { + "filterResult": { + "namespace": "pod-namespace1" ## Create secret + } + }, + { + "filterResult": { + "namespace": "pod-namespace2" ## Don't create secret, because ns has deletionTimestamp + } + } + ], + hook.SECRETS_SNAPSHOT_NAME: [ + { + "filterResult": { + "data": {"test": "test"}, + "namespace": "source_namespace", + "type": "Opaque" + } + }, + { + "filterResult": { + "data": {"test": "test"}, + "namespace": "pod-namespace3", ## Delete secret, because namespace pod-namespace3 hasn't pods + "type": "Opaque" + } + }, + ], + hook.NAMESPACE_SNAPSHOT_NAME: [ + { + "filterResult": { + "name": "source_namespace", + "isTerminating": False + } + }, + { + "filterResult": { + "name": "pod-namespace1", + "isTerminating": False + } + }, + { + "filterResult": { + "name": "pod-namespace2", + "isTerminating": True + } + }, + { + "filterResult": { + "name": "pod-namespace3", + "isTerminating": False + } + }, + ] + } + } +] + +class TestManageSecrets(testing.TestHook): + def setUp(self): + self.func = hook.reconcile() + self.bindind_context = binding_context + self.values = {} + def test_manage_secrets(self): + self.hook_run() + self.assertEqual(len(self.kube_resources), 1) + self.assertEqual(self.kube_resources[0]["kind"], "Secret") + self.assertEqual(self.kube_resources[0]["metadata"]["name"], "secret_name") + self.assertEqual(self.kube_resources[0]["metadata"]["namespace"], "pod-namespace1") + self.assertEqual(self.kube_resources[0]["type"], "Opaque") + self.assertEqual(self.kube_resources[0]["data"], {'test': 'test'}) + self.assertEqual(self.kube_resources[0]["metadata"]["labels"], {'test': 'test'}) + diff --git a/hooks/lib/tests/testing.py b/hooks/lib/tests/testing.py new file mode 100644 index 00000000..f0257aa2 --- /dev/null +++ b/hooks/lib/tests/testing.py @@ -0,0 +1,57 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from deckhouse import hook +import unittest +import jsonpatch +import kubernetes_validate +import jsonschema + +class TestHook(unittest.TestCase): + kube_resources = [] + kube_version = "1.28" + def setUp(self): + self.bindind_context = [] + self.values = {} + self.func = None + + def tearDown(self): + pass + + def hook_run(self, validate_kube_resources: bool = True) -> None: + out = hook.testrun(func=self.func, + binding_context=self.bindind_context, + initial_values=self.values) + for patch in out.values_patches.data: + self.values = jsonpatch.apply_patch(self.values, [patch]) + + deletes = ("Delete", "DeleteInBackground", "DeleteNonCascading") + for kube_operation in out.kube_operations.data: + if kube_operation["operation"] in deletes: + continue + obj = kube_operation["object"] + if validate_kube_resources: + try: + ## TODO Validate CRD + kubernetes_validate.validate(obj, self.kube_version, strict=True) + self.kube_resources.append(obj) + except (kubernetes_validate.SchemaNotFoundError, + kubernetes_validate.InvalidSchemaError, + kubernetes_validate.ValidationError, + jsonschema.RefResolutionError) as e: + self.fail(f"Object is not valid. Raised an exception: {e} ") + else: + self.kube_resources.append(obj) + diff --git a/hooks/lib/utils.py b/hooks/lib/utils.py new file mode 100644 index 00000000..a486efd5 --- /dev/null +++ b/hooks/lib/utils.py @@ -0,0 +1,54 @@ +# +# Copyright 2023 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import os +import json + +def base64_encode(b: bytes) -> str: + return str(base64.b64encode(b), encoding='utf-8') + +def base64_decode(s: str) -> str: + return str(base64.b64decode(s), encoding="utf-8") + +def base64_encode_from_str(s: str) -> str: + return base64_encode(bytes(s, 'utf-8')) + +def json_load(path: str): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data + +def get_dir_path() -> str: + return os.path.dirname(os.path.abspath(__file__)) + +def is_base64(s): + try: + base64_decode(s) + return True + except base64.binascii.Error: + return False + +def check_elem_in_list(l: list, elem) -> bool: + for i in l: + if i == elem: + return True + return False + +def find_index_in_list(l: list, elem) -> int: + for i in range(len(l)): + if l[i] == elem: + return i + return \ No newline at end of file diff --git a/images/webhooks/src/go.mod b/images/webhooks/src/go.mod new file mode 100644 index 00000000..4e3cc2ab --- /dev/null +++ b/images/webhooks/src/go.mod @@ -0,0 +1,50 @@ +module webhooks + +go 1.21.1 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/slok/kubewebhook/v2 v2.5.0 + k8s.io/api v0.29.0 + k8s.io/apimachinery v0.29.0 + k8s.io/client-go v0.29.0 + k8s.io/klog/v2 v2.110.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/oauth2 v0.10.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v3 v3.0.1 // indirect + gomodules.xyz/orderedmap v0.1.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/images/webhooks/src/go.sum b/images/webhooks/src/go.sum new file mode 100644 index 00000000..a26595df --- /dev/null +++ b/images/webhooks/src/go.sum @@ -0,0 +1,172 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slok/kubewebhook/v2 v2.5.0 h1:CwMxLbTEcha3+SxSXc4pc9iIbREdhgLurAs+/uRzxIw= +github.com/slok/kubewebhook/v2 v2.5.0/go.mod h1:TcQS+Ae0TDiiwm9glxum6AFvtumR33qdAenUeiQ/TWs= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= +golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v3 v3.0.1 h1:Te7hKxV52TKCbNYq3t84tzKav3xhThdvSsSp/W89IyI= +gomodules.xyz/jsonpatch/v3 v3.0.1/go.mod h1:CBhndykehEwTOlEfnsfJwvkFQbSN8YZFr9M+cIHAJto= +gomodules.xyz/orderedmap v0.1.0 h1:fM/+TGh/O1KkqGR5xjTKg6bU8OKBkg7p0Y+x/J9m8Os= +gomodules.xyz/orderedmap v0.1.0/go.mod h1:g9/TPUCm1t2gwD3j3zfV8uylyYhVdCNSi+xCEIu7yTU= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= +k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= +k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o= +k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis= +k8s.io/client-go v0.29.0 h1:KmlDtFcrdUzOYrBhXHgKw5ycWzc3ryPX5mQe0SkG3y8= +k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/images/webhooks/src/main.go b/images/webhooks/src/main.go new file mode 100644 index 00000000..ff616a2b --- /dev/null +++ b/images/webhooks/src/main.go @@ -0,0 +1,95 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "flag" + "fmt" + "github.com/sirupsen/logrus" + kwhhttp "github.com/slok/kubewebhook/v2/pkg/http" + kwhlogrus "github.com/slok/kubewebhook/v2/pkg/log/logrus" + kwhmutating "github.com/slok/kubewebhook/v2/pkg/webhook/mutating" + corev1 "k8s.io/api/core/v1" + "net/http" + "os" + "webhooks/validators" +) + +type config struct { + certFile string + keyFile string +} + +//goland:noinspection SpellCheckingInspection +func httpHandlerHealthz(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Ok.") +} + +func initFlags() config { + cfg := config{} + + fl := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + fl.StringVar(&cfg.certFile, "tls-cert-file", "", "TLS certificate file") + fl.StringVar(&cfg.keyFile, "tls-key-file", "", "TLS key file") + + fl.Parse(os.Args[1:]) + return cfg +} + +const ( + port = ":8443" +) + +func main() { + logrusLogEntry := logrus.NewEntry(logrus.New()) + logrusLogEntry.Logger.SetLevel(logrus.DebugLevel) + logger := kwhlogrus.NewLogrus(logrusLogEntry) + + cfg := initFlags() + + mt := kwhmutating.MutatorFunc(validators.PodSchedulerMutation) + + mcfg := kwhmutating.WebhookConfig{ + ID: "PodSchedulerMutation", + Obj: &corev1.Pod{}, + Mutator: mt, + Logger: logger, + } + wh, err := kwhmutating.NewWebhook(mcfg) + if err != nil { + fmt.Fprintf(os.Stderr, "error creating webhook: %s", err) + os.Exit(1) + } + + // Get the handler for our webhook. + whHandler, err := kwhhttp.HandlerFor(kwhhttp.HandlerConfig{Webhook: wh, Logger: logger}) + if err != nil { + fmt.Fprintf(os.Stderr, "error creating webhook handler: %s", err) + os.Exit(1) + } + + mux := http.NewServeMux() + mux.Handle("/pod-scheduler-mutation", whHandler) + mux.HandleFunc("/healthz", httpHandlerHealthz) + + logger.Infof("Listening on %s", port) + err = http.ListenAndServeTLS(port, cfg.certFile, cfg.keyFile, mux) + if err != nil { + fmt.Fprintf(os.Stderr, "error serving webhook: %s", err) + os.Exit(1) + } +} diff --git a/images/webhooks/src/validators/podSchedulerMutation.go b/images/webhooks/src/validators/podSchedulerMutation.go new file mode 100644 index 00000000..60216f12 --- /dev/null +++ b/images/webhooks/src/validators/podSchedulerMutation.go @@ -0,0 +1,94 @@ +package validators + +import ( + "context" + "github.com/slok/kubewebhook/v2/pkg/model" + kwhmutating "github.com/slok/kubewebhook/v2/pkg/webhook/mutating" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const ( + annBetaStorageProvisioner = "volume.beta.kubernetes.io/storage-provisioner" + annStorageProvisioner = "volume.kubernetes.io/storage-provisioner" + csiEndpoint = "lvm.csi.storage.deckhouse.io" + schedulerName = "sds-lvm" +) + +func PodSchedulerMutation(ctx context.Context, _ *model.AdmissionReview, obj metav1.Object) (*kwhmutating.MutatorResult, error) { + pod, ok := obj.(*corev1.Pod) + if !ok { + // If not a pod just continue the mutation chain(if there is one) and do nothing. + return &kwhmutating.MutatorResult{}, nil + } + + if pod.Spec.SchedulerName == "" || pod.Spec.SchedulerName == "default-scheduler" { + config, err := rest.InClusterConfig() + if err != nil { + return &kwhmutating.MutatorResult{}, err + } + + staticClient, err := kubernetes.NewForConfig(config) + if err != nil { + return &kwhmutating.MutatorResult{}, err + } + + for _, currentVolume := range pod.Spec.Volumes { + var discoveredProvisioner string + + if currentVolume.PersistentVolumeClaim == nil { + continue + } + + pvc, err := staticClient.CoreV1().PersistentVolumeClaims(pod.Namespace).Get(ctx, currentVolume.PersistentVolumeClaim.ClaimName, metav1.GetOptions{}) + if err != nil { + return &kwhmutating.MutatorResult{}, err + } + + // Try to gather provisioner name from annotations + if pvc != nil { + if provisioner, ok := pvc.Annotations[annStorageProvisioner]; ok { + discoveredProvisioner = provisioner + } + if provisioner, ok := pvc.Annotations[annBetaStorageProvisioner]; ok { + discoveredProvisioner = provisioner + } + } + // Try to gather provisioner name from associated StorageClass + if discoveredProvisioner == "" && pvc.Spec.StorageClassName != nil && *pvc.Spec.StorageClassName != "" { + sc, err := staticClient.StorageV1().StorageClasses().Get(ctx, *pvc.Spec.StorageClassName, metav1.GetOptions{}) + if err != nil { + return &kwhmutating.MutatorResult{}, err + } + + if sc != nil && sc.Provisioner == csiEndpoint { + discoveredProvisioner = sc.Provisioner + } + } + // Try to gather provisioner name from associated PV + if discoveredProvisioner == "" && pvc.Spec.VolumeName != "" { + pv, err := staticClient.CoreV1().PersistentVolumes().Get(ctx, pvc.Spec.VolumeName, metav1.GetOptions{}) + if err != nil { + return &kwhmutating.MutatorResult{}, err + } + if pv != nil && pv.Spec.CSI != nil { + discoveredProvisioner = pv.Spec.CSI.Driver + } + } + + // Overwrite the scheduler name + if discoveredProvisioner == csiEndpoint { + pod.Spec.SchedulerName = schedulerName + return &kwhmutating.MutatorResult{ + MutatedObject: pod, + }, nil + } + } + } + + return &kwhmutating.MutatorResult{ + MutatedObject: pod, + }, nil +} diff --git a/images/webhooks/werf.inc.yaml b/images/webhooks/werf.inc.yaml new file mode 100644 index 00000000..025d2f4c --- /dev/null +++ b/images/webhooks/werf.inc.yaml @@ -0,0 +1,16 @@ +--- +image: webhooks +from: "registry.deckhouse.io/base_images/golang:1.21.4-alpine3.18@sha256:cf84f3d6882c49ea04b6478ac514a2582c8922d7e5848b43d2918fff8329f6e6" + +git: + - add: /images/webhooks/src + to: /src + stageDependencies: + setup: + - "**/*" + +shell: + setup: + - cd /src + - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o webhooks + - mv webhooks /webhooks \ No newline at end of file diff --git a/openapi/values.yaml b/openapi/values.yaml index 23228511..d1d8cb40 100644 --- a/openapi/values.yaml +++ b/openapi/values.yaml @@ -11,6 +11,23 @@ properties: default: [] items: type: string + customWebhookCert: + type: object + default: {} + x-required-for-helm: + - crt + - key + - ca + properties: + crt: + type: string + x-examples: ["YjY0ZW5jX3N0cmluZwo="] + key: + type: string + x-examples: ["YjY0ZW5jX3N0cmluZwo="] + ca: + type: string + x-examples: ["YjY0ZW5jX3N0cmluZwo="] registry: type: object description: "System field, overwritten by Deckhouse. Don't use" diff --git a/templates/webhooks/deployment.yaml b/templates/webhooks/deployment.yaml new file mode 100644 index 00000000..83bcd09f --- /dev/null +++ b/templates/webhooks/deployment.yaml @@ -0,0 +1,96 @@ +{{- define "webhooks_resources" }} +cpu: 10m +memory: 50Mi +{{- end }} + +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +--- +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: webhooks + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "webhooks" "workload-resource-policy.deckhouse.io" "master")) | nindent 2 }} +spec: + targetRef: + apiVersion: "apps/v1" + kind: Deployment + name: webhooks + updatePolicy: + updateMode: "Auto" + resourcePolicy: + containerPolicies: + - containerName: webhooks + minAllowed: + {{- include "webhooks_resources" . | nindent 8 }} + maxAllowed: + cpu: 20m + memory: 100Mi +{{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webhooks + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "webhooks" )) | nindent 2 }} +spec: + {{- include "helm_lib_deployment_on_master_strategy_and_replicas_for_ha" . | nindent 2 }} + selector: + matchLabels: + app: webhooks + template: + metadata: + labels: + app: webhooks + spec: + {{- include "helm_lib_priority_class" (tuple . "system-cluster-critical") | nindent 6 }} + {{- include "helm_lib_tolerations" (tuple . "any-node" "with-uninitialized" "with-cloud-provider-uninitialized") | nindent 6 }} + {{- include "helm_lib_node_selector" (tuple . "master") | nindent 6 }} + {{- include "helm_lib_pod_anti_affinity_for_ha" (list . (dict "app" "webhooks")) | nindent 6 }} + containers: + - name: webhooks + command: + - /webhooks + - -tls-cert-file=/etc/webhook/certs/tls.crt + - -tls-key-file=/etc/webhook/certs/tls.key + image: {{ include "helm_lib_module_image" (list . "webhooks") }} + imagePullPolicy: IfNotPresent + volumeMounts: + - name: webhook-certs + mountPath: /etc/webhook/certs + readOnly: true + readinessProbe: + httpGet: + path: /healthz + port: 8443 + scheme: HTTPS + initialDelaySeconds: 5 + failureThreshold: 2 + periodSeconds: 1 + livenessProbe: + httpGet: + path: /healthz + port: 8443 + scheme: HTTPS + periodSeconds: 1 + failureThreshold: 3 + ports: + - name: http + containerPort: 8443 + protocol: TCP + resources: + requests: + {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 12 }} +{{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- include "webhooks_resources" . | nindent 12 }} +{{- end }} + + imagePullSecrets: + - name: {{ .Chart.Name }}-module-registry + serviceAccount: webhooks + serviceAccountName: webhooks + volumes: + - name: webhook-certs + secret: + secretName: webhooks-https-certs \ No newline at end of file diff --git a/templates/webhooks/rbac-for-us.yaml b/templates/webhooks/rbac-for-us.yaml new file mode 100644 index 00000000..04816e9b --- /dev/null +++ b/templates/webhooks/rbac-for-us.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: webhooks + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "webhooks")) | nindent 2 }} +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: d8:{{ .Chart.Name }}:webhooks + {{- include "helm_lib_module_labels" (list . (dict "app" "webhooks")) | nindent 2 }} +rules: + - verbs: + - get + - list + - watch + apiGroups: + - storage.deckhouse.io + resources: + - drbdstorageclasses + - verbs: + - get + - list + apiGroups: + - "" + resources: + - nodes + - verbs: + - get + apiGroups: + - "" + resources: + - persistentvolumeclaims +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: d8:{{ .Chart.Name }}:webhooks + {{- include "helm_lib_module_labels" (list . (dict "app" "webhooks")) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:{{ .Chart.Name }}:webhooks +subjects: + - kind: ServiceAccount + name: webhooks + namespace: d8-{{ .Chart.Name }} diff --git a/templates/webhooks/secret.yaml b/templates/webhooks/secret.yaml new file mode 100644 index 00000000..3ee40bc9 --- /dev/null +++ b/templates/webhooks/secret.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: webhooks-https-certs + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "webhooks")) | nindent 2 }} +type: kubernetes.io/tls +data: + ca.crt: {{ .Values.sdsLvm.internal.customWebhookCert.ca }} + tls.crt: {{ .Values.sdsLvm.internal.customWebhookCert.crt }} + tls.key: {{ .Values.sdsLvm.internal.customWebhookCert.key }} diff --git a/templates/webhooks/service.yaml b/templates/webhooks/service.yaml new file mode 100644 index 00000000..dc01ddbf --- /dev/null +++ b/templates/webhooks/service.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: webhooks + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "webhooks" )) | nindent 2 }} +spec: + type: ClusterIP + ports: + - port: 443 + targetPort: 8443 + protocol: TCP + name: http + selector: + app: webhooks \ No newline at end of file diff --git a/templates/webhooks/webhook.yaml b/templates/webhooks/webhook.yaml new file mode 100644 index 00000000..fb4e5cb5 --- /dev/null +++ b/templates/webhooks/webhook.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: "d8-{{ .Chart.Name }}-pod-scheduler-mutation" +webhooks: + - name: "d8-{{ .Chart.Name }}-pod-scheduler-mutation.storage.deckhouse.io" + failurePolicy: Ignore + namespaceSelector: + matchExpressions: + - key: heritage + operator: NotIn + values: + - deckhouse + rules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["pods"] + scope: "Namespaced" + clientConfig: + service: + namespace: "d8-{{ .Chart.Name }}" + name: "webhooks" + path: "/pod-scheduler-mutation" + caBundle: | + {{ .Values.sdsLvm.internal.customWebhookCert.ca }} + + admissionReviewVersions: ["v1", "v1beta1"] + sideEffects: None + timeoutSeconds: 5