dashboard/areas/apps/models.py

299 lines
10 KiB
Python
Raw Normal View History

"""Everything to do with Apps"""
import os
2022-09-27 15:41:46 +02:00
import base64
2022-09-23 19:04:29 +02:00
from sqlalchemy import ForeignKey, Integer, String, Boolean
from sqlalchemy.orm import relationship
2022-10-04 12:35:56 +02:00
2022-04-13 10:27:17 +02:00
from database import db
import helpers.kubernetes as k8s
2022-04-13 10:27:17 +02:00
2022-09-29 08:51:59 +02:00
2022-09-27 15:41:46 +02:00
DEFAULT_APP_SUBDOMAINS = {
"nextcloud": "files",
"wordpress": "www",
"monitoring": "grafana",
}
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)
2022-10-04 15:02:11 +02:00
external = db.Column(Boolean, unique=False, nullable=False, server_default='0')
2022-09-27 15:41:46 +02:00
# The URL is only stored in the DB for external applications; otherwise the
# URL is stored in a configmap (see get_url)
2022-09-23 19:04:29 +02:00
url = db.Column(String(length=128), unique=False)
2022-04-13 10:27:17 +02:00
def __repr__(self):
return f"{self.id} <{self.name}>"
2022-09-23 19:04:29 +02:00
def get_url(self):
2022-09-27 15:41:46 +02:00
"""
Returns the URL where this application is running
For external applications: the URL is stored in the database
For internal applications: the URL is stored in a configmap named
`stackspin-{self.slug}-kustomization-variables under
`{self.slug_domain}`. This function reads that configmap. If the
configmap does not contain a URL for the application (which is
possible, if the app is not installed yet, for example), we return a
default URL.
"""
2022-09-23 19:04:29 +02:00
if self.external:
return self.url
2022-09-26 13:42:13 +02:00
# Get domain name from configmap
ks_config_map = k8s.get_kubernetes_config_map_data(
f"stackspin-{self.slug}-kustomization-variables",
"flux-system")
domain_key = f"{self.slug}_domain"
2022-10-03 06:47:25 +02:00
# If config map found with this domain name for this service, return
# that URL
if ks_config_map and domain_key in ks_config_map.keys():
return f"https://{ks_config_map[domain_key]}"
domain_secret = k8s.get_kubernetes_secret_data(
"stackspin-cluster-variables",
"flux-system")
domain = base64.b64decode(domain_secret['domain']).decode()
# See if there is another default subdomain for this app than just
# "slug.{domain}"
if self.slug in DEFAULT_APP_SUBDOMAINS:
return f"https://{DEFAULT_APP_SUBDOMAINS[self.slug]}.{domain}"
# No default known
return f"https://{self.slug}.{domain}"
2022-09-26 13:42:13 +02:00
def get_status(self):
"""Returns an AppStatus object that describes the current cluster state"""
2022-10-04 12:35:56 +02:00
return AppStatus(self)
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()
def uninstall(self):
"""
Delete the app kustomization.
In our case, 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. It also triggers a deletion of the OAuth2Client object, but
does not delete the secrets generated by the `install` command. It also
does not remove the TLS secret generated by cert-manager.
"""
self.__delete_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:
2022-09-23 19:04:29 +02:00
db.session.delete(role)
db.session.commit()
2022-09-22 16:14:49 +02:00
db.session.delete(self)
return db.session.commit()
2022-09-22 16:14:49 +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)
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}")
@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()
@property
def kustomization(self):
"""Returns the kustomization object for this app"""
return k8s.get_kustomization(self.slug)
2022-10-04 12:35:56 +02:00
def to_dict(self):
2022-09-29 08:51:59 +02:00
"""
represent this object as a dict, compatible for JSON output
2022-09-29 08:51:59 +02:00
"""
return {"id": self.id,
"name": self.name,
"slug": self.slug,
"external": self.external,
2022-10-04 12:35:56 +02:00
"status": self.get_status().to_dict(),
2022-10-03 06:47:25 +02:00
"url": self.get_url()}
2022-09-29 08:51:59 +02:00
@property
def helmreleases(self):
"""Returns the helmreleases associated with the kustomization for this app"""
return k8s.get_all_helmreleases(self.namespace,
f"kustomize.toolkit.fluxcd.io/name={self.slug}")
2022-09-22 16:14:49 +02:00
@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")
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
role = relationship("Role")
2022-04-13 10:27:17 +02:00
def __repr__(self):
return (f"role_id: {self.role_id}, user_id: {self.user_id},"
f" app_id: {self.app_id}, role: {self.role}")
class AppStatus(): # pylint: disable=too-few-public-methods
"""
Represents the status of an app in the Kubernetes cluster.
This class can answer a few questions, like "is the app installed?", but
can also return raw status messages from Kustomizations and HelmReleases
This constructor sets three variables:
self.installed (bool): Whether the app should be installed
self.ready (bool): Whether the app is installed correctly
self.message (str): Information about the status
2022-10-04 12:35:56 +02:00
:param app: An app of which the kustomization and helmreleases
property will be used.
:type app: App
"""
2022-10-04 12:35:56 +02:00
def __init__(self, app):
2022-10-04 15:02:11 +02:00
if app.external:
self.installed = True
self.ready = True
self.message = "App is external"
return
2022-10-04 12:35:56 +02:00
kustomization = app.kustomization
if kustomization is not None and "status" in kustomization:
ks_ready, ks_message = AppStatus.check_condition(kustomization['status'])
self.installed = True
2022-10-04 12:35:56 +02:00
if ks_ready:
self.ready = ks_ready
self.message = "Installed"
return
else:
ks_ready = None
ks_message = "Kustomization does not exist"
self.installed = False
self.ready = False
self.message = "Not installed"
return
2022-10-04 12:35:56 +02:00
helmreleases = app.helmreleases
for helmrelease in helmreleases:
hr_status = helmrelease['status']
hr_ready, hr_message = AppStatus.check_condition(hr_status)
# For now, only show the message of the first HR that isn't ready
if not hr_ready:
self.ready = False
self.message = f"HelmRelease {helmrelease['metadata']['name']} status: {hr_message}"
return
2022-10-04 12:35:56 +02:00
# If we end up here, all HRs are ready, but the kustomization is not
self.ready = ks_ready
self.message = f"App Kustomization status: {ks_message}"
def __repr__(self):
return f"Installed: {self.installed}\tReady: {self.ready}\tMessage: {self.message}"
@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 occur, 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, "Condition with type 'Ready' not found"
2022-10-04 12:35:56 +02:00
def to_dict(self):
"""Represents this app status as a dict"""
return {
"installed": self.installed,
"ready": self.ready,
"message": self.message,
}