Skip to content
Snippets Groups Projects
Verified Commit 5893ee39 authored by Maarten de Waard's avatar Maarten de Waard :angel:
Browse files

draft of installing apps and getting app status

parent a22cd872
No related branches found
No related tags found
1 merge request!55Resolve "Merge dashboard and dashboard-backend repos"
.pylintrc 0 → 100644
This diff is collapsed.
......@@ -24,6 +24,14 @@ 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'])
@jwt_required()
......
"""Everything to do with Apps"""
import os
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
class App(db.Model):
......@@ -16,8 +22,122 @@ 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)
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:
return f"App failed installing: {hr_message}"
if not ks_ready:
return f"App failed installing: {ks_message}"
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()
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)
@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")
@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'
@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
class AppRole(db.Model):
class AppRole(db.Model): # pylint: disable=too-few-public-methods
"""
The AppRole object, stores the roles Users have on Apps
"""
......@@ -29,4 +149,5 @@ class AppRole(db.Model):
role = relationship("Role")
def __repr__(self):
return f"role_id: {self.role_id}, user_id: {self.user_id}, app_id: {self.app_id}, role: {self.role}"
return (f"role_id: {self.role_id}, user_id: {self.user_id},"
f" app_id: {self.app_id}, role: {self.role}")
---
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: add-{{ app }}
namespace: flux-system
spec:
interval: 1h0m0s
path: ./flux2/cluster/optional/{{ app }}
prune: true
sourceRef:
kind: GitRepository
name: stackspin
---
apiVersion: v1
kind: Secret
metadata:
name: stackspin-nextcloud-variables
data:
nextcloud_password: "{{ 32 | generate_password | b64encode }}"
nextcloud_mariadb_password: "{{ 32 | generate_password | b64encode }}"
nextcloud_mariadb_root_password: "{{ 32 | generate_password | b64encode }}"
onlyoffice_database_password: "{{ 32 | generate_password | b64encode }}"
onlyoffice_jwt_secret: "{{ 32 | generate_password | b64encode }}"
onlyoffice_rabbitmq_password: "{{ 32 | generate_password | b64encode }}"
---
apiVersion: v1
kind: Secret
metadata:
name: stackspin-{{ app }}-oauth-variables
data:
client_id: "{{ app | b64encode }}"
client_secret: "{{ 32 | generate_password | b64encode }}"
apiVersion: v1
kind: Secret
metadata:
name: stackspin-wekan-variables
data:
mongodb_password: "{{ 32 | generate_password | b64encode }}"
mongodb_root_password: "{{ 32 | generate_password | b64encode }}"
---
apiVersion: v1
kind: Secret
metadata:
name: stackspin-wordpress-variables
data:
wordpress_admin_password: "{{ 32 | generate_password | b64encode }}"
wordpress_mariadb_password: "{{ 32 | generate_password | b64encode }}"
wordpress_mariadb_root_password: "{{ 32 | generate_password | b64encode }}"
apiVersion: v1
kind: Secret
metadata:
name: stackspin-zulip-variables
data:
admin_password: "{{ 32 | generate_password | b64encode }}"
memcached_password: "{{ 32 | generate_password | b64encode }}"
rabbitmq_password: "{{ 32 | generate_password | b64encode }}"
rabbitmq_erlang_cookie: "{{ 32 | generate_password | b64encode }}"
redis_password: "{{ 32 | generate_password | b64encode }}"
postgresql_password: "{{ 32 | generate_password | b64encode }}"
zulip_password: "{{ 32 | generate_password | b64encode }}"
......@@ -13,11 +13,11 @@ from flask.cli import AppGroup
from ory_kratos_client.api import v0alpha2_api as kratos_api
from sqlalchemy import func
from config import *
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
from areas.apps import AppRole, App
from areas.apps import AppRole, App, APP_NOT_INSTALLED_STATUS
from database import db
# APIs
......@@ -66,7 +66,7 @@ def create_app(slug, name):
if app_obj:
current_app.logger.info(f"App definition: {name} ({slug}) already exists in database")
return
db.session.add(obj)
db.session.commit()
current_app.logger.info(f"App definition: {name} ({slug}) created")
......@@ -106,6 +106,46 @@ def delete_app(slug):
current_app.logger.info("Success")
return
@app_cli.command("get_status")
@click.argument("slug")
def get_status_app(slug):
"""Gets the current app status from the Kubernetes cluster
:param slug: str Slug of app to remove
"""
current_app.logger.info(f"Getting status for app: {slug}")
app = App.query.filter_by(slug=slug).first()
if not app:
current_app.logger.error(f"App {slug} does not exist")
return
current_app.logger.info("Status: " + str(app.get_status()))
@app_cli.command("install")
@click.argument("slug")
def install_app(slug):
"""Gets the current app status from the Kubernetes cluster
:param slug: str Slug of app to remove
"""
current_app.logger.info(f"Installing app: {slug}")
app = App.query.filter_by(slug=slug).first()
if not app:
current_app.logger.error(f"App {slug} does not exist")
return
current_status = app.get_status()
if current_status == APP_NOT_INSTALLED_STATUS:
app.install()
current_app.logger.info(
f"App {slug} installing... use `get_status` to see status")
else:
current_app.logger.error("App {slug} should have status"
f" {APP_NOT_INSTALLED_STATUS} but has status: {current_status}")
cli.cli.add_command(app_cli)
......@@ -282,7 +322,7 @@ def setpassword_user(email, password):
# Execute UI sequence to set password, given we have a recovery URL
result = kratos_user.ui_set_password(KRATOS_PUBLIC_URL, url, password)
except Exception as error:
except Exception as error: # pylint: disable=broad-except
current_app.logger.error(f"Error while setting password: {error}")
return False
......@@ -321,7 +361,7 @@ def recover_user(email):
url = kratos_user.get_recovery_link()
print(url)
except Exception as error:
except Exception as error: # pylint: disable=broad-except
current_app.logger.error(f"Error while getting reset link: {error}")
......
......@@ -34,8 +34,10 @@ services:
# - OAUTHLIB_INSECURE_TRANSPORT=1
ports:
- "5000:5000"
user: "${KUBECTL_UID}:${KUBECTL_GID}"
volumes:
- .:/app
- "$KUBECONFIG:/.kube/config"
depends_on:
- kube_port_mysql
entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"]
......
"""
List of functions to get data from Flux Kustomizations and Helmreleases
"""
import crypt
import secrets
import string
import jinja2
import yaml
from kubernetes import client, config
from kubernetes.client import api_client
from kubernetes.client.exceptions import ApiException
from kubernetes.utils import create_from_yaml
from kubernetes.utils.create_from_yaml import FailToCreateError
def create_variables_secret(app_slug, variables_filepath):
"""Checks if a variables secret for app_name already exists, generates it if necessary.
:param app_slug: The slug of the app, used in the oauth secrets
:type app_slug: string
:param variables_filepath: The path to an existing jinja2 template
:type variables_filepath: string
"""
new_secret_dict = read_template_to_dict(
variables_filepath,
{"app": app_slug})
secret_name, secret_namespace = get_secret_metadata(new_secret_dict)
current_secret_data = get_kubernetes_secret_data(
secret_name, secret_namespace
)
if current_secret_data is None:
# Create new secret
update_secret = False
elif current_secret_data.keys() != new_secret_dict["data"].keys():
# Update current secret with new keys
update_secret = True
print(
f"Secret {secret_name} in namespace {secret_namespace}"
" already exists. Merging..."
)
# Merge dicts. Values from current_secret_data take precedence
new_secret_dict["data"] |= current_secret_data
else:
# Do Nothing
print(
f"Secret {secret_name} in namespace {secret_namespace}"
" is already in a good state, doing nothing."
)
return True
print(
f"Storing secret {secret_name} in namespace"
f" {secret_namespace} in cluster."
)
store_kubernetes_secret(
new_secret_dict, secret_namespace, update=update_secret
)
return True
def get_secret_metadata(secret_dict):
"""Returns secret name and namespace from metadata field in a yaml string."""
secret_name = secret_dict["metadata"]["name"]
# default namespace is flux-system, but other namespace can be
# provided in secret metadata
if "namespace" in secret_dict["metadata"]:
secret_namespace = secret_dict["metadata"]["namespace"]
else:
secret_namespace = "flux-system"
return secret_name, secret_namespace
def get_kubernetes_secret_data(secret_name, namespace):
"""Returns the contents of a kubernetes secret or None if the secret does not exist."""
api_client_instance = api_client.ApiClient()
api_instance = client.CoreV1Api(api_client_instance)
try:
secret = api_instance.read_namespaced_secret(secret_name, namespace).data
except ApiException as ex:
# 404 is expected when the optional secret does not exist.
if ex.status != 404:
raise ex
return None
return secret
def store_kubernetes_secret(secret_dict, namespace, update=False):
"""Stores either a new secret in the cluster, or updates an existing one."""
api_client_instance = api_client.ApiClient()
if update:
verb = "updated"
api_response = patch_kubernetes_secret(secret_dict, namespace)
else:
verb = "created"
try:
api_response = create_from_yaml(
api_client_instance,
yaml_objects=[secret_dict],
namespace=namespace
)
except FailToCreateError as ex:
print(f"Secret not {verb} because of exception {ex}")
return
print(f"Secret {verb} with api response: {api_response}")
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)
try:
api_response = custom_objects_api.create_namespaced_custom_object(
group="kustomize.toolkit.fluxcd.io",
version="v1beta2",
namespace="flux-system",
plural="kustomizations",
body=kustomization_dict)
# create_from_yaml(
# api_client_instance,
# yaml_objects=[kustomization_dict],
# # All kustomizations live in the flux-system namespace
# namespace="flux-system"
# )
except FailToCreateError as ex:
print(f"Could not create {app_slug} Kustomization because of exception {ex}")
return
print(f"Kustomization created with api response: {api_response}")
def read_template_to_dict(template_filepath, template_globals):
"""Reads a Jinja2 template that contains yaml and turns it into a dict
:param template_filepath: The path to an existing Jinja2 template
:type template_filepath: string
:param template_globals: The variables substituted in the template
:type template_globals: dict
:return: dict, or None if anything fails
"""
env = jinja2.Environment(
extensions=["jinja2_base64_filters.Base64Filters"])
env.filters["generate_password"] = generate_password
# Check if k8s secret already exists, if not, generate it
with open(template_filepath, encoding="UTF-8") as template_file:
lines = template_file.read()
templated_dict = yaml.safe_load(
env.from_string(lines, globals=template_globals).render()
)
return templated_dict
return None
def patch_kubernetes_secret(secret_dict, namespace):
"""Patches secret in the cluster with new data."""
api_client_instance = api_client.ApiClient()
api_instance = client.CoreV1Api(api_client_instance)
name = secret_dict["metadata"]["name"]
body = {}
body["data"] = secret_dict["data"]
return api_instance.patch_namespaced_secret(name, namespace, body)
def generate_password(length):
"""Generates a password of "length" characters."""
length = int(length)
password = "".join((secrets.choice(string.ascii_letters)
for i in range(length)))
return password
def gen_htpasswd(user, password):
"""Generate htpasswd entry for user with password."""
return f"{user}:{crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))}"
def get_all_kustomization_names(namespace='flux-system'):
"""
Returns all flux kustomizations in a namespace.
:param namespace: namespace that contains kustomizations. Default: `flux-system`
:type namespace: str
:return: List of names for kustomizations in namespace
:rtype: list
"""
kustomizations = get_all_kustomizations(namespace)
return_kustomizations = []
for kustomization in kustomizations['items']:
return_kustomizations.append(kustomization['metadata']['name'])
return return_kustomizations
def get_all_kustomizations(namespace='flux-system'):
"""
Returns all flux kustomizations in a namespace.
:param namespace: namespace that contains kustomizations. Default: `flux-system`
:type namespace: str
:return: Kustomizations as returned by CustomObjectsApi.list_namespaced_custom_object()
:rtype: object
"""
config.load_kube_config()
api = client.CustomObjectsApi()
api_response = api.list_namespaced_custom_object(
group="kustomize.toolkit.fluxcd.io",
version="v1beta1",
plural="kustomizations",
namespace=namespace,
)
return api_response
def get_all_helmrelease_names(namespace='stackspin'):
"""
Returns names of all helmreleases in a namespace.
:param namespace: namespace that contains kustomizations. Default: `stackspin`
:type namespace: str
:return: List of names for helmreleases in namespace
:rtype: list
"""
helmreleases = get_all_helmreleases(namespace)
return_helmreleases = []
for helmrelease in helmreleases['items']:
return_helmreleases.append(helmrelease['metadata']['name'])
return return_helmreleases
def get_all_helmreleases(namespace='stackspin'):
"""
Returns all helmreleases in a namespace.
:param namespace: namespace that contains kustomizations. Default: `stackspin`
:type namespace: str
:return: Helmreleases as returned by CustomObjectsApi.list_namespaced_custom_object()
:rtype: object
"""
config.load_kube_config()
api = client.CustomObjectsApi()
api_response = api.list_namespaced_custom_object(
group="helm.toolkit.fluxcd.io",
version="v2beta1",
plural="helmreleases",
namespace=namespace,
)
return api_response
def get_kustomization(name, namespace='flux-system'):
"""Returns all info of a Flux kustomization with name 'name'"""
config.load_kube_config()
api = client.CustomObjectsApi()
try:
resource = api.get_namespaced_custom_object(
group="kustomize.toolkit.fluxcd.io",
version="v1beta1",
name=name,
namespace=namespace,
plural="kustomizations",
)
except client.exceptions.ApiException as error:
if error.status == 404:
return None
# Raise all non-404 errors
raise error
return resource
def get_helmrelease(name, namespace='stackspin-apps'):
"""Returns all info of a Flux helmrelease with name 'name'"""
config.load_kube_config()
api = client.CustomObjectsApi()
try:
resource = api.get_namespaced_custom_object(
group="helm.toolkit.fluxcd.io",
version="v2beta1",
name=name,
namespace=namespace,
plural="helmreleases",
)
except client.exceptions.ApiException as error:
if error.status == 404:
return None
# Raise all non-404 errors
raise error
return resource
def get_readiness(app_status):
"""
Parses an app status's 'conditions' to find a type field called 'Ready' and
returns its status. Works for Kustomizations as well as Helmreleases.
"""
for condition in app_status['conditions']:
if condition['type'] == 'Ready':
return condition['status']
# If this point is reached, no condition "Ready" exists, so the application
# is not ready.
return False
......@@ -15,6 +15,8 @@ install==1.3.5
itsdangerous==2.1.1
jsonschema==4.4.0
Jinja2==3.0.3
jinja2-base64-filters==0.1.4
kubernetes==24.2.0
MarkupSafe==2.1.1
mypy-extensions==0.4.3
oauthlib==3.2.0
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment