From c88434dc29115d2dd3ada6d59fe0edc664465658 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:56:34 -0600 Subject: [PATCH] Write migrated config atomically (#24926) * write migrated config atomically Config migration rewrote config.yml in place with open("w") after each step, so nginx's get_nginx_settings.py, which starts alongside frigate, could read an empty file, get None from yaml.load, and crash on config.get. Each step now writes to a temp file with the original mode and os.replace's it into place. * 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. * follow config symlinks in migration writes --- frigate/test/test_config_migration.py | 37 +++++++++++++++++++ frigate/util/config.py | 51 +++++++++++++++++---------- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/frigate/test/test_config_migration.py b/frigate/test/test_config_migration.py index c33aa95425..9f9226dad4 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,42 @@ 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_a_symlinked_config_migrates_its_target(self): + target = os.path.join(self.temp_dir.name, "frigate.yml") + with open(target, "w") as f: + f.write("mqtt:\n enabled: false\ncameras: {}\n") + os.symlink(target, self.config_file) + + migrate_frigate_config(self.config_file) + + self.assertTrue(os.path.islink(self.config_file)) + with open(target) as f: + self.assertEqual(YAML().load(f)["version"], CURRENT_CONFIG_VERSION) + 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 634016a331..7f74f8f685 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 @@ -212,6 +214,28 @@ def find_config_file() -> str: return config_path +def _write_config(config_file: str, yaml: YAML, config: dict[str, Any]) -> None: + config_file = os.path.realpath(config_file) + st = os.stat(config_file) + tmp_file = f"{config_file}.tmp" + with open(tmp_file, "w") as f: + yaml.dump(config, f) + 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): """handle migrating the frigate config.""" logger.info("Checking if frigate config needs migration...") @@ -259,8 +283,7 @@ def migrate_frigate_config(config_file: str): if previous_version < "0.14": logger.info(f"Migrating frigate config from {previous_version} to 0.14...") new_config = migrate_014(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) previous_version = "0.14" logger.info("Migrating export file names...") @@ -277,56 +300,48 @@ def migrate_frigate_config(config_file: str): if previous_version < "0.15-0": logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...") new_config = migrate_015_0(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) previous_version = "0.15-0" if previous_version < "0.15-1": logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...") new_config = migrate_015_1(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) previous_version = "0.15-1" if previous_version < "0.16-0": logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...") new_config = migrate_016_0(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) previous_version = "0.16-0" if previous_version < "0.17-0": logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...") new_config = migrate_017_0(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) previous_version = "0.17-0" if previous_version < "0.18-0": logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...") new_config = migrate_018_0(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) previous_version = "0.18-0" if previous_version < "0.19-0": logger.info(f"Migrating frigate config from {previous_version} to 0.19-0...") new_config = migrate_019_0(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) previous_version = "0.19-0" if needs_models: logger.info("Migrating frigate detectors and model to models...") new_config = migrate_models(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) if needs_detector_rename: logger.info("Migrating renamed frigate detectors...") new_config = rename_hailo_detector(new_config) - with open(config_file, "w") as f: - yaml.dump(new_config, f) + _write_config(config_file, yaml, new_config) logger.info("Finished frigate config migration...")