From 19790176ba960701fcaa949950dab2556ed4783e Mon Sep 17 00:00:00 2001 From: xeruf <27jf@pm.me> Date: Wed, 6 Jul 2022 11:24:59 +0100 Subject: [PATCH] suitecrm: add db secrets --- basic/apps/people/suitecrm-release.yaml | 2 +- .../people/suitecrm-values-configmap.yaml | 4 + generate_secrets.py | 244 ++++++++++++++++++ .../stackspin-suitecrm-variables.yaml.jinja | 8 + 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 generate_secrets.py create mode 100644 templates/stackspin-suitecrm-variables.yaml.jinja diff --git a/basic/apps/people/suitecrm-release.yaml b/basic/apps/people/suitecrm-release.yaml index 7de7a0f..af723c3 100644 --- a/basic/apps/people/suitecrm-release.yaml +++ b/basic/apps/people/suitecrm-release.yaml @@ -8,7 +8,7 @@ spec: chart: spec: chart: suitecrm - version: 11.1.9 + version: 11.1.10 sourceRef: kind: HelmRepository name: bitnami diff --git a/basic/apps/people/suitecrm-values-configmap.yaml b/basic/apps/people/suitecrm-values-configmap.yaml index e43372e..1ee7fcf 100644 --- a/basic/apps/people/suitecrm-values-configmap.yaml +++ b/basic/apps/people/suitecrm-values-configmap.yaml @@ -16,6 +16,10 @@ data: suitecrmUsername: "admin" suitecrmEmail: "${admin_email}" existingSecret: stackspin-suitecrm-variables + mariadb: + auth: + password: "${mariadb-password}" + rootPassword: "${mariadb-root-password}" # TODO Adjust OIDC SSO to service # - name: Stackspin # key: "${client_id}" diff --git a/generate_secrets.py b/generate_secrets.py new file mode 100644 index 0000000..e1d7f52 --- /dev/null +++ b/generate_secrets.py @@ -0,0 +1,244 @@ +"""Generates Kubernetes secrets based on a provided app name. + +If the `templates` directory contains a secret called `stackspin-{app}-variables`, it +will check if that secret already exists in the cluster, and if not: generate +it. It does the same for an `stackspin-{app}-basic-auth` secret that will contain a +password as well as a htpasswd encoded version of it. + +See https://open.greenhost.net/stackspin/stackspin/-/issues/891 for the +context why we use this script and not a helm chart to generate secrets. + +usage: `python generate_secrets.py $appName` + +As a special case, `python generate_secrets.py stackspin` will check that the +`stackspin-cluster-variables` secret exists and that its values do not contain +problematic characters. +""" + +import base64 +import crypt +import os +import secrets +import string +import sys + +import jinja2 +import yaml +from kubernetes import client, config +from kubernetes.client import api_client +from kubernetes.client.exceptions import ApiException +from kubernetes.utils import create_from_yaml +from kubernetes.utils.create_from_yaml import FailToCreateError + +# This script gets called with an app name as argument. Most of them need an +# oauth client in Hydra, but some don't. This list contains the ones that +# don't. +APPS_WITHOUT_OAUTH = [ + "single-sign-on", + "prometheus", + "alertmanager", +] + + +def main(): + """Run everything.""" + # Add jinja filters we want to use + env = jinja2.Environment( + extensions=["jinja2_base64_filters.Base64Filters"]) + env.filters["generate_password"] = generate_password + + if len(sys.argv) < 2: + print("Please provide an app name as an argument") + sys.exit(1) + app_name = sys.argv[1] + + if app_name == "stackspin": + # This is a special case: we don't generate new secrets, but verify the + # validity of the cluster variables (populated from .flux.env). + verify_cluster_variables() + else: + # Create app variables secret + create_variables_secret( + app_name, f"stackspin-{app_name}-variables.yaml.jinja", env) + # Create a secret that contains the oauth variables for Hydra Maester + if app_name not in APPS_WITHOUT_OAUTH: + create_variables_secret( + app_name, "stackspin-oauth-variables.yaml.jinja", env) + create_basic_auth_secret(app_name, env) + + +def verify_cluster_variables(): + data = get_kubernetes_secret_data("stackspin-cluster-variables", "flux-system") + if data is None: + raise Exception("Secret stackspin-cluster-variables was not found.") + message = "In secret stackspin-cluster-variables, key {}, the character {}" \ + " was used which will probably lead to problems, so aborting." \ + " You can update the value by using `kubectl edit secret -n" \ + " flux-system stackspin-cluster-variables`." + for key, value in data.items(): + decoded_value = base64.b64decode(value).decode("ascii") + for character in ["\"", "$"]: + if character in decoded_value: + raise Exception(message.format(key, character)) + + +def get_templates_dir(): + """Returns directory that contains the Jinja templates used to create app secrets.""" + return os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") + + +def create_variables_secret(app_name, variables_filename, env): + """Checks if a variables secret for app_name already exists, generates it if necessary.""" + variables_filepath = os.path.join(get_templates_dir(), variables_filename) + if os.path.exists(variables_filepath): + # Check if k8s secret already exists, if not, generate it + with open(variables_filepath, encoding="UTF-8") as template_file: + lines = template_file.read() + secret_name, secret_namespace = get_secret_metadata(lines) + new_secret_dict = yaml.safe_load( + env.from_string(lines, globals={"app": app_name}).render() + ) + current_secret_data = get_kubernetes_secret_data( + secret_name, secret_namespace + ) + if current_secret_data is None: + # Create new secret + update_secret = False + elif current_secret_data.keys() != new_secret_dict["data"].keys(): + # Update current secret with new keys + update_secret = True + print( + f"Secret {secret_name} in namespace {secret_namespace}" + " already exists. Merging..." + ) + # Merge dicts. Values from current_secret_data take precedence + new_secret_dict["data"] |= current_secret_data + else: + # Do Nothing + print( + f"Secret {secret_name} in namespace {secret_namespace}" + " is already in a good state, doing nothing." + ) + return + print( + f"Storing secret {secret_name} in namespace" + f" {secret_namespace} in cluster." + ) + store_kubernetes_secret( + new_secret_dict, secret_namespace, update=update_secret + ) + else: + print( + f"Template {variables_filename} does not exist, no action needed") + + +def create_basic_auth_secret(app_name, env): + """Checks if a basic auth secret for app_name already exists, generates it if necessary.""" + basic_auth_filename = os.path.join( + get_templates_dir(), f"stackspin-{app_name}-basic-auth.yaml.jinja" + ) + if os.path.exists(basic_auth_filename): + with open(basic_auth_filename, encoding="UTF-8") as template_file: + lines = template_file.read() + secret_name, secret_namespace = get_secret_metadata(lines) + + if get_kubernetes_secret_data(secret_name, secret_namespace) is None: + basic_auth_username = "admin" + basic_auth_password = generate_password(32) + basic_auth_htpasswd = gen_htpasswd( + basic_auth_username, basic_auth_password + ) + print( + f"Adding secret {secret_name} in namespace" + f" {secret_namespace} to cluster." + ) + template = env.from_string( + lines, + globals={ + "pass": basic_auth_password, + "htpasswd": basic_auth_htpasswd, + }, + ) + secret_dict = yaml.safe_load(template.render()) + store_kubernetes_secret(secret_dict, secret_namespace) + else: + print( + f"Secret {secret_name} in namespace {secret_namespace}" + " already exists. Not generating new secrets." + ) + else: + print(f"File {basic_auth_filename} does not exist, no action needed") + + +def get_secret_metadata(yaml_string): + """Returns secret name and namespace from metadata field in a yaml string.""" + secret_dict = yaml.safe_load(yaml_string) + secret_name = secret_dict["metadata"]["name"] + # default namespace is flux-system, but other namespace can be + # provided in secret metadata + if "namespace" in secret_dict["metadata"]: + secret_namespace = secret_dict["metadata"]["namespace"] + else: + secret_namespace = "flux-system" + return secret_name, secret_namespace + + +def get_kubernetes_secret_data(secret_name, namespace): + """Returns the contents of a kubernetes secret or None if the secret does not exist.""" + try: + secret = API.read_namespaced_secret(secret_name, namespace).data + except ApiException as ex: + # 404 is expected when the optional secret does not exist. + if ex.status != 404: + raise ex + return None + return secret + + +def store_kubernetes_secret(secret_dict, namespace, update=False): + """Stores either a new secret in the cluster, or updates an existing one.""" + api_client_instance = api_client.ApiClient() + if update: + verb = "updated" + api_response = patch_kubernetes_secret(secret_dict, namespace) + else: + verb = "created" + try: + api_response = create_from_yaml( + api_client_instance, + yaml_objects=[secret_dict], + namespace=namespace + ) + except FailToCreateError as ex: + print(f"Secret not {verb} because of exception {ex}") + return + print(f"Secret {verb} with api response: {api_response}") + + +def patch_kubernetes_secret(secret_dict, namespace): + """Patches secret in the cluster with new data.""" + api_client_instance = api_client.ApiClient() + api_instance = client.CoreV1Api(api_client_instance) + name = secret_dict["metadata"]["name"] + body = {} + body["data"] = secret_dict["data"] + return api_instance.patch_namespaced_secret(name, namespace, body) + + +def generate_password(length): + """Generates a password of "length" characters.""" + length = int(length) + password = "".join((secrets.choice(string.ascii_letters) + for i in range(length))) + return password + + +def gen_htpasswd(user, password): + """Generate htpasswd entry for user with password.""" + return f"{user}:{crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))}" + + +if __name__ == "__main__": + config.load_kube_config() + API = client.CoreV1Api() + main() diff --git a/templates/stackspin-suitecrm-variables.yaml.jinja b/templates/stackspin-suitecrm-variables.yaml.jinja new file mode 100644 index 0000000..45004f7 --- /dev/null +++ b/templates/stackspin-suitecrm-variables.yaml.jinja @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: stackspin-suitecrm-variables +data: + suitecrm-password: "{{ 32 | generate_password | b64encode }}" + mariadb-password: "{{ 32 | generate_password | b64encode }}" + mariadb-root-password: "{{ 32 | generate_password | b64encode }}"