From 15af4c5871a5b43eee08f546cf50458aaf92c39f Mon Sep 17 00:00:00 2001
From: Arie Peterson <arie@greenhost.nl>
Date: Thu, 6 Jun 2024 12:28:17 +0200
Subject: [PATCH] Fine-tune backend logging and use LOG_LEVEL env var

---
 backend/app.py                |  7 ++--
 backend/cluster_config.py     | 12 +++----
 backend/config.py             |  2 ++
 backend/helpers/kubernetes.py | 18 +++++-----
 backend/helpers/provision.py  | 68 +++++++++++++++++------------------
 dev.sh                        |  2 +-
 6 files changed, 56 insertions(+), 53 deletions(-)

diff --git a/backend/app.py b/backend/app.py
index 76958441..5190cf2d 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -21,6 +21,7 @@ from areas import resources
 from areas import roles
 from areas import tags
 from cliapp import cliapp
+import config
 import helpers.kubernetes
 import helpers.provision
 import helpers.threads
@@ -49,6 +50,7 @@ import os
 import sys
 
 # Configure logging.
+log_level = logging.getLevelName(config.LOG_LEVEL or 'INFO')
 from logging.config import dictConfig
 dictConfig({
     'version': 1,
@@ -59,10 +61,11 @@ dictConfig({
         'class': 'logging.StreamHandler',
         'stream': 'ext://flask.logging.wsgi_errors_stream',
         'formatter': 'default',
+        'level': log_level,
     }},
     'root': {
-        'level': 'INFO',
         'handlers': ['wsgi'],
+        'level': log_level,
     },
     # Loggers are created also by alembic, flask_migrate, etc. Without this
     # setting, those loggers seem to be ignored.
@@ -76,8 +79,6 @@ app.config["SQLALCHEMY_DATABASE_URI"] = SQLALCHEMY_DATABASE_URI
 app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {'pool_pre_ping': True}
 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS
 
-app.logger.setLevel(logging.INFO)
-
 cors = CORS(app)
 
 db.init_app(app)
diff --git a/backend/cluster_config.py b/backend/cluster_config.py
index 4ced17f7..7934213f 100644
--- a/backend/cluster_config.py
+++ b/backend/cluster_config.py
@@ -11,7 +11,7 @@ import helpers.kubernetes as k8s
 # Read in two configmaps from the cluster, which specify which apps should be
 # present in the database. Returns the list of app slugs.
 def populate_apps():
-    logging.info("cluster_config: populating apps")
+    logging.debug("cluster_config: populating apps")
     database_apps = {}
     for app in App.query.all():
         slug = app.slug
@@ -29,7 +29,7 @@ def _populate_apps_from(database_apps, configmap_name):
     slugs = []
     cm_apps = k8s.get_kubernetes_config_map_data(configmap_name, "flux-system")
     if cm_apps is None:
-        logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
+        logging.debug(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
     else:
         for app_slug, app_data in cm_apps.items():
             logging.debug(f"configmap app: {app_slug}")
@@ -54,7 +54,7 @@ def _populate_apps_from(database_apps, configmap_name):
 # Read in two configmaps from the cluster, which specify which oauthclients
 # should be present in the database.
 def populate_oauthclients():
-    logging.info("cluster_config: populating oauthclients")
+    logging.debug("cluster_config: populating oauthclients")
     database_oauthclients = {}
     for client in OAuthClientApp.query.all():
         id = client.oauthclient_id
@@ -69,7 +69,7 @@ def populate_oauthclients():
 def _populate_oauthclients_from(database_oauthclients, configmap_name):
     cm_oauthclients = k8s.get_kubernetes_config_map_data(configmap_name, "flux-system")
     if cm_oauthclients is None:
-        logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
+        logging.debug(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
     else:
         for client_id, client_app in cm_oauthclients.items():
             logging.debug(f"configmap oauthclient: {client_id}")
@@ -96,9 +96,9 @@ def populate_scim_config(apps):
         secret_name = f"stackspin-scim-{app}"
         scim_config = k8s.get_kubernetes_secret_data(secret_name, "flux-system")
         if scim_config is None:
-            logging.info(f"Could not find secret '{secret_name}' in namespace 'flux-system'; ignoring.")
+            logging.debug(f"Could not find secret '{secret_name}' in namespace 'flux-system'; ignoring.")
             continue
-        logging.info(f"Processing secret stackspin-scim-{app}")
+        logging.debug(f"Processing secret stackspin-scim-{app}")
         app = App.query.filter_by(slug=app).first()
         if not app:
             logging.error(f"  could not find app with slug {app}")
diff --git a/backend/config.py b/backend/config.py
index ffa09677..2822fb86 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -1,5 +1,7 @@
 import os
 
+LOG_LEVEL = os.environ.get("LOG_LEVEL")
+
 SECRET_KEY = os.environ.get("SECRET_KEY")
 HYDRA_CLIENT_ID = os.environ.get("HYDRA_CLIENT_ID")
 HYDRA_CLIENT_SECRET = os.environ.get("HYDRA_CLIENT_SECRET")
diff --git a/backend/helpers/kubernetes.py b/backend/helpers/kubernetes.py
index 2c49d6ec..3aa3afbb 100644
--- a/backend/helpers/kubernetes.py
+++ b/backend/helpers/kubernetes.py
@@ -67,7 +67,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
-        current_app.logger.info(
+        current_app.logger.debug(
             f"Secret {secret_name} in namespace {secret_namespace}"
             " already exists. Merging..."
         )
@@ -75,12 +75,12 @@ def create_variables_secret(app_slug, variables_filepath):
         new_secret_dict["data"] |= current_secret_data
     else:
         # Do Nothing
-        current_app.logger.info(
+        current_app.logger.debug(
             f"Secret {secret_name} in namespace {secret_namespace}"
             " is already in a good state, doing nothing."
         )
         return True
-    current_app.logger.info(
+    current_app.logger.debug(
         f"Storing secret {secret_name} in namespace"
         f" {secret_namespace} in cluster."
     )
@@ -184,9 +184,9 @@ def store_kubernetes_secret(secret_dict, namespace, update=False):
                 namespace=namespace
             )
         except FailToCreateError as ex:
-            current_app.logger.info(f"Secret not created because of exception {ex}")
+            current_app.logger.warning(f"Secret not created because of exception {ex}")
             raise ex
-    current_app.logger.info(f"Secret {verb} with api response: {api_response}")
+    current_app.logger.debug(f"Secret {verb} with api response: {api_response}")
 
 
 def store_kustomization(kustomization_template_filepath, app_slug):
@@ -212,7 +212,7 @@ def store_kustomization(kustomization_template_filepath, app_slug):
             plural="kustomizations",
             body=kustomization_dict)
     except FailToCreateError as ex:
-        current_app.logger.info(
+        current_app.logger.warning(
             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}")
@@ -244,7 +244,7 @@ def delete_kustomization(kustomization_name):
             name=kustomization_name,
             body=body)
     except ApiException as ex:
-        current_app.logger.info(
+        current_app.logger.warning(
             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}")
@@ -453,7 +453,7 @@ def watch_dashboard_config(app, reload):
                     label_selector="stackspin.net/scim-config=1",
                     watch=True
                 ):
-                current_app.logger.info(f"{event['type']} SCIM config secret: {event['object'].metadata.name}")
+                current_app.logger.debug(f"{event['type']} SCIM config secret: {event['object'].metadata.name}")
                 debounced_reload()
     threading.Thread(target=watch_scim_secrets).start()
     def watch_dashboard_configmaps():
@@ -464,7 +464,7 @@ def watch_dashboard_config(app, reload):
                     label_selector="stackspin.net/dashboard-config=1",
                     watch=True
                 ):
-                current_app.logger.info(f"{event['type']} dashboard config configmap: {event['object'].metadata.name}")
+                current_app.logger.debug(f"{event['type']} dashboard config configmap: {event['object'].metadata.name}")
                 debounced_reload()
     threading.Thread(target=watch_dashboard_configmaps).start()
 
diff --git a/backend/helpers/provision.py b/backend/helpers/provision.py
index 4c9c1599..beaba6a9 100644
--- a/backend/helpers/provision.py
+++ b/backend/helpers/provision.py
@@ -34,20 +34,20 @@ class Group:
 
     def add_member(self, user):
         if user.scim_id not in self.members:
-            logging.info(f"Adding user to dict: {user.displayName} ({user.scim_id})")
+            logging.debug(f"Adding user to dict: {user.displayName} ({user.scim_id})")
             self.members[user.scim_id] = user
             self.modified = True
 
     def remove_member(self, user):
         if user.scim_id in self.members:
-            logging.info(f"Found user to remove from dict: {user.displayName} ({user.scim_id})")
+            logging.debug(f"Found user to remove from dict: {user.displayName} ({user.scim_id})")
             del self.members[user.scim_id]
             self.modified = True
 
     def debug(self):
-        logging.info(f"Group {self.displayName} ({self.scim_id})")
+        logging.debug(f"Group {self.displayName} ({self.scim_id})")
         for _, member in self.members.items():
-            logging.info(f"  with user {member.displayName} ({member.scim_id})")
+            logging.debug(f"  with user {member.displayName} ({member.scim_id})")
 
 # Read from the database which users need to be provisioned on which apps, and
 # do the corresponding SCIM calls to those apps to do the actual provisioning.
@@ -96,7 +96,7 @@ class Provision:
         for app_id, app in self.scim_apps.items():
             existing_users[app_id] = self._get_existing_users(app)
             for userId, u in existing_users[app_id].items():
-                logging.info(f"Existing user in {app.slug}: {u.displayName} ({userId})")
+                logging.debug(f"Existing user in {app.slug}: {u.displayName} ({userId})")
             if app.scim_group_support:
                 existing_groups[app_id] = self._get_existing_groups(app)
                 for _, g in existing_groups[app_id].items():
@@ -118,7 +118,7 @@ class Provision:
             AppRole.provision_status.in_((ProvisionStatus.SyncNeeded, ProvisionStatus.ToDelete))
         )
         for app_role in app_roles:
-            # logging.info(f"found app_role: {app_role}")
+            logging.debug(f"found app_role: {app_role}")
             # Check if this app supports SCIM at all and is configured for it.
             app_role.last_provision_attempt = datetime.now()
             app = app_role.app
@@ -131,10 +131,10 @@ class Provision:
             # pre-fetched list we got via SCIM.
             existing_user = existing_users[app.id].get(app_role.user_id)
             if existing_user is not None:
-                logging.info(f"User {app_role.user_id} already exists in the app {app.slug}.")
+                logging.debug(f"User {app_role.user_id} already exists in the app {app.slug}.")
             if app_role.role_id == Role.NO_ACCESS_ROLE_ID:
                 if existing_user is None:
-                    logging.info(f"User without access does not exist yet in {app.slug}, so nothing to do.")
+                    logging.debug(f"User without access does not exist yet in {app.slug}, so nothing to do.")
                     # We have not provisioned this user in this app yet, so we
                     # don't have to do anything at this point.
                     app_role.provision_status = ProvisionStatus.Provisioned
@@ -142,7 +142,7 @@ class Provision:
                     db.session.commit()
                     continue
                 else:
-                    logging.info(f"User without access exists in the app {app.slug}; we continue so we can disable the user in the app.")
+                    logging.debug(f"User without access exists in the app {app.slug}; we continue so we can disable the user in the app.")
             try:
                 self._provision_user(app_role, app, existing_user, admin_group.get(app.id))
             except ProvisionError as ex:
@@ -152,11 +152,11 @@ class Provision:
         for app_id, app in self.scim_apps.items():
             if app.scim_group_support:
                 if admin_group[app_id].modified:
-                    logging.info(f"Admin group for {app.slug} was modified, so updating it via SCIM.")
+                    logging.debug(f"Admin group for {app.slug} was modified, so updating it via SCIM.")
                     admin_group[app_id].debug()
                     self._provision_group(admin_group[app_id], app)
                 else:
-                    logging.info(f"Admin group for {app.slug} was not modified.")
+                    logging.debug(f"Admin group for {app.slug} was not modified.")
 
     # Provision the user via SCIM PUT or POST, based on the user and role
     # information in `app_role`, and on the existing user object in the app
@@ -164,7 +164,7 @@ class Provision:
     # `admin_group` object so we may later add or remove the user to/from the
     # admin group.
     def _provision_user(self, app_role, app, existing_user, admin_group):
-        logging.info(f"Reconciling user {app_role.user_id}")
+        logging.debug(f"Reconciling user {app_role.user_id}")
         app = self.scim_apps[app.id]
         scim_headers = {
             'Authorization': 'Bearer ' + app.scim_token
@@ -177,7 +177,7 @@ class Provision:
                 logging.info(f"Deleting user {app_role.user_id} from {app.slug}")
                 url = f"{app.scim_url}Users/{existing_user.scim_id}"
                 response = requests.delete(url, headers=scim_headers)
-                logging.info(f"SCIM http status: {response.status_code}")
+                logging.debug(f"SCIM http status: {response.status_code}")
                 if response.status_code == 204:
                     db.session.delete(app_role)
                     db.session.commit()
@@ -187,7 +187,7 @@ class Provision:
             return
 
         # Get the related user object
-        logging.info(f"Getting user data from Kratos.")
+        logging.debug(f"Getting user data from Kratos.")
         try:
             kratos_user = KratosUser(self.kratos_identity_api, app_role.user_id)
         except BackendError as e:
@@ -202,7 +202,7 @@ class Provision:
             active = False
         else:
             active = True
-        logging.info(f"Active user: {active}")
+        logging.debug(f"Active user: {active}")
         data = {
             'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'],
             'externalId': app_role.user_id,
@@ -243,18 +243,18 @@ class Provision:
         if existing_user is None:
             url = f"{app.scim_url}/Users"
             response = requests.post(url, headers=scim_headers, json=data)
-            logging.info(f"Post SCIM user: {url} with data: {data} getting status: {response.status_code}")
+            logging.debug(f"Post SCIM user: {url} with data: {data} getting status: {response.status_code}")
         else:
             url = f"{app.scim_url}/Users/{existing_user.scim_id}"
             response = requests.put(url, headers=scim_headers, json=data)
-            logging.info(f"Put SCIM user: {url} with data: {data} getting status: {response.status_code}")
+            logging.debug(f"Put SCIM user: {url} with data: {data} getting status: {response.status_code}")
         try:
             response_json = response.json()
         except json.decoder.JSONDecodeError as e:
             logging.info("SCIM result was not json")
             logging.info(response.content)
             raise ProvisionError("App returned non-json data in SCIM user put/post.")
-        logging.info(f"got: {response_json}")
+        logging.debug(f"got: {response_json}")
         if existing_user is None:
             # Because this is a new user for the app, we should read off its
             # SCIM ID and store that in the Stackspin database.
@@ -263,12 +263,12 @@ class Provision:
         user = User(app_role.user_id, response_json['id'], kratos_user.name)
         if app.scim_group_support:
             if app_role.role_id == Role.ADMIN_ROLE_ID:
-                logging.info(f"Adding user to admin group: {user.displayName} ({user.kratos_id})")
+                logging.debug(f"Adding user to admin group: {user.displayName} ({user.kratos_id})")
                 admin_group.add_member(user)
             else:
-                logging.info(f"Removing user from admin group: {user.displayName} ({user.kratos_id})")
+                logging.debug(f"Removing user from admin group: {user.displayName} ({user.kratos_id})")
                 admin_group.remove_member(user)
-            logging.info("After adding/removing user:")
+            logging.debug("After adding/removing user:")
             admin_group.debug()
 
         app_role.status = ProvisionStatus.Provisioned
@@ -277,7 +277,7 @@ class Provision:
         return
 
     def _scim_list_users(self, app):
-        logging.info(f"Info: Getting list of current users from {app.slug} via SCIM.")
+        logging.debug(f"Getting list of current users from {app.slug} via SCIM.")
         # SCIM prescribes a 1-based index.
         startIndex = 1
         # Get this many items per request. The application might further reduce
@@ -292,7 +292,7 @@ class Provision:
                 'Authorization': 'Bearer ' + app.scim_token
             }
             response = requests.get(url, headers=scim_headers)
-            logging.info(f"SCIM http status: {response.status_code}")
+            logging.debug(f"SCIM http status: {response.status_code}")
             try:
                 response_json = response.json()
             except json.decoder.JSONDecodeError as e:
@@ -305,7 +305,7 @@ class Provision:
             running_total = running_total + added
             if running_total == response_json['totalResults'] or added == 0:
                 # We've got them all.
-                logging.info(f"All existing users for {app.slug}: {users}")
+                logging.debug(f"All existing users for {app.slug}: {users}")
                 return users
             else:
                 startIndex = startIndex + added
@@ -318,7 +318,7 @@ class Provision:
         for u in scim_users:
             kratos_id = u.get('externalId')
             if not kratos_id:
-                logging.info(f"Got user without externalId: {u}")
+                logging.debug(f"Got user without externalId: {u}")
                 # Users that were created just-in-time when logging in to the
                 # app will not have `externalId` set, so we attempt to look up
                 # the user from our Stackspin database based on the app ID and
@@ -328,7 +328,7 @@ class Provision:
                     scim_id=u['id']
                 ).first()
                 if app_role is None:
-                    logging.info(f"  SCIM ID {u['id']} not listed in database.")
+                    logging.debug(f"  SCIM ID {u['id']} not listed in database.")
                     # We can't find this app user in our Stackspin database, at
                     # least based on the SCIM ID. It could be that it was
                     # created before the introduction of SCIM, or was created
@@ -350,7 +350,7 @@ class Provision:
                     if kratos_user is None:
                         # This user is not known at all by Stackspin, so
                         # we'll ignore it.
-                        logging.info(f"  SCIM user unknown, ignoring.")
+                        logging.debug(f"  SCIM user unknown, ignoring.")
                         continue
                     # We found the user based on email address. We'll
                     # store the SCIM ID for this user in the Stackspin
@@ -363,7 +363,7 @@ class Provision:
                     if app_role is not None:
                         app_role.scim_id = u['id']
                         db.session.commit()
-                        logging.info(f"  Stored SCIM ID {u['id']} for user {kratos_user.uuid} for app {app.slug}")
+                        logging.debug(f"  Stored SCIM ID {u['id']} for user {kratos_user.uuid} for app {app.slug}")
                     kratos_id = kratos_user.uuid
                 else:
                     kratos_id = app_role.user_id
@@ -371,20 +371,20 @@ class Provision:
         return users
 
     def _get_existing_groups(self, app):
-        logging.info(f"Info: Getting list of current groups from {app.slug} via SCIM.")
+        logging.debug(f"Getting list of current groups from {app.slug} via SCIM.")
         url = f"{app.scim_url}/Groups"
         scim_headers = {
             'Authorization': 'Bearer ' + app.scim_token
         }
         response = requests.get(url, headers=scim_headers)
-        logging.info(f"SCIM http status: {response.status_code}")
+        logging.debug(f"SCIM http status: {response.status_code}")
         try:
             response_json = response.json()
         except json.decoder.JSONDecodeError as e:
             logging.info("SCIM result was not json")
             logging.info(response.content)
             raise ProvisionError("Failed to get existing groups from {app.slug}")
-        logging.info(f"got: {response_json}")
+        logging.debug(f"got: {response_json}")
         groups = {}
         for group in response_json['Resources']:
             members = {}
@@ -395,14 +395,14 @@ class Provision:
         return groups
 
     def _provision_group(self, group, app):
-        logging.info(f"Reconciling group {group.scim_id}")
+        logging.debug(f"Reconciling group {group.scim_id}")
         scim_headers = {
             'Authorization': 'Bearer ' + app.scim_token
         }
         member_data = [
             {'value': member.scim_id, 'display': member.displayName, '$ref': member.ref(app.scim_url)}
             for _, member in group.members.items()]
-        logging.info(f"Will update admin group for {app.slug} with member data {member_data}")
+        logging.debug(f"Will update admin group for {app.slug} with member data {member_data}")
         data = {
             'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Group'],
             'displayName': group.displayName,
@@ -410,7 +410,7 @@ class Provision:
         }
         url = f"{app.scim_url}/Groups/{group.scim_id}"
         response = requests.put(url, headers=scim_headers, json=data)
-        logging.info(f"SCIM http status: {response.status_code}")
+        logging.debug(f"SCIM http status: {response.status_code}")
         try:
             response_json = response.json()
         except json.decoder.JSONDecodeError as e:
diff --git a/dev.sh b/dev.sh
index 38591317..fdf7d7e8 100755
--- a/dev.sh
+++ b/dev.sh
@@ -103,7 +103,7 @@ runBackend() {
       echo "Stopping any previous intercept for dashboard-backend..."
       telepresence leave dashboard-backend
       echo "Starting new intercept for dashboard-backend..."
-      telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --replace --env-file=./backend.env -- env TELEPRESENCE_MODE=native flask run --reload
+      telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --replace --env-file=./backend.env -- env TELEPRESENCE_MODE=native FLASK_DEBUG=1 LOG_LEVEL=DEBUG flask run --reload
       deactivate
       popd > /dev/null
       ;;
-- 
GitLab