From 4c57f92c8a87c4b9270a464121743b864635c291 Mon Sep 17 00:00:00 2001 From: Maarten de Waard Date: Fri, 23 Sep 2022 17:10:31 +0200 Subject: [PATCH] Process feedback; make it possible to install monitoring --- areas/apps/apps.py | 7 ---- areas/apps/models.py | 82 +++++++++++++++++++------------------------ cliapp/cliapp/cli.py | 25 +++++++++++-- helpers/kubernetes.py | 29 ++++++++++++--- 4 files changed, 84 insertions(+), 59 deletions(-) diff --git a/areas/apps/apps.py b/areas/apps/apps.py index 1be83ae..fe9f30a 100644 --- a/areas/apps/apps.py +++ b/areas/apps/apps.py @@ -24,13 +24,6 @@ APPS_DATA = [ APP_DATA = {"id": 1, "name": "Nextcloud", "selected": True, "status": "ON for everyone", "config": CONFIG_DATA}, -# Apps that should not get oauth variables when they are installed -APPS_WITHOUT_OAUTH = [ - "single-sign-on", - "prometheus", - "alertmanager", -] - APP_NOT_INSTALLED_STATUS = "Not installed" @api_v1.route('/apps', methods=['GET']) diff --git a/areas/apps/models.py b/areas/apps/models.py index 23a6249..f28971e 100644 --- a/areas/apps/models.py +++ b/areas/apps/models.py @@ -6,7 +6,7 @@ from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import relationship from database import db import helpers.kubernetes as k8s -from .apps import APPS_WITHOUT_OAUTH, APP_NOT_INSTALLED_STATUS +from .apps import APP_NOT_INSTALLED_STATUS class App(db.Model): @@ -22,32 +22,21 @@ class App(db.Model): def __repr__(self): return f"{self.id} <{self.name}>" - def get_kustomization_status(self): - """Returns True if the kustomization for this App is ready""" - kustomization = k8s.get_kustomization(self.slug) - if kustomization is None: - return None - return kustomization['status'] - - def get_helmrelease_status(self): - """Returns True if the kustomization for this App is ready""" - helmrelease = k8s.get_helmrelease(self.slug, self.namespace) - if helmrelease is None: - return None - return helmrelease['status'] - def get_status(self): """Returns a string that describes the app state in the cluster""" - ks_status = self.get_kustomization_status() - if ks_status is not None: - ks_ready, ks_message = App.check_condition(ks_status) + kustomization = self.kustomization + if kustomization is not None and "status" in kustomization: + ks_ready, ks_message = App.check_condition(kustomization['status']) else: ks_ready = None - hr_status = self.get_helmrelease_status() - if hr_status is not None: + for helmrelease in self.helmreleases['items']: + hr_status = helmrelease['status'] hr_ready, hr_message = App.check_condition(hr_status) - else: - hr_ready = None + + # For now, only show the message of the first HR that isn't ready + if not hr_ready: + break + if ks_ready is None: return APP_NOT_INSTALLED_STATUS # *Should* not happen, but just in case: @@ -71,6 +60,15 @@ class App(db.Model): # Create add- kustomization self.__create_kustomization() + def uninstall(self): + """ + Delete the app kustomization. + + This triggers a deletion of the app's PVCs (so deletes all data), as + well as any other Kustomizations and HelmReleases related to the app + """ + self.__delete_kustomization() + def delete(self): """ Fully deletes an application @@ -80,34 +78,16 @@ class App(db.Model): """ # Delete all roles first for role in self.roles: - db.session.delete(role) + role.delete() - # Delete the kustomization - if self.__delete_kustomization(): - - # TODO: This is where we might want to poll for status changes in the - # app, so that only once the kustomization and all its stuff (other ks, - # helmrelease, etc.) is deleted, we continue - - # If the kustomization delete went well, commit DB changes. - db.session.commit() - # Then delete the app - db.session.delete(self) - db.session.commit() - return True - return False + db.session.delete(self) + return db.session.commit() def __generate_secrets(self): """Generates passwords for app installation""" # Create app variables secret if self.variables_template_filepath: k8s.create_variables_secret(self.slug, self.variables_template_filepath) - # Create a secret that contains the oauth variables for Hydra Maester - if self.slug not in APPS_WITHOUT_OAUTH: - k8s.create_variables_secret( - self.slug, - os.path.join(self.__get_templates_dir(), - "stackspin-oauth-variables.yaml.jinja")) def __create_kustomization(self): """Creates the `add-{app_slug}` kustomization in the Kubernetes cluster""" @@ -150,6 +130,18 @@ class App(db.Model): app_id=self.id ).all() + @property + def kustomization(self): + """Returns the kustomization object for this app""" + return k8s.get_kustomization(self.slug) + + + @property + def helmreleases(self): + """Returns the helmreleases associated with the kustomization for this app""" + return k8s.list_helmreleases(self.namespace, + f"kustomize.toolkit.fluxcd.io/name={self.slug}") + @staticmethod def __get_templates_dir(): """Returns directory that contains the Jinja templates used to create app secrets.""" @@ -161,7 +153,7 @@ class App(db.Model): Returns a tuple that has true/false for readiness and a message Ready, in this case means that the condition's type == "Ready" and its - status == "True". If the condition type "Ready" does not exist, the + status == "True". If the condition type "Ready" does not occur, the status is interpreted as not ready. The message that is returned is the message that comes with the @@ -173,7 +165,7 @@ class App(db.Model): for condition in status["conditions"]: if condition["type"] == "Ready": return condition["status"] == "True", condition["message"] - return False + return False, "Condition with type 'Ready' not found" class AppRole(db.Model): # pylint: disable=too-few-public-methods diff --git a/cliapp/cliapp/cli.py b/cliapp/cliapp/cli.py index cf6fbc5..a538936 100644 --- a/cliapp/cliapp/cli.py +++ b/cliapp/cliapp/cli.py @@ -13,7 +13,7 @@ from flask.cli import AppGroup from ory_kratos_client.api import v0alpha2_api as kratos_api from sqlalchemy import func -from config import HYDRA_ADMIN_URL,KRATOS_ADMIN_URL,KRATOS_PUBLIC_URL +from config import HYDRA_ADMIN_URL, KRATOS_ADMIN_URL, KRATOS_PUBLIC_URL from helpers import KratosUser from cliapp import cli from areas.roles import Role @@ -88,7 +88,7 @@ def list_app(): ) @click.argument("slug") def delete_app(slug): - """Removes app from database as well as uninstalls it from the cluster + """Removes app from database :param slug: str Slug of app to remove """ current_app.logger.info(f"Trying to delete app: {slug}") @@ -102,6 +102,25 @@ def delete_app(slug): current_app.logger.info(f"Success: {deleted}") return +@app_cli.command( + "uninstall", +) +@click.argument("slug") +def uninstall_app(slug): + """Uninstalls the app from the cluster + :param slug: str Slug of app to remove + """ + current_app.logger.info(f"Trying to delete app: {slug}") + app_obj = App.query.filter_by(slug=slug).first() + + if not app_obj: + current_app.logger.info("Not found") + return + + uninstalled = app_obj.uninstall() + current_app.logger.info(f"Success: {uninstalled}") + return + @app_cli.command("status") @click.argument("slug") def status_app(slug): @@ -116,7 +135,7 @@ def status_app(slug): current_app.logger.error(f"App {slug} does not exist") return - current_app.logger.info("Status: " + str(app.get_status())) + current_app.logger.info(f"Status: {app.get_status()}") @app_cli.command("install") @click.argument("slug") diff --git a/helpers/kubernetes.py b/helpers/kubernetes.py index 3169c63..9d843e2 100644 --- a/helpers/kubernetes.py +++ b/helpers/kubernetes.py @@ -110,8 +110,7 @@ def store_kustomization(kustomization_template_filepath, app_slug): """Add a kustomization that installs app {app_slug} to the cluster""" kustomization_dict = read_template_to_dict(kustomization_template_filepath, {"app": app_slug}) - api_client_instance = api_client.ApiClient() - custom_objects_api = client.CustomObjectsApi(api_client_instance) + custom_objects_api = client.CustomObjectsApi() try: api_response = custom_objects_api.create_namespaced_custom_object( group="kustomize.toolkit.fluxcd.io", @@ -128,8 +127,7 @@ def delete_kustomization(kustomization_name): """Deletes kustomization for an app_slug. Should also result in the deletion of the app's HelmReleases, PVCs, OAuth2Client, etc. Nothing will remain""" - api_client_instance = api_client.ApiClient() - custom_objects_api = client.CustomObjectsApi(api_client_instance) + custom_objects_api = client.CustomObjectsApi() body = client.V1DeleteOptions() try: api_response = custom_objects_api.delete_namespaced_custom_object( @@ -143,6 +141,7 @@ def delete_kustomization(kustomization_name): print(f"Could not delete {kustomization_name} Kustomization because of exception {ex}") return False print(f"Kustomization deleted with api response: {api_response}") + return api_response def read_template_to_dict(template_filepath, template_globals): @@ -292,6 +291,28 @@ def get_helmrelease(name, namespace='stackspin-apps'): return resource +def list_helmreleases(namespace='stackspin-apps', label_selector=""): + """ + Lists all helmreleases in a certain namespace (stackspin-apps by default) + + Optionally takes a label selector to limit the list. + """ + api_instance = client.CustomObjectsApi() + + try: + api_response = api_instance.list_namespaced_custom_object( + group="helm.toolkit.fluxcd.io", + version="v2beta1", + namespace=namespace, + plural="helmreleases", + label_selector=label_selector) + except ApiException as error: + if error.status == 404: + return None + # Raise all non-404 errors + raise error + return api_response + def get_readiness(app_status): """