Fix merge conflicts

Merge branch 'main' into 45-allow-database-to-have-external-apps
This commit is contained in:
Mart van Santen 2022-09-30 10:33:43 +08:00
commit dd5d4f1acd
5 changed files with 265 additions and 805 deletions

600
.pylintrc
View file

@ -1,610 +1,10 @@
[MAIN]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold to be exceeded before program exits with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regex patterns to the ignore-list. The
# regex matches against paths and can be in Posix or Windows format.
ignore-paths=
# Files or directories matching the regex patterns are skipped. The regex
# matches against base names, not paths. The default value ignores Emacs file
# locks
ignore-patterns=^\.#
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=pylint_flask,pylint_flask_sqlalchemy
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.9
# Discover python modules and packages in the file system subtree.
recursive=no
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it work,
# install the 'python-enchant' package.
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored. Default to name
# with leading underscore.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=BaseException,
Exception
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace,scoped_session
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2

View file

@ -9,8 +9,6 @@ from database import db
from areas import api_v1
from constants import APP_NOT_INSTALLED_STATUS
CONFIG_DATA = [
{
"id": "values.yml",
@ -31,8 +29,6 @@ APPS_DATA = [
APP_DATA = {"id": 1, "name": "Nextcloud", "selected": True, "status": "ON for everyone", "config": CONFIG_DATA},
@api_v1.route('/apps', methods=['GET'])
@jwt_required()
@cross_origin()

View file

@ -10,8 +10,6 @@ import helpers.kubernetes as k8s
from flask import current_app
from constants import APP_NOT_INSTALLED_STATUS
DEFAULT_APP_SUBDOMAINS = {
"nextcloud": "files",
"wordpress": "www",
@ -72,45 +70,9 @@ class App(db.Model):
return f"https://{self.slug}.{domain}"
return f"https://{ks_config_map[domain_key]}"
def get_status(self):
"""Returns a string that describes the app state in the cluster"""
if self.external:
return "External app"
# TODO: Get some kind of caching for those values, as this is called
# on every app list, causing significant delays in the interface
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
for helmrelease in self.helmreleases['items']:
hr_status = helmrelease['status']
hr_ready, hr_message = App.check_condition(hr_status)
# 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:
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 HelmRelease status: {hr_message}"
if not ks_ready:
return f"App Kustomization status: {ks_message}"
return "App is installing..."
"""Returns an AppStatus object that describes the current cluster state"""
return AppStatus(self.kustomization, self.helmreleases)
def install(self):
"""Creates a Kustomization in the Kubernetes cluster that installs this application"""
@ -123,8 +85,11 @@ class App(db.Model):
"""
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
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()
@ -149,6 +114,14 @@ class App(db.Model):
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 = \
@ -195,18 +168,6 @@ class App(db.Model):
"""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."""
return os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
@staticmethod
def check_condition(status):
"""
@ -240,6 +201,19 @@ class App(db.Model):
"status": self.get_status()}
@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}")
@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
"""
The AppRole object, stores the roles Users have on Apps
@ -254,3 +228,75 @@ class AppRole(db.Model): # pylint: disable=too-few-public-methods
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
:param kustomization_status: The status of the Kustomization of this app:
:type kustomization_status: str
:param helmrelease_status: The status of the helmreleases of this app
:type helmrelease_status: str[]
"""
def __init__(self, kustomization, helmreleases):
self.helmreleases = {}
if kustomization is not None and "status" in kustomization:
ks_ready, ks_message = AppStatus.check_condition(kustomization['status'])
self.installed = True
else:
ks_ready = None
ks_message = "Kustomization does not exist"
self.installed = False
self.ready = False
self.message = "Not installed"
return
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
# If we end up here, all HRs are ready
if ks_ready:
self.ready = True
self.message = "Installed"
else:
self.ready = False
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"

View file

@ -17,7 +17,7 @@ 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, APP_NOT_INSTALLED_STATUS
from areas.apps import AppRole, App
from database import db
# APIs
@ -126,9 +126,14 @@ def delete_app(slug):
current_app.logger.info("Not found")
return
deleted = app_obj.delete()
current_app.logger.info(f"Success: {deleted}")
return
app_status = app_obj.get_status()
if not app_status.installed:
app_obj.delete()
current_app.logger.info("Success.")
else:
current_app.logger.info("Can not delete installed application, run"
" 'uninstall' first")
@app_cli.command(
"uninstall",
@ -138,15 +143,15 @@ 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}")
current_app.logger.info(f"Trying to uninstall 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}")
app_obj.uninstall()
current_app.logger.info("Success.")
return
@app_cli.command("status")
@ -163,7 +168,7 @@ def status_app(slug):
current_app.logger.error(f"App {slug} does not exist")
return
current_app.logger.info(f"Status: {app.get_status()}")
current_app.logger.info(app.get_status())
@app_cli.command("install")
@click.argument("slug")
@ -185,13 +190,12 @@ def install_app(slug):
return
current_status = app.get_status()
if current_status == APP_NOT_INSTALLED_STATUS:
if not current_status.installed:
app.install()
current_app.logger.info(
f"App {slug} installing... use `status` to see status")
else:
current_app.logger.error("App {slug} should have status"
f" {APP_NOT_INSTALLED_STATUS} but has status: {current_status}")
current_app.logger.error(f"App {slug} is already installed")
@app_cli.command("roles")
@click.argument("slug")

View file

@ -12,17 +12,28 @@ 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
from flask import current_app
# Load the kube config once
#
# By default this loads whatever we define in the `KUBECONFIG` env variable,
# otherwise loads the config from default locations, similar to what kubectl
# does.
config.load_kube_config()
def create_variables_secret(app_slug, variables_filepath):
"""Checks if a variables secret for app_name already exists, generates it if necessary.
If a secret already exists, loops through keys from the template, and adds
values for keys that miss in the Kubernetes secret, but are available in
the template.
: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
:return: returns True, unless an exception gets raised by the Kubernetes API
:rtype: boolean
"""
new_secret_dict = read_template_to_dict(
variables_filepath,
@ -37,7 +48,7 @@ def create_variables_secret(app_slug, variables_filepath):
elif current_secret_data.keys() != new_secret_dict["data"].keys():
# Update current secret with new keys
update_secret = True
print(
current_app.logger.info(
f"Secret {secret_name} in namespace {secret_namespace}"
" already exists. Merging..."
)
@ -45,12 +56,12 @@ def create_variables_secret(app_slug, variables_filepath):
new_secret_dict["data"] |= current_secret_data
else:
# Do Nothing
print(
current_app.logger.info(
f"Secret {secret_name} in namespace {secret_namespace}"
" is already in a good state, doing nothing."
)
return True
print(
current_app.logger.info(
f"Storing secret {secret_name} in namespace"
f" {secret_namespace} in cluster."
)
@ -61,7 +72,14 @@ def create_variables_secret(app_slug, variables_filepath):
def get_secret_metadata(secret_dict):
"""Returns secret name and namespace from metadata field in a yaml string."""
"""
Returns secret name and namespace from metadata field in a yaml string.
:param secret_dict: Dictionary of the secret as returned by read_namespaced_secret
:type secret_dict: dict
:return: Tuple containing secret name and secret namespace
:rtype: tuple
"""
secret_name = secret_dict["metadata"]["name"]
# default namespace is flux-system, but other namespace can be
# provided in secret metadata
@ -73,7 +91,17 @@ def get_secret_metadata(secret_dict):
def get_kubernetes_secret_data(secret_name, namespace):
"""Returns the contents of a kubernetes secret or None if the secret does not exist."""
"""
Get secret from Kubernetes
:param secret_name: Name of the secret
:type secret_name: string
:param namespace: Namespace of the secret
:type namespace: string
:return: The contents of a kubernetes secret or None if the secret does not exist.
:rtype: dict or None
"""
api_client_instance = api_client.ApiClient()
api_instance = client.CoreV1Api(api_client_instance)
try:
@ -85,24 +113,22 @@ def get_kubernetes_secret_data(secret_name, namespace):
return None
return secret
def get_kubernetes_config_map_data(config_map_name, namespace):
"""
Returns the contents of a kubernetes config map.
Returns None if the config map does not exist.
"""
api_instance = client.CoreV1Api()
try:
config_map = api_instance.read_namespaced_config_map(config_map_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 config_map
def store_kubernetes_secret(secret_dict, namespace, update=False):
"""Stores either a new secret in the cluster, or updates an existing one."""
"""
Stores either a new secret in the cluster, or updates an existing one.
:param secret_dict: Dictionary of the secret as returned by read_namespaced_secret
:type secret_dict: dict
:param namespace: Namespace of the secret
:type namespace: string
:param update: If True, use `patch_kubernetes_secret`,
otherwise use `create_from_yaml` (default: False)
:type update: boolean
:return: None
:rtype: None
"""
api_client_instance = api_client.ApiClient()
if update:
verb = "updated"
@ -116,13 +142,23 @@ def store_kubernetes_secret(secret_dict, namespace, update=False):
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}")
current_app.logger.info(f"Secret not created because of exception {ex}")
raise ex
current_app.logger.info(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"""
"""
Add a kustomization that installs app {app_slug} to the cluster.
:param kustomization_template_filepath: Path to the template that describes
the kustomization. The template should have an `{{ app }}` entry.
:type kustomization_template_filepath: string
:param app_slug: Slug for the app, used to replace `{{ app }}` in the
template
:return: True on success
:rtype: boolean
"""
kustomization_dict = read_template_to_dict(kustomization_template_filepath,
{"app": app_slug})
custom_objects_api = client.CustomObjectsApi()
@ -134,14 +170,27 @@ def store_kustomization(kustomization_template_filepath, app_slug):
plural="kustomizations",
body=kustomization_dict)
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}")
current_app.logger.info(
f"Could not create {app_slug} Kustomization because of exception {ex}")
raise ex
current_app.logger.debug(f"Kustomization created with api response: {api_response}")
return True
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"""
"""
Deletes a kustomization.
Note that if the kustomization has `prune: true` in its spec, this will
trigger deletion of other elements generated by the Kustomizartion. See
App.uninstall() to learn what implications this has for what will and will
not be deleted by the kustomize-controller.
:param kustomization_name: name of the kustomization to delete
:type kustomization_name: string
:return: Response of delete API call
:rtype: dict
"""
custom_objects_api = client.CustomObjectsApi()
body = client.V1DeleteOptions()
try:
@ -153,14 +202,16 @@ def delete_kustomization(kustomization_name):
name=kustomization_name,
body=body)
except ApiException as ex:
print(f"Could not delete {kustomization_name} Kustomization because of exception {ex}")
return False
print(f"Kustomization deleted with api response: {api_response}")
current_app.logger.info(
f"Could not delete {kustomization_name} Kustomization because of exception {ex}")
raise ex
current_app.logger.debug(f"Kustomization deleted with api response: {api_response}")
return api_response
def read_template_to_dict(template_filepath, template_globals):
"""Reads a Jinja2 template that contains yaml and turns it into a dict
"""
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
@ -182,7 +233,17 @@ def read_template_to_dict(template_filepath, template_globals):
def patch_kubernetes_secret(secret_dict, namespace):
"""Patches secret in the cluster with new data."""
"""
Patches secret in the cluster with new data.
Warning: currently ignores everything that's not in secret_dict["data"]
:param secret_dict: Dictionary of the secret as returned by read_namespaced_secret
:type secret_dict: dict
:param namespace: Namespace of the secret
:type namespace: string
:return: Response of the patch API call
"""
api_client_instance = api_client.ApiClient()
api_instance = client.CoreV1Api(api_client_instance)
name = secret_dict["metadata"]["name"]
@ -192,30 +253,32 @@ def patch_kubernetes_secret(secret_dict, namespace):
def generate_password(length):
"""Generates a password of "length" characters."""
"""
Generates a password with letters and digits.
:param length: The amount of characters in the password
:type length: int
:return: Generated password
:rtype: string
"""
length = int(length)
password = "".join((secrets.choice(string.ascii_letters)
password = "".join((secrets.choice(string.ascii_letters + string.digits)
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))}"
"""
Generate htpasswd entry for user with password.
def get_all_kustomization_names(namespace='flux-system'):
:param user: Username used in the htpasswd entry
:type user: string
:param password: Password for the user, will get encrypted.
:type password: string
:return: htpassword line entry
:rtype: string
"""
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
return f"{user}:{crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))}"
def get_all_kustomizations(namespace='flux-system'):
@ -223,8 +286,8 @@ 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
:return: 'items' in dict returned by CustomObjectsApi.list_namespaced_custom_object()
:rtype: dict[]
"""
api = client.CustomObjectsApi()
api_response = api.list_namespaced_custom_object(
@ -236,81 +299,17 @@ def get_all_kustomizations(namespace='flux-system'):
return api_response
def get_all_helmrelease_names(namespace='stackspin'):
def get_all_helmreleases(namespace='stackspin', label_selector=""):
"""
Returns names of all helmreleases in a namespace.
:param namespace: namespace that contains kustomizations. Default: `stackspin`
Lists all helmreleases in a certain namespace (stackspin by default)
:param namespace: namespace that contains helmreleases. Default: `stackspin-apps`
: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
:param label_selector: a label selector to limit the list (optional)
:type label_selector: str
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
"""
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'"""
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'"""
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 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.
:return: List of helmreleases
:rtype: dict[]
"""
api_instance = client.CustomObjectsApi()
@ -326,17 +325,32 @@ def list_helmreleases(namespace='stackspin-apps', label_selector=""):
return None
# Raise all non-404 errors
raise error
return api_response
return api_response['items']
def get_readiness(app_status):
def get_kustomization(name, namespace='flux-system'):
"""
Parses an app status's 'conditions' to find a type field called 'Ready' and
returns its status. Works for Kustomizations as well as Helmreleases.
Returns all info of a Flux kustomization with name 'name'
:param name: Name of the kustomizatoin
:type name: string
:param namespace: Namespace of the kustomization
:type namespace: string
:return: kustomization as returned by the API
:rtype: dict
"""
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
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