Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 7 additions & 19 deletions config-merger/script/merge_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"""

import os
import shutil
import sys

import yaml
Expand Down Expand Up @@ -383,11 +382,8 @@ def ensure_node_id(user_config_path):
user_config['network'] = {}
user_config['network']['node_id'] = node_id

# Write back to user config (atomic)
temp_path = user_config_path + '.node_id_tmp.' + str(os.getpid())
with open(temp_path, 'w') as f:
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
os.rename(temp_path, user_config_path)
write_file_atomic(user_config_path, yaml.dump(
user_config, default_flow_style=False, sort_keys=False))

print(f"Node ID set in {user_config_path}")

Expand Down Expand Up @@ -426,17 +422,9 @@ def main():
print("User config not found, copying from defaults...")
os.makedirs(os.path.dirname(user_config_path), exist_ok=True)

# Write to temp file first, then atomic rename
temp_path = user_config_path + '.tmp.' + str(os.getpid())
shutil.copy(default_config, temp_path)
try:
os.rename(temp_path, user_config_path) # Atomic on POSIX
print(f"Created {user_config_path}")
except (FileExistsError, OSError):
# Another process created it first, clean up temp
if os.path.exists(temp_path):
os.remove(temp_path)
print("User config was created by another process")
with open(default_config) as f:
write_file_atomic(user_config_path, f.read())
print(f"Created {user_config_path}")

# Ensure node_id exists and matches hardware (add/update if needed, Pi only)
ensure_node_id(user_config_path)
Expand Down Expand Up @@ -473,8 +461,8 @@ def main():
# Write merged config to output
print(f"Writing merged config to {output_config_path}")
os.makedirs(os.path.dirname(output_config_path), exist_ok=True)
with open(output_config_path, 'w') as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
write_file_atomic(output_config_path, yaml.dump(
config, default_flow_style=False, sort_keys=False))

# Generate .env file for tar1090-node
generate_env_file(config, os.path.dirname(output_config_path))
Expand Down
28 changes: 28 additions & 0 deletions config-merger/test/test_merge_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ def run_merge(self):

return output_yml

def test_config_yml_is_replaced_not_rewritten(self):
self.write_yaml(os.path.join(self.defaults_dir, 'default.yml'), {'network': {'ip': '0.0.0.0'}})
output = self.run_merge()
before = os.stat(output).st_ino
self.run_merge()
self.assertNotEqual(os.stat(output).st_ino, before)
self.assertEqual(self.read_yaml(output)['network']['ip'], '0.0.0.0')

def test_defaults_only(self):
"""Test merge with only default.yml"""
default_config = {
Expand Down Expand Up @@ -1061,6 +1069,26 @@ def test_env_reaches_both_compose_slots_as_new_files(self):
self.assertEqual(f.read(), expected)
self.assertNotIn('\x00', expected)

def test_every_write_goes_through_write_file_atomic(self):
# ret9573ecda, 2026-08-27 23:23:18: config.yml, tar1090.env,
# retina-tracker.yaml and .env were all zeroed by one power cut.
# Temp-file-and-rename without fsync did not save the two written
# that way. The only write-mode open() allowed is the helper's own.
import ast
script = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'script', 'merge_config.py')
with open(script) as f:
tree = ast.parse(f.read())
writers = []
for func in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
for call in [n for n in ast.walk(func) if isinstance(n, ast.Call)]:
name = getattr(call.func, 'id', None) or getattr(call.func, 'attr', None)
mode = call.args[1].value if len(call.args) > 1 and isinstance(call.args[1], ast.Constant) else 'r'
if name == 'open' and any(c in str(mode) for c in 'wax'):
writers.append(func.name)
if name in ('copy', 'copyfile', 'rename'):
writers.append(func.name)
self.assertEqual(sorted(set(writers)), ['write_file_atomic'])

def test_skips_a_slot_that_does_not_exist(self):
root = os.path.join(self.test_dir, 'mender-docker-compose')
os.makedirs(os.path.join(root, 'current', 'manifests'))
Expand Down
Loading