2022-08-12 13:08:03 +02:00
|
|
|
"""Everything to do with Apps"""
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
2022-04-13 10:27:17 +02:00
|
|
|
from sqlalchemy import ForeignKey, Integer, String
|
2022-04-14 13:32:35 +02:00
|
|
|
from sqlalchemy.orm import relationship
|
2022-04-13 10:27:17 +02:00
|
|
|
from database import db
|
2022-08-12 13:08:03 +02:00
|
|
|
import helpers.kubernetes as k8s
|
|
|
|
from .apps import APPS_WITHOUT_OAUTH, APP_NOT_INSTALLED_STATUS
|
2022-04-13 10:27:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
class App(db.Model):
|
|
|
|
"""
|
|
|
|
The App object, interact with the App database object. Data is stored in
|
|
|
|
the local database.
|
|
|
|
"""
|
|
|
|
|
|
|
|
id = db.Column(Integer, primary_key=True)
|
|
|
|
name = db.Column(String(length=64))
|
|
|
|
slug = db.Column(String(length=64), unique=True)
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return f"{self.id} <{self.name}>"
|
|
|
|
|
2022-08-12 13:08:03 +02:00
|
|
|
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)
|
|
|
|
else:
|
|
|
|
ks_ready = None
|
|
|
|
hr_status = self.get_helmrelease_status()
|
|
|
|
if hr_status is not None:
|
|
|
|
hr_ready, hr_message = App.check_condition(hr_status)
|
|
|
|
else:
|
|
|
|
hr_ready = None
|
|
|
|
if ks_ready is None:
|
|
|
|
return APP_NOT_INSTALLED_STATUS
|
|
|
|
# *Should* not happen, but just in case:
|
|
|
|
if (ks_ready is None and hr_ready is not None) or \
|
|
|
|
(hr_ready is None and ks_ready is not None):
|
|
|
|
return ("This app is in a strange state. Contact a Stackspin"
|
|
|
|
" administrator if this status stays for longer than 5 minutes")
|
|
|
|
if ks_ready and hr_ready:
|
|
|
|
return "App installed and running"
|
|
|
|
if not hr_ready:
|
2022-09-22 16:14:49 +02:00
|
|
|
return f"App HelmRelease status: {hr_message}"
|
2022-08-12 13:08:03 +02:00
|
|
|
if not ks_ready:
|
2022-09-22 16:14:49 +02:00
|
|
|
return f"App Kustomization status: {ks_message}"
|
2022-08-12 13:08:03 +02:00
|
|
|
return "App is installing..."
|
|
|
|
|
|
|
|
|
|
|
|
def install(self):
|
|
|
|
"""Creates a Kustomization in the Kubernetes cluster that installs this application"""
|
|
|
|
# Generate the necessary passwords, etc. from a template
|
|
|
|
self.__generate_secrets()
|
|
|
|
# Create add-<app> kustomization
|
|
|
|
self.__create_kustomization()
|
|
|
|
|
2022-09-22 16:14:49 +02:00
|
|
|
def delete(self):
|
|
|
|
"""
|
|
|
|
Fully deletes an application
|
|
|
|
|
|
|
|
This includes user roles, all kubernetes objects and also PVCs, so your
|
|
|
|
data will be *gone*
|
|
|
|
"""
|
|
|
|
# Delete all roles first
|
|
|
|
for role in self.roles:
|
|
|
|
db.session.delete(role)
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
2022-08-12 13:08:03 +02:00
|
|
|
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"""
|
|
|
|
kustomization_template_filepath = \
|
|
|
|
os.path.join(self.__get_templates_dir(),
|
|
|
|
"add-app-kustomization.yaml.jinja")
|
|
|
|
k8s.store_kustomization(kustomization_template_filepath, self.slug)
|
|
|
|
|
2022-09-22 16:14:49 +02:00
|
|
|
def __delete_kustomization(self):
|
|
|
|
"""Deletes kustomization for this app"""
|
|
|
|
k8s.delete_kustomization(f"add-{self.slug}")
|
2022-08-12 13:08:03 +02:00
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
def variables_template_filepath(self):
|
|
|
|
"""Path to the variables template used to generate secrets the app needs"""
|
|
|
|
variables_template_filepath = os.path.join(self.__get_templates_dir(),
|
|
|
|
f"stackspin-{self.slug}-variables.yaml.jinja")
|
|
|
|
if os.path.exists(variables_template_filepath):
|
|
|
|
return variables_template_filepath
|
|
|
|
return None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def namespace(self):
|
|
|
|
"""
|
|
|
|
Returns the Kubernetes namespace of this app
|
|
|
|
|
|
|
|
FIXME: This should probably become a database field.
|
|
|
|
"""
|
|
|
|
if self.slug in ['nextcloud', 'wordpress', 'wekan', 'zulip']:
|
|
|
|
return 'stackspin-apps'
|
|
|
|
return 'stackspin'
|
|
|
|
|
2022-09-22 16:14:49 +02:00
|
|
|
@property
|
|
|
|
def roles(self):
|
|
|
|
"""
|
|
|
|
All roles that are linked to this app
|
|
|
|
"""
|
|
|
|
return AppRole.query.filter_by(
|
|
|
|
app_id=self.id
|
|
|
|
).all()
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
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")
|
|
|
|
|
2022-08-12 13:08:03 +02:00
|
|
|
@staticmethod
|
|
|
|
def check_condition(status):
|
|
|
|
"""
|
|
|
|
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 is interpreted as not ready.
|
|
|
|
|
|
|
|
The message that is returned is the message that comes with the
|
|
|
|
condition with type "Ready"
|
|
|
|
|
|
|
|
:param status: Kubernetes resource's "status" object.
|
|
|
|
:type status: dict
|
|
|
|
"""
|
|
|
|
for condition in status["conditions"]:
|
|
|
|
if condition["type"] == "Ready":
|
|
|
|
return condition["status"] == "True", condition["message"]
|
|
|
|
return False
|
|
|
|
|
2022-04-13 10:27:17 +02:00
|
|
|
|
2022-08-12 13:08:03 +02:00
|
|
|
class AppRole(db.Model): # pylint: disable=too-few-public-methods
|
2022-04-13 10:27:17 +02:00
|
|
|
"""
|
|
|
|
The AppRole object, stores the roles Users have on Apps
|
|
|
|
"""
|
|
|
|
|
|
|
|
user_id = db.Column(String(length=64), primary_key=True)
|
|
|
|
app_id = db.Column(Integer, ForeignKey("app.id"), primary_key=True)
|
2022-04-13 15:11:51 +02:00
|
|
|
role_id = db.Column(Integer, ForeignKey("role.id"))
|
2022-04-13 10:27:17 +02:00
|
|
|
|
2022-04-14 13:32:35 +02:00
|
|
|
role = relationship("Role")
|
|
|
|
|
2022-04-13 10:27:17 +02:00
|
|
|
def __repr__(self):
|
2022-08-12 13:08:03 +02:00
|
|
|
return (f"role_id: {self.role_id}, user_id: {self.user_id},"
|
|
|
|
f" app_id: {self.app_id}, role: {self.role}")
|