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,7 +425,10 @@ 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)
try:
with lock:
with open(config_file, "r") as f: with open(config_file, "r") as f:
old_raw_config = f.read() old_raw_config = f.read()
@ -433,7 +437,9 @@ def config_set(request: Request, body: AppConfigSetBody):
# process query string parameters (takes precedence over body.config_data) # process query string parameters (takes precedence over body.config_data)
parsed_url = urllib.parse.urlparse(str(request.url)) parsed_url = urllib.parse.urlparse(str(request.url))
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True) 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 # Filter out empty keys but keep blank values for non-empty keys
query_string = {k: v for k, v in query_string.items() if k} query_string = {k: v for k, v in query_string.items() if k}
@ -448,7 +454,10 @@ def config_set(request: Request, body: AppConfigSetBody):
if not updates: if not updates:
return JSONResponse( return JSONResponse(
content=( content=(
{"success": False, "message": "No configuration data provided"} {
"success": False,
"message": "No configuration data provided",
}
), ),
status_code=400, status_code=400,
) )
@ -500,7 +509,9 @@ def config_set(request: Request, body: AppConfigSetBody):
settings = config.get_nested_object(body.update_topic) settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update( request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera), CameraConfigUpdateTopic(
CameraConfigUpdateEnum[field], camera
),
settings, settings,
) )
else: else:
@ -521,6 +532,16 @@ def config_set(request: Request, body: AppConfigSetBody):
), ),
status_code=200, status_code=200,
) )
except Timeout:
return JSONResponse(
content=(
{
"success": False,
"message": "Another process is currently updating the config. Please try again in a few seconds.",
}
),
status_code=503,
)
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())]) @router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])