mirror of
https://github.com/bunkerity/bunkerweb
synced 2026-05-24 09:28:37 +00:00
Merge pull request #1214 from bunkerity/dev
Merge branch "dev" into branch "staging"
This commit is contained in:
commit
199057beac
7 changed files with 146 additions and 78 deletions
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from contextlib import suppress
|
||||
from os import getenv
|
||||
from time import sleep
|
||||
from copy import deepcopy
|
||||
|
|
@ -119,6 +120,10 @@ class Config(ConfigCaller):
|
|||
}
|
||||
)
|
||||
|
||||
err = self.try_database_readonly()
|
||||
if err:
|
||||
return False
|
||||
|
||||
while not self._db.is_initialized():
|
||||
self.__logger.warning("Database is not initialized, retrying in 5 seconds ...")
|
||||
sleep(5)
|
||||
|
|
@ -140,6 +145,15 @@ class Config(ConfigCaller):
|
|||
if err:
|
||||
self.__logger.error(f"Failed to update instances: {err}")
|
||||
|
||||
# save config to database
|
||||
changed_plugins = []
|
||||
if "config" in changes:
|
||||
err = self._db.save_config(self.__config, "autoconf", changed=False)
|
||||
if isinstance(err, str):
|
||||
success = False
|
||||
self.__logger.error(f"Can't save config in database: {err}, config may not work as expected")
|
||||
changed_plugins = err
|
||||
|
||||
# save custom configs to database
|
||||
if "custom_configs" in changes:
|
||||
err = self._db.save_custom_configs(custom_configs, "autoconf", changed=False)
|
||||
|
|
@ -147,16 +161,39 @@ class Config(ConfigCaller):
|
|||
success = False
|
||||
self.__logger.error(f"Can't save autoconf custom configs in database: {err}, custom configs may not work as expected")
|
||||
|
||||
# save config to database
|
||||
if "config" in changes:
|
||||
err = self._db.save_config(self.__config, "autoconf")
|
||||
if err:
|
||||
success = False
|
||||
self.__logger.error(f"Can't save config in database: {err}, config may not work as expected")
|
||||
else:
|
||||
# update changes in db
|
||||
ret = self._db.checked_changes(changes, value=True)
|
||||
if ret:
|
||||
self.__logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
|
||||
# update changes in db
|
||||
ret = self._db.checked_changes(changes, plugins_changes=changed_plugins, value=True)
|
||||
if ret:
|
||||
self.__logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
|
||||
|
||||
return success
|
||||
|
||||
def _try_database_readonly(self) -> bool:
|
||||
if not self.db.readonly:
|
||||
try:
|
||||
self.db.test_write()
|
||||
except BaseException:
|
||||
self.db.readonly = True
|
||||
return True
|
||||
|
||||
if self.db.database_uri and self.db.readonly:
|
||||
try:
|
||||
self.db.retry_connection(pool_timeout=1)
|
||||
self.db.retry_connection(log=False)
|
||||
self.db.readonly = False
|
||||
self.__logger.info("The database is no longer read-only, defaulting to read-write mode")
|
||||
except BaseException:
|
||||
try:
|
||||
self.db.retry_connection(readonly=True, pool_timeout=1)
|
||||
self.db.retry_connection(readonly=True, log=False)
|
||||
except BaseException:
|
||||
if self.db.database_uri_readonly:
|
||||
with suppress(BaseException):
|
||||
self.db.retry_connection(fallback=True, pool_timeout=1)
|
||||
self.db.retry_connection(fallback=True, log=False)
|
||||
self.db.readonly = True
|
||||
|
||||
if self.db.readonly:
|
||||
self.__logger.error("Database is in read-only mode, configuration will not be saved")
|
||||
|
||||
return self.db.readonly
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from os.path import join
|
|||
from pathlib import Path
|
||||
from re import compile as re_compile
|
||||
from sys import argv, path as sys_path
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
||||
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union
|
||||
from time import sleep
|
||||
from uuid import uuid4
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
|
@ -75,7 +75,6 @@ class Database:
|
|||
"""Initialize the database"""
|
||||
self.logger = logger
|
||||
self.readonly = False
|
||||
self.last_fallback = None
|
||||
|
||||
if pool:
|
||||
self.logger.warning("The pool parameter is deprecated, it will be removed in the next version")
|
||||
|
|
@ -130,6 +129,7 @@ class Database:
|
|||
"pool_recycle": 1800,
|
||||
"pool_size": 40,
|
||||
"max_overflow": 20,
|
||||
"pool_timeout": 5,
|
||||
} | kwargs
|
||||
|
||||
try:
|
||||
|
|
@ -180,7 +180,6 @@ class Database:
|
|||
self.sql_engine.dispose(close=True)
|
||||
self.sql_engine = create_engine(self.database_uri_readonly, **self._engine_kwargs)
|
||||
self.readonly = True
|
||||
self.last_fallback = datetime.now()
|
||||
fallback = True
|
||||
continue
|
||||
self.logger.error(f"Can't connect to database after {DATABASE_RETRY_TIMEOUT} seconds: {e}")
|
||||
|
|
@ -192,7 +191,6 @@ class Database:
|
|||
self.sql_engine.dispose(close=True)
|
||||
self.sql_engine = create_engine(sqlalchemy_string, **self._engine_kwargs)
|
||||
self.readonly = True
|
||||
self.last_fallback = datetime.now()
|
||||
if "Unknown table" in str(e):
|
||||
not_connected = False
|
||||
continue
|
||||
|
|
@ -218,16 +216,19 @@ class Database:
|
|||
def test_write(self):
|
||||
"""Test the write access to the database"""
|
||||
self.logger.debug("Testing write access to the database ...")
|
||||
self.retry_connection(pool_timeout=1)
|
||||
with self.__db_session() as session:
|
||||
table_name = uuid4().hex
|
||||
session.execute(text(f"CREATE TABLE IF NOT EXISTS test_{table_name} (id INT)"))
|
||||
session.execute(text(f"DROP TABLE IF EXISTS test_{table_name}"))
|
||||
session.commit()
|
||||
self.retry_connection()
|
||||
|
||||
def retry_connection(self, *, readonly: bool = False, fallback: bool = False, **kwargs) -> None:
|
||||
def retry_connection(self, *, readonly: bool = False, fallback: bool = False, log: bool = True, **kwargs) -> None:
|
||||
"""Retry the connection to the database"""
|
||||
|
||||
self.logger.debug(f"Retrying the connection to the database {'in read-only mode' if readonly else ''}{' with fallback' if fallback else ''} ...")
|
||||
if log:
|
||||
self.logger.debug(f"Retrying the connection to the database{' in read-only mode' if readonly else ''}{' with fallback' if fallback else ''} ...")
|
||||
|
||||
assert self.sql_engine is not None
|
||||
|
||||
|
|
@ -254,21 +255,6 @@ class Database:
|
|||
self.logger.error("The database engine is not initialized")
|
||||
_exit(1)
|
||||
|
||||
if self.database_uri and self.readonly and self.last_fallback and (datetime.now() - self.last_fallback).total_seconds() > 30:
|
||||
# ? If the database is forced to be read-only, we try to connect as a non read-only user every time until the database is writable
|
||||
try:
|
||||
self.retry_connection(pool_timeout=1)
|
||||
self.readonly = False
|
||||
self.logger.info("The database is no longer read-only, defaulting to read-write mode")
|
||||
except (OperationalError, DatabaseError):
|
||||
try:
|
||||
self.retry_connection(readonly=True, pool_timeout=1)
|
||||
except (OperationalError, DatabaseError):
|
||||
if self.database_uri_readonly:
|
||||
with suppress(OperationalError, DatabaseError):
|
||||
self.retry_connection(fallback=True, pool_timeout=1)
|
||||
self.readonly = True
|
||||
|
||||
session = None
|
||||
try:
|
||||
with self.sql_engine.connect() as conn:
|
||||
|
|
@ -283,19 +269,20 @@ class Database:
|
|||
self.logger.warning("The database is read-only, retrying in read-only mode ...")
|
||||
try:
|
||||
self.retry_connection(readonly=True, pool_timeout=1)
|
||||
self.retry_connection(readonly=True, log=False)
|
||||
except (OperationalError, DatabaseError):
|
||||
if self.database_uri_readonly:
|
||||
self.logger.warning("Can't connect to the database in read-only mode, falling back to read-only one")
|
||||
with suppress(OperationalError, DatabaseError):
|
||||
self.retry_connection(fallback=True, pool_timeout=1)
|
||||
self.retry_connection(fallback=True, log=False)
|
||||
self.readonly = True
|
||||
self.last_fallback = datetime.now()
|
||||
elif isinstance(e, (ConnectionRefusedError, OperationalError)) and self.database_uri_readonly:
|
||||
self.logger.warning("Can't connect to the database, falling back to read-only one ...")
|
||||
with suppress(OperationalError, DatabaseError):
|
||||
self.retry_connection(fallback=True, pool_timeout=1)
|
||||
self.retry_connection(fallback=True, log=False)
|
||||
self.readonly = True
|
||||
self.last_fallback = datetime.now()
|
||||
raise
|
||||
finally:
|
||||
if session:
|
||||
|
|
@ -512,9 +499,15 @@ class Database:
|
|||
except BaseException as e:
|
||||
return str(e)
|
||||
|
||||
def checked_changes(self, changes: Optional[List[str]] = None, value: Optional[bool] = False) -> str:
|
||||
def checked_changes(
|
||||
self,
|
||||
changes: Optional[List[str]] = None,
|
||||
plugins_changes: Optional[Union[Literal["all"], Set[str], List[str], Tuple[str]]] = None,
|
||||
value: Optional[bool] = False,
|
||||
) -> str:
|
||||
"""Set changed bit for config, custom configs, instances and plugins"""
|
||||
changes = changes or ["config", "custom_configs", "external_plugins", "pro_plugins", "instances"]
|
||||
plugins_changes = plugins_changes or set()
|
||||
with self.__db_session() as session:
|
||||
if self.readonly:
|
||||
return "The database is read-only, the changes will not be saved"
|
||||
|
|
@ -536,25 +529,13 @@ class Database:
|
|||
metadata.pro_plugins_changed = value
|
||||
if "instances" in changes:
|
||||
metadata.instances_changed = value
|
||||
session.commit()
|
||||
except BaseException as e:
|
||||
return str(e)
|
||||
|
||||
return ""
|
||||
if plugins_changes:
|
||||
if plugins_changes == "all":
|
||||
session.query(Plugins).update({Plugins.config_changed: value})
|
||||
else:
|
||||
session.query(Plugins).filter(Plugins.id.in_(plugins_changes)).update({Plugins.config_changed: value})
|
||||
|
||||
def checked_plugins_changes(self, plugins: Optional[List[str]] = None, value: Optional[bool] = False) -> str:
|
||||
"""Set changed bit for plugins"""
|
||||
with self.__db_session() as session:
|
||||
if self.readonly:
|
||||
return "The database is read-only, the changes will not be saved"
|
||||
|
||||
plugins = plugins or []
|
||||
|
||||
try:
|
||||
query = session.query(Plugins)
|
||||
if plugins:
|
||||
query = query.filter(Plugins.id.in_(plugins))
|
||||
query.update({Plugins.config_changed: value})
|
||||
session.commit()
|
||||
except BaseException as e:
|
||||
return str(e)
|
||||
|
|
@ -1130,7 +1111,7 @@ class Database:
|
|||
|
||||
return True, ""
|
||||
|
||||
def save_config(self, config: Dict[str, Any], method: str, changed: Optional[bool] = True) -> str:
|
||||
def save_config(self, config: Dict[str, Any], method: str, changed: Optional[bool] = True) -> Union[str, Set[str]]:
|
||||
"""Save the config in the database"""
|
||||
to_put = []
|
||||
with self.__db_session() as session:
|
||||
|
|
@ -1331,6 +1312,9 @@ class Database:
|
|||
continue
|
||||
query.update({Global_values.value: value})
|
||||
|
||||
if changed_services:
|
||||
changed_plugins = set(plugin.id for plugin in session.query(Plugins).with_entities(Plugins.id).all())
|
||||
|
||||
if changed:
|
||||
with suppress(ProgrammingError, OperationalError):
|
||||
metadata = session.query(Metadata).get(1)
|
||||
|
|
@ -1338,9 +1322,7 @@ class Database:
|
|||
if not metadata.first_config_saved:
|
||||
metadata.first_config_saved = True
|
||||
|
||||
if changed_services:
|
||||
session.query(Plugins).update({Plugins.config_changed: True})
|
||||
elif changed_plugins:
|
||||
if changed_plugins:
|
||||
session.query(Plugins).filter(Plugins.id.in_(changed_plugins)).update({Plugins.config_changed: True})
|
||||
|
||||
try:
|
||||
|
|
@ -1349,7 +1331,7 @@ class Database:
|
|||
except BaseException as e:
|
||||
return str(e)
|
||||
|
||||
return ""
|
||||
return changed_plugins
|
||||
|
||||
def save_custom_configs(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in ((
|
|||
sys_path.append(deps_path)
|
||||
|
||||
from docker import DockerClient
|
||||
from kubernetes import client as kube_client
|
||||
from kubernetes import config as kube_config
|
||||
|
||||
from common_utils import get_integration # type: ignore
|
||||
from logger import setup_logger # type: ignore
|
||||
|
|
@ -142,6 +144,8 @@ if __name__ == "__main__":
|
|||
logger.error(f"Missing RX rights on directory : {path}")
|
||||
sys_exit(1)
|
||||
|
||||
tmp_config = {}
|
||||
|
||||
if args.variables:
|
||||
variables_path = Path(args.variables)
|
||||
logger.info(f"Variables : {variables_path}")
|
||||
|
|
@ -178,7 +182,6 @@ if __name__ == "__main__":
|
|||
|
||||
api_http_port = None
|
||||
api_server_name = None
|
||||
tmp_config = {}
|
||||
custom_confs = []
|
||||
apis = []
|
||||
|
||||
|
|
@ -216,6 +219,49 @@ if __name__ == "__main__":
|
|||
host=api_server_name or getenv("API_SERVER_NAME", "bwapi"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
kube_config.load_incluster_config()
|
||||
kubernetes_client = kube_client.CoreV1Api()
|
||||
|
||||
api_http_port = None
|
||||
api_server_name = None
|
||||
custom_confs = []
|
||||
apis = []
|
||||
|
||||
for pod in kubernetes_client.list_pod_for_all_namespaces(watch=False).items:
|
||||
if pod.metadata.annotations is not None and "bunkerweb.io/INSTANCE" in pod.metadata.annotations:
|
||||
for env in pod.env:
|
||||
if custom_confs_rx.match(env.name):
|
||||
custom_conf = custom_confs_rx.search(env.name).groups()
|
||||
custom_confs.append(
|
||||
{
|
||||
"value": f"# CREATED BY ENV\n{env.value}",
|
||||
"exploded": (
|
||||
custom_conf[0],
|
||||
custom_conf[1],
|
||||
custom_conf[2].replace(".conf", ""),
|
||||
),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"Found custom conf env var {'for service ' + custom_conf[0] if custom_conf[0] else 'without service'} with type {custom_conf[1]} and name {custom_conf[2]}"
|
||||
)
|
||||
else:
|
||||
tmp_config[env.name] = env.value
|
||||
|
||||
if not db and env.name == "DATABASE_URI":
|
||||
db = Database(logger, sqlalchemy_string=env.value)
|
||||
elif env.name == "API_HTTP_PORT":
|
||||
api_http_port = env.value
|
||||
elif env.name == "API_SERVER_NAME":
|
||||
api_server_name = env.value
|
||||
|
||||
apis.append(
|
||||
API(
|
||||
f"http://{pod.status.pod_ip or pod.metadata.name}:{api_http_port or getenv('API_HTTP_PORT', '5000')}",
|
||||
host=api_server_name or getenv("API_SERVER_NAME", "bwapi"),
|
||||
)
|
||||
)
|
||||
|
||||
if not db:
|
||||
db = Database(logger)
|
||||
|
|
@ -278,11 +324,13 @@ if __name__ == "__main__":
|
|||
sys_exit(0)
|
||||
|
||||
changes = []
|
||||
changed_plugins = set()
|
||||
err = db.save_config(config_files, args.method, changed=False)
|
||||
|
||||
if err:
|
||||
if isinstance(err, str):
|
||||
logger.warning(f"Couldn't save config to database : {err}, config may not work as expected")
|
||||
else:
|
||||
changed_plugins = err
|
||||
changes.append("config")
|
||||
logger.info("Config successfully saved to database")
|
||||
|
||||
|
|
@ -327,7 +375,7 @@ if __name__ == "__main__":
|
|||
|
||||
if not args.no_check_changes:
|
||||
# update changes in db
|
||||
ret = db.checked_changes(changes, value=True)
|
||||
ret = db.checked_changes(changes, plugins_changes=changed_plugins, value=True)
|
||||
if ret:
|
||||
logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
|
||||
except SystemExit as e:
|
||||
|
|
|
|||
|
|
@ -367,19 +367,21 @@ class JobScheduler(ApiCaller):
|
|||
if self.db.database_uri and self.db.readonly:
|
||||
try:
|
||||
self.db.retry_connection(pool_timeout=1)
|
||||
self.db.retry_connection(log=False)
|
||||
self.db.readonly = False
|
||||
self.__logger.info("The database is no longer read-only, defaulting to read-write mode")
|
||||
except BaseException:
|
||||
try:
|
||||
self.db.retry_connection(readonly=True, pool_timeout=1)
|
||||
self.db.retry_connection(readonly=True, log=False)
|
||||
except BaseException:
|
||||
if self.db.database_uri_readonly:
|
||||
with suppress(BaseException):
|
||||
self.db.retry_connection(fallback=True, pool_timeout=1)
|
||||
self.db.retry_connection(fallback=True, log=False)
|
||||
self.db.readonly = True
|
||||
|
||||
if self.db.readonly:
|
||||
self.__logger.error("Database is in read-only mode, jobs will not be executed")
|
||||
return True
|
||||
|
||||
return self.db.readonly
|
||||
|
|
|
|||
|
|
@ -747,12 +747,9 @@ if __name__ == "__main__":
|
|||
logger.error(f"Exception while reloading after running jobs once scheduling : {format_exc()}")
|
||||
|
||||
try:
|
||||
ret = SCHEDULER.db.checked_changes(CHANGES)
|
||||
ret = SCHEDULER.db.checked_changes(CHANGES, plugins_changes="all")
|
||||
if ret:
|
||||
logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
|
||||
ret = SCHEDULER.db.checked_plugins_changes(changed_plugins)
|
||||
if ret:
|
||||
logger.error(f"An error occurred when setting the plugins changes to checked in the database : {ret}")
|
||||
except BaseException as e:
|
||||
logger.error(f"Error while setting changes to checked in the database: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -407,8 +407,6 @@ def set_csp_header(response):
|
|||
+ " base-uri 'self';"
|
||||
+ (" connect-src *;" if request.path.startswith(("/check", "/setup")) else "")
|
||||
)
|
||||
if app.config["DB"].readonly:
|
||||
flash("Database connection is in read-only mode : no modification possible.", "error")
|
||||
|
||||
return response
|
||||
|
||||
|
|
@ -447,18 +445,20 @@ def before_request():
|
|||
if app.config["DB"].database_uri and app.config["DB"].readonly:
|
||||
try:
|
||||
app.config["DB"].retry_connection(pool_timeout=1)
|
||||
app.config["DB"].retry_connection(log=False)
|
||||
app.config["DB"].readonly = False
|
||||
app.logger.info("The database is no longer read-only, defaulting to read-write mode")
|
||||
except BaseException:
|
||||
try:
|
||||
app.config["DB"].retry_connection(readonly=True, pool_timeout=1)
|
||||
app.config["DB"].retry_connection(readonly=True, log=False)
|
||||
except BaseException:
|
||||
if app.config["DB"].database_uri_readonly:
|
||||
with suppress(BaseException):
|
||||
app.config["DB"].retry_connection(fallback=True, pool_timeout=1)
|
||||
app.config["DB"].retry_connection(fallback=True, log=False)
|
||||
app.config["DB"].readonly = True
|
||||
|
||||
if not app.config["DB"].readonly and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
|
||||
elif not app.config["DB"].readonly and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
|
||||
try:
|
||||
app.config["DB"].test_write()
|
||||
except BaseException:
|
||||
|
|
@ -481,6 +481,9 @@ def before_request():
|
|||
logout_user()
|
||||
session.clear()
|
||||
|
||||
if app.config["DB"].readonly:
|
||||
flash("Database connection is in read-only mode : no modification possible.", "error")
|
||||
|
||||
|
||||
@app.route("/", strict_slashes=False)
|
||||
def index():
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from flask import flash
|
|||
from json import loads as json_loads
|
||||
from pathlib import Path
|
||||
from re import error as RegexError, search as re_search
|
||||
from typing import List, Literal, Optional, Tuple
|
||||
from typing import List, Literal, Optional, Set, Tuple, Union
|
||||
|
||||
|
||||
class Config:
|
||||
|
|
@ -15,7 +15,9 @@ class Config:
|
|||
self.__settings = json_loads(Path(sep, "usr", "share", "bunkerweb", "settings.json").read_text(encoding="utf-8"))
|
||||
self.__db = db
|
||||
|
||||
def __gen_conf(self, global_conf: dict, services_conf: list[dict], *, check_changes: bool = True, changed_service: Optional[str] = None) -> None:
|
||||
def __gen_conf(
|
||||
self, global_conf: dict, services_conf: list[dict], *, check_changes: bool = True, changed_service: Optional[str] = None
|
||||
) -> Union[str, Set[str]]:
|
||||
"""Generates the nginx configuration file from the given configuration
|
||||
|
||||
Parameters
|
||||
|
|
@ -136,9 +138,6 @@ class Config:
|
|||
|
||||
return error
|
||||
|
||||
def reload_config(self) -> Optional[str]:
|
||||
return self.__gen_conf(self.get_config(methods=False), self.get_services(methods=False))
|
||||
|
||||
def new_service(self, variables: dict, is_draft: bool = False) -> Tuple[str, int]:
|
||||
"""Creates a new service from the given variables
|
||||
|
||||
|
|
@ -165,7 +164,7 @@ class Config:
|
|||
|
||||
services.append(variables | {"IS_DRAFT": "yes" if is_draft else "no"})
|
||||
ret = self.__gen_conf(self.get_config(methods=False), services, check_changes=not is_draft)
|
||||
if ret:
|
||||
if isinstance(ret, str):
|
||||
return ret, 1
|
||||
return f"Configuration for {variables['SERVER_NAME'].split(' ')[0]} has been generated.", 0
|
||||
|
||||
|
|
@ -205,7 +204,7 @@ class Config:
|
|||
config.pop(k)
|
||||
|
||||
ret = self.__gen_conf(config, services, check_changes=check_changes, changed_service=server_name_splitted[0])
|
||||
if ret:
|
||||
if isinstance(ret, str):
|
||||
return ret, 1
|
||||
return f"Configuration for {old_server_name_splitted[0]} has been edited.", 0
|
||||
|
||||
|
|
@ -223,7 +222,7 @@ class Config:
|
|||
the confirmation message
|
||||
"""
|
||||
ret = self.__gen_conf(self.get_config(methods=False) | variables, self.get_services(methods=False))
|
||||
if ret:
|
||||
if isinstance(ret, str):
|
||||
return ret, 1
|
||||
return "The global configuration has been edited.", 0
|
||||
|
||||
|
|
@ -273,6 +272,6 @@ class Config:
|
|||
service.pop(k)
|
||||
|
||||
ret = self.__gen_conf(new_env, new_services, check_changes=check_changes)
|
||||
if ret:
|
||||
if isinstance(ret, str):
|
||||
return ret, 1
|
||||
return f"Configuration for {service_name} has been deleted.", 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue