Process feedback; make it possible to install monitoring

This commit is contained in:
Maarten de Waard 2022-09-23 17:10:31 +02:00
parent caa9b2e79b
commit 8e41705d39
No known key found for this signature in database
GPG key ID: 1D3E893A657CC8DA
4 changed files with 84 additions and 59 deletions

View file

@ -24,13 +24,6 @@ APPS_DATA = [
APP_DATA = {"id": 1, "name": "Nextcloud", "selected": True, "status": "ON for everyone", "config": CONFIG_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" APP_NOT_INSTALLED_STATUS = "Not installed"
@api_v1.route('/apps', methods=['GET']) @api_v1.route('/apps', methods=['GET'])

View file

@ -6,7 +6,7 @@ from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import db from database import db
import helpers.kubernetes as k8s 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): class App(db.Model):
@ -22,32 +22,21 @@ class App(db.Model):
def __repr__(self): def __repr__(self):
return f"{self.id} <{self.name}>" 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): def get_status(self):
"""Returns a string that describes the app state in the cluster""" """Returns a string that describes the app state in the cluster"""
ks_status = self.get_kustomization_status() kustomization = self.kustomization
if ks_status is not None: if kustomization is not None and "status" in kustomization:
ks_ready, ks_message = App.check_condition(ks_status) ks_ready, ks_message = App.check_condition(kustomization['status'])
else: else:
ks_ready = None ks_ready = None
hr_status = self.get_helmrelease_status() for helmrelease in self.helmreleases['items']:
if hr_status is not None: hr_status = helmrelease['status']
hr_ready, hr_message = App.check_condition(hr_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: if ks_ready is None:
return APP_NOT_INSTALLED_STATUS return APP_NOT_INSTALLED_STATUS
# *Should* not happen, but just in case: # *Should* not happen, but just in case:
@ -71,6 +60,15 @@ class App(db.Model):
# Create add-<app> kustomization # Create add-<app> kustomization
self.__create_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): def delete(self):
""" """
Fully deletes an application Fully deletes an application
@ -80,34 +78,16 @@ class App(db.Model):
""" """
# Delete all roles first # Delete all roles first
for role in self.roles: 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.delete(self)
db.session.commit() return db.session.commit()
return True
return False
def __generate_secrets(self): def __generate_secrets(self):
"""Generates passwords for app installation""" """Generates passwords for app installation"""
# Create app variables secret # Create app variables secret
if self.variables_template_filepath: if self.variables_template_filepath:
k8s.create_variables_secret(self.slug, 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): def __create_kustomization(self):
"""Creates the `add-{app_slug}` kustomization in the Kubernetes cluster""" """Creates the `add-{app_slug}` kustomization in the Kubernetes cluster"""
@ -150,6 +130,18 @@ class App(db.Model):
app_id=self.id app_id=self.id
).all() ).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 @staticmethod
def __get_templates_dir(): def __get_templates_dir():
"""Returns directory that contains the Jinja templates used to create app secrets.""" """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 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 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. status is interpreted as not ready.
The message that is returned is the message that comes with the 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"]: for condition in status["conditions"]:
if condition["type"] == "Ready": if condition["type"] == "Ready":
return condition["status"] == "True", condition["message"] 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 class AppRole(db.Model): # pylint: disable=too-few-public-methods

View file

@ -88,7 +88,7 @@ def list_app():
) )
@click.argument("slug") @click.argument("slug")
def delete_app(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 :param slug: str Slug of app to remove
""" """
current_app.logger.info(f"Trying to delete app: {slug}") 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}") current_app.logger.info(f"Success: {deleted}")
return 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") @app_cli.command("status")
@click.argument("slug") @click.argument("slug")
def status_app(slug): def status_app(slug):
@ -116,7 +135,7 @@ def status_app(slug):
current_app.logger.error(f"App {slug} does not exist") current_app.logger.error(f"App {slug} does not exist")
return return
current_app.logger.info("Status: " + str(app.get_status())) current_app.logger.info(f"Status: {app.get_status()}")
@app_cli.command("install") @app_cli.command("install")
@click.argument("slug") @click.argument("slug")

View file

@ -110,8 +110,7 @@ def store_kustomization(kustomization_template_filepath, app_slug):
"""Add a kustomization that installs app {app_slug} to the cluster""" """Add a kustomization that installs app {app_slug} to the cluster"""
kustomization_dict = read_template_to_dict(kustomization_template_filepath, kustomization_dict = read_template_to_dict(kustomization_template_filepath,
{"app": app_slug}) {"app": app_slug})
api_client_instance = api_client.ApiClient() custom_objects_api = client.CustomObjectsApi()
custom_objects_api = client.CustomObjectsApi(api_client_instance)
try: try:
api_response = custom_objects_api.create_namespaced_custom_object( api_response = custom_objects_api.create_namespaced_custom_object(
group="kustomize.toolkit.fluxcd.io", 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 """Deletes kustomization for an app_slug. Should also result in the
deletion of the app's HelmReleases, PVCs, OAuth2Client, etc. Nothing will deletion of the app's HelmReleases, PVCs, OAuth2Client, etc. Nothing will
remain""" remain"""
api_client_instance = api_client.ApiClient() custom_objects_api = client.CustomObjectsApi()
custom_objects_api = client.CustomObjectsApi(api_client_instance)
body = client.V1DeleteOptions() body = client.V1DeleteOptions()
try: try:
api_response = custom_objects_api.delete_namespaced_custom_object( 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}") print(f"Could not delete {kustomization_name} Kustomization because of exception {ex}")
return False return False
print(f"Kustomization deleted with api response: {api_response}") print(f"Kustomization deleted with api response: {api_response}")
return api_response
def read_template_to_dict(template_filepath, template_globals): def read_template_to_dict(template_filepath, template_globals):
@ -292,6 +291,28 @@ def get_helmrelease(name, namespace='stackspin-apps'):
return resource 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): def get_readiness(app_status):
""" """