From 78a3174238c13e6d65e437bdda08c1fbb8d78a51 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:11:04 -0600 Subject: [PATCH] keep config owner and handle file bind mounts in migration writes os.replace failed with EBUSY when config.yml was a file bind mount, as in the documented --validate-config command, and the replaced file lost its original owner when Frigate ran as root. The write now copies in place on EBUSY and restores the original owner when permitted. Tests cover a failed dump leaving the original config and the bind-mount fallback. --- frigate/test/test_config_migration.py | 25 +++++++++++++++++++++++++ frigate/util/config.py | 19 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/frigate/test/test_config_migration.py b/frigate/test/test_config_migration.py index c33aa95425..dd682c4e38 100644 --- a/frigate/test/test_config_migration.py +++ b/frigate/test/test_config_migration.py @@ -1,5 +1,6 @@ """Tests for migrating detectors and model into the models list.""" +import errno import logging import os import tempfile @@ -240,6 +241,30 @@ class TestMigrateConfigFile(unittest.TestCase): with open(self.config_file) as f: return YAML().load(f) + def test_a_failed_write_leaves_the_original_config(self): + original = "mqtt:\n enabled: false\ncameras: {}\n" + with open(self.config_file, "w") as f: + f.write(original) + + with ( + patch.object(YAML, "dump", side_effect=RuntimeError), + self.assertRaises(RuntimeError), + ): + migrate_frigate_config(self.config_file) + + with open(self.config_file) as f: + self.assertEqual(f.read(), original) + + def test_a_file_bind_mount_is_written_in_place(self): + with patch( + "frigate.util.config.os.replace", + side_effect=OSError(errno.EBUSY, "Device or resource busy"), + ): + migrated = self._migrate("mqtt:\n enabled: false\ncameras: {}\n") + + self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION) + self.assertFalse(os.path.exists(f"{self.config_file}.tmp")) + def test_migrates_a_config_already_stamped_with_the_current_version(self): # 0.19 is unreleased, so a dev config can be current and still use # the pre-models keys diff --git a/frigate/util/config.py b/frigate/util/config.py index 1555a903b4..7c83edf793 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -1,6 +1,8 @@ """configuration utils.""" import asyncio +import contextlib +import errno import ipaddress import logging import os @@ -213,11 +215,24 @@ def find_config_file() -> str: def _write_config(config_file: str, yaml: YAML, config: dict[str, Any]) -> None: + st = os.stat(config_file) tmp_file = f"{config_file}.tmp" with open(tmp_file, "w") as f: yaml.dump(config, f) - shutil.copymode(config_file, tmp_file) - os.replace(tmp_file, config_file) + os.chmod(tmp_file, st.st_mode) + + with contextlib.suppress(PermissionError): + os.chown(tmp_file, st.st_uid, st.st_gid) + + try: + os.replace(tmp_file, config_file) + except OSError as e: + if e.errno != errno.EBUSY: + raise + + # a file bind mount can't be renamed over, so write it in place + shutil.copyfile(tmp_file, config_file) + os.remove(tmp_file) def migrate_frigate_config(config_file: str):