From 8e41705d39d0fe8ca0a9f1720e3148801cdc48ea Mon Sep 17 00:00:00 2001
From: Maarten de Waard <maarten@greenhost.nl>
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 1be83ae7..fe9f30a1 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 23a6249c..f28971e5 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-<app> 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)
-
-        # Delete the kustomization
-        if self.__delete_kustomization():
+            role.delete()
 
-        # 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 ba564c13..6a689bfd 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 3169c63d..9d843e29 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):
     """
-- 
GitLab