mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 09:16:10 -06:00
added db read-only mode implementation
This commit is contained in:
parent
d7c37d9dd6
commit
6346e4dd60
115
netbox/netbox/cursor.py
Normal file
115
netbox/netbox/cursor.py
Normal file
@ -0,0 +1,115 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.backends.utils import CursorWrapper as _CursorWrapper
|
||||
|
||||
from netbox.exceptions import DatabaseWriteDenied
|
||||
|
||||
logger = logging.getLogger('netbox.db')
|
||||
|
||||
class ReadOnlyCursorWrapper:
|
||||
"""
|
||||
A read-only wrapper around a database cursor.
|
||||
|
||||
This wrapper prevents write operations from being performed on the database. It is used to prevent changes to the
|
||||
database during a read-only request. It is not intended to be used directly; rather, it is applied automatically by
|
||||
the ReadOnlyMiddleware. See the documentation for that class for more information.
|
||||
"""
|
||||
|
||||
SQL_BLACKLIST = (
|
||||
# Data definition
|
||||
'CREATE',
|
||||
'ALTER',
|
||||
'DROP',
|
||||
'TRUNCATE',
|
||||
'RENAME',
|
||||
# Data manipulation
|
||||
'INSERT',
|
||||
'UPDATE',
|
||||
'DELETE',
|
||||
'MERGE',
|
||||
'REPLACE',
|
||||
)
|
||||
|
||||
def __init__(self, cursor, db, *args, **kwargs):
|
||||
self.cursor = cursor
|
||||
self.db = db
|
||||
self.read_only = settings.MAINTENANCE_MODE
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
# Check the SQL
|
||||
if self.read_only and self._write_sql(sql):
|
||||
raise DatabaseWriteDenied
|
||||
|
||||
return self.cursor.execute(sql, params)
|
||||
|
||||
def executemany(self, sql, param_list):
|
||||
# Check the SQL
|
||||
if self.read_only and self._write_sql(sql):
|
||||
raise DatabaseWriteDenied
|
||||
|
||||
return self.cursor.executemany(sql, param_list)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self.cursor, item)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.cursor)
|
||||
|
||||
def _write_sql(self, sql):
|
||||
"""
|
||||
Check the SQL to determine if it is a write operation.
|
||||
"""
|
||||
return any(
|
||||
s.strip().upper().startswith(self.SQL_BLACKLIST) for s in sql.split(';')
|
||||
)
|
||||
|
||||
@property
|
||||
def _last_executed(self):
|
||||
return getattr(self.cursor, '_last_executed', '')
|
||||
|
||||
class CursorWrapper(_CursorWrapper):
|
||||
def __init__(self, cursor, db):
|
||||
self.cursor = ReadOnlyCursorWrapper(cursor, db)
|
||||
self.db = db
|
||||
|
||||
class CursorDebugWrapper(CursorWrapper):
|
||||
def execute(self, sql, params=None):
|
||||
start = time.time()
|
||||
try:
|
||||
return self.cursor.execute(sql, params)
|
||||
finally:
|
||||
stop = time.time()
|
||||
duration = stop - start
|
||||
sql = self.db.ops.last_executed_query(self.cursor, sql, params)
|
||||
self.db.queries_log.append({
|
||||
'sql': sql,
|
||||
'time': '%.3f' % duration,
|
||||
})
|
||||
logger.debug(
|
||||
"(%.3f) %s; args=%s",
|
||||
duration,
|
||||
sql,
|
||||
params,
|
||||
extra={"duration": duration, "sql": sql, "params": params},
|
||||
)
|
||||
|
||||
def executemany(self, sql, param_list):
|
||||
start = time.time()
|
||||
try:
|
||||
return self.cursor.executemany(sql, param_list)
|
||||
finally:
|
||||
stop = time.time()
|
||||
duration = stop - start
|
||||
self.db.queries.append({
|
||||
"sql": "%s times: %s" % (len(param_list), sql),
|
||||
"time": "%.3f" % duration,
|
||||
})
|
||||
logger.debug(
|
||||
"(%.3f) %s; args=%s",
|
||||
duration,
|
||||
sql,
|
||||
param_list,
|
||||
extra={"duration": duration, "sql": sql, "params": param_list},
|
||||
)
|
8
netbox/netbox/exceptions.py
Normal file
8
netbox/netbox/exceptions.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.db.utils import DatabaseError
|
||||
|
||||
|
||||
class DatabaseWriteDenied(DatabaseError):
|
||||
"""
|
||||
Custom exception raised when a write operation is attempted in maintenance mode.
|
||||
"""
|
||||
pass
|
@ -3,16 +3,19 @@ import uuid
|
||||
from urllib import parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.contrib import auth, messages
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import ProgrammingError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.encoding import iri_to_uri
|
||||
|
||||
from extras.context_managers import change_logging
|
||||
from netbox.config import clear_config
|
||||
from netbox.exceptions import DatabaseWriteDenied
|
||||
from netbox.views import handler_500
|
||||
from utilities.api import is_api_request, rest_api_server_error
|
||||
from utilities.api import is_api_request
|
||||
|
||||
|
||||
class LoginRequiredMiddleware:
|
||||
@ -202,3 +205,44 @@ class ExceptionHandlingMiddleware:
|
||||
# Return a custom error message, or fall back to Django's default 500 error handling
|
||||
if custom_template:
|
||||
return handler_500(request, template_name=custom_template)
|
||||
|
||||
|
||||
class HttpResponseReload(HttpResponse):
|
||||
"""
|
||||
Reload page and stay on the same page from where request was made.
|
||||
"""
|
||||
status_code = 302
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
referrer = request.META.get('HTTP_REFERER', '/')
|
||||
self['Location'] = iri_to_uri(referrer)
|
||||
|
||||
|
||||
class DatabaseReadOnlyMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Process exceptions raised by the database when a write operation is attempted while the database is in read-only
|
||||
"""
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
# Only process DatabaseWriteDenied exceptions
|
||||
if not isinstance(exception, DatabaseWriteDenied):
|
||||
return None
|
||||
|
||||
not_allowed_methods = ['POST', 'PUT', 'PATCH', 'DELETE']
|
||||
error_message = 'The database is currently in read-only mode. Please try again later.'
|
||||
status_code = 503
|
||||
|
||||
# If the request is an API request, return a 503 Service Unavailable response
|
||||
if is_api_request(request) and request.method in not_allowed_methods:
|
||||
return JsonResponse({'detail': error_message, }, status=status_code)
|
||||
else:
|
||||
# Handle exceptions
|
||||
if request.method in not_allowed_methods:
|
||||
# Display a message to the user
|
||||
messages.error(request, error_message)
|
||||
|
||||
# Redirect back to the referring page
|
||||
return HttpResponseReload(request)
|
||||
else:
|
||||
return HttpResponse(error_message, status=status_code)
|
||||
|
@ -357,6 +357,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'netbox.middleware.DatabaseReadOnlyMiddleware',
|
||||
'netbox.middleware.ExceptionHandlingMiddleware',
|
||||
'netbox.middleware.RemoteUserMiddleware',
|
||||
'netbox.middleware.LoginRequiredMiddleware',
|
||||
@ -751,3 +752,10 @@ for plugin_name in PLUGINS:
|
||||
RQ_QUEUES.update({
|
||||
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
|
||||
})
|
||||
|
||||
# Monkey patch CursorWrapper and CursorDebugWrapper to enable read-only mode
|
||||
if getattr(configuration, 'MAINTENANCE_MODE', False):
|
||||
from django.db.backends import utils
|
||||
from netbox.cursor import CursorWrapper, CursorDebugWrapper
|
||||
utils.CursorWrapper = CursorWrapper
|
||||
utils.CursorDebugWrapper = CursorDebugWrapper
|
||||
|
Loading…
Reference in New Issue
Block a user