use filelock to ensure atomic config updates from endpoint

This commit is contained in:
Josh Hawkins 2026-01-16 08:23:02 -06:00
parent b4462138fb
commit 1e061538a1

View File

@ -19,6 +19,7 @@ from fastapi import APIRouter, Body, Path, Request, Response
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from filelock import FileLock, Timeout
from markupsafe import escape from markupsafe import escape
from peewee import SQL, fn, operator from peewee import SQL, fn, operator
from pydantic import ValidationError from pydantic import ValidationError
@ -424,104 +425,124 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody): def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file() config_file = find_config_file()
lock = FileLock(f"{config_file}.lock", timeout=5)
with open(config_file, "r") as f:
old_raw_config = f.read()
try: try:
updates = {} with lock:
with open(config_file, "r") as f:
old_raw_config = f.read()
# process query string parameters (takes precedence over body.config_data) try:
parsed_url = urllib.parse.urlparse(str(request.url)) updates = {}
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True)
# Filter out empty keys but keep blank values for non-empty keys # process query string parameters (takes precedence over body.config_data)
query_string = {k: v for k, v in query_string.items() if k} parsed_url = urllib.parse.urlparse(str(request.url))
query_string = urllib.parse.parse_qs(
parsed_url.query, keep_blank_values=True
)
if query_string: # Filter out empty keys but keep blank values for non-empty keys
updates = process_config_query_string(query_string) query_string = {k: v for k, v in query_string.items() if k}
elif body.config_data:
updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
if not updates: if query_string:
return JSONResponse( updates = process_config_query_string(query_string)
content=( elif body.config_data:
{"success": False, "message": "No configuration data provided"} updates = flatten_config_data(body.config_data)
), # Convert None values to empty strings for deletion (e.g., when deleting masks)
status_code=400, updates = {k: ("" if v is None else v) for k, v in updates.items()}
)
# apply all updates in a single operation if not updates:
update_yaml_file_bulk(config_file, updates) return JSONResponse(
content=(
{
"success": False,
"message": "No configuration data provided",
}
),
status_code=400,
)
# validate the updated config # apply all updates in a single operation
with open(config_file, "r") as f: update_yaml_file_bulk(config_file, updates)
new_raw_config = f.read()
# validate the updated config
with open(config_file, "r") as f:
new_raw_config = f.read()
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error parsing config. Check logs for error message.",
}
),
status_code=400,
)
except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse(
content=({"success": False, "message": "Error updating config"}),
status_code=500,
)
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum[field], camera
),
settings,
)
else:
# Generic handling for global config updates
settings = config.get_nested_object(body.update_topic)
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish(
body.update_topic, settings
)
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return JSONResponse( return JSONResponse(
content=( content=(
{ {
"success": False, "success": True,
"message": "Error parsing config. Check logs for error message.", "message": "Config successfully updated, restart to apply",
} }
), ),
status_code=400, status_code=200,
) )
except Exception as e: except Timeout:
logging.error(f"Error updating config: {e}")
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Error updating config"}), content=(
status_code=500, {
"success": False,
"message": "Another process is currently updating the config. Please try again in a few seconds.",
}
),
status_code=503,
) )
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
settings,
)
else:
# Generic handling for global config updates
settings = config.get_nested_object(body.update_topic)
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish(
body.update_topic, settings
)
return JSONResponse(
content=(
{
"success": True,
"message": "Config successfully updated, restart to apply",
}
),
status_code=200,
)
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())]) @router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
def vainfo(): def vainfo():