mirror of
https://github.com/odysseus-dev/odysseus.git
synced 2026-08-29 10:21:46 +00:00
Some checks are pending
CI / Focused test guidance (report-only) (push) Waiting to run
CI / Python syntax (compileall) (push) Waiting to run
CI / JS syntax (node --check) (push) Waiting to run
CI / Python tests (pytest) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
* fix(docker): migrate retained SearXNG settings Retained nonempty SearXNG settings can miss defaults required by newer pinned images while bypassing the entrypoint's narrow regeneration checks. Add an atomic PyYAML-aware migration to all Compose variants. Preserve existing inheritance choices, custom content, secrets, ownership, and mode while inserting only the missing top-level default-inheritance key. Validated with 39 focused and adjacent tests, compile checks, and fresh and retained pinned-image HTTP 200 gates. Full repository CI remains for the PR. * fix(docker): chmod the settings temp file before chowning it The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/DAC_OVERRIDE and carries no FOWNER, and searxng's own entrypoint chowns /etc/searxng to searxng:searxng, so every retained settings file belongs to that user by the second boot. Chowning the temporary file first left root unable to chmod it, so the migration exited 1 and `set -eu` killed the container before `exec /usr/local/searxng/entrypoint.sh` — SearXNG never started and odysseus blocked on its healthcheck. Swap the two calls so the chmod lands while the temporary file is still root-owned, and cover the ordering with a test that refuses the chmod once the chown has happened, the way the kernel does. * fix(docker): let searxng boot when the settings migration fails The migration runs under `set -eu`, so any settings file it cannot parse or rewrite took the container down instead of merely going unmigrated. A symlinked /etc/searxng/settings.yml is enough: the migration refuses a non-regular file and searxng, which reads through the symlink perfectly well, never got to start. Guard the call with `|| true` in all three Compose variants. The failure still prints its reason on stderr, and searxng is left to report anything genuinely wrong with the file. --------- Co-authored-by: Léo <leograndcontact@gmail.com>
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Make retained SearXNG settings inherit defaults without replacing them."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from yaml.nodes import MappingNode
|
|
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
|
|
|
|
|
|
_UTF8_BOM = b"\xef\xbb\xbf"
|
|
|
|
|
|
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
|
|
"""Parse settings with the same safe YAML semantics SearXNG uses."""
|
|
try:
|
|
loaded = yaml.safe_load(text)
|
|
node = yaml.compose(text, Loader=yaml.SafeLoader)
|
|
except yaml.YAMLError:
|
|
raise ValueError("settings file is not valid single-document YAML") from None
|
|
|
|
if loaded is None and node is None:
|
|
return None, {}
|
|
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
|
|
raise ValueError("settings root is not a mapping")
|
|
return node, loaded
|
|
|
|
|
|
def _flow_mapping_start(text: str) -> int:
|
|
"""Return the root flow mapping's opening-brace character offset."""
|
|
try:
|
|
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
|
if isinstance(token, FlowMappingStartToken):
|
|
return token.start_mark.index
|
|
except yaml.YAMLError:
|
|
pass
|
|
raise ValueError("flow-style settings mapping has no opening brace")
|
|
|
|
|
|
def _newline_for(contents: bytes) -> bytes:
|
|
first_lf = contents.find(b"\n")
|
|
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
|
|
return b"\r\n"
|
|
return b"\n"
|
|
|
|
|
|
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
|
|
"""Return a safe character offset and indent for a root block mapping key."""
|
|
if root is None:
|
|
return len(text), 0
|
|
|
|
try:
|
|
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
|
if not isinstance(token, BlockMappingStartToken):
|
|
continue
|
|
line_start = token.start_mark.index - token.start_mark.column
|
|
if not text[line_start : token.start_mark.index].strip():
|
|
return line_start, token.start_mark.column
|
|
return root.end_mark.index, token.start_mark.column
|
|
except yaml.YAMLError:
|
|
pass
|
|
return root.end_mark.index, root.start_mark.column
|
|
|
|
|
|
def _add_block_default_inheritance(
|
|
contents: bytes, text: str, root: MappingNode | None
|
|
) -> bytes:
|
|
newline = _newline_for(contents)
|
|
character_offset, indent_width = _block_mapping_position(text, root)
|
|
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
|
offset = bom_length + len(text[:character_offset].encode("utf-8"))
|
|
separator = b""
|
|
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
|
|
separator = newline
|
|
addition = (
|
|
separator
|
|
+ b" " * indent_width
|
|
+ b"use_default_settings: true"
|
|
+ newline
|
|
)
|
|
return contents[:offset] + addition + contents[offset:]
|
|
|
|
|
|
def migrate_settings(path: Path) -> bool:
|
|
"""Add the missing inheritance key atomically; return whether the file changed."""
|
|
source_stat = path.lstat()
|
|
if not stat.S_ISREG(source_stat.st_mode):
|
|
raise ValueError(f"settings path is not a regular file: {path}")
|
|
|
|
contents = path.read_bytes()
|
|
if not contents:
|
|
return False
|
|
|
|
text = contents.decode("utf-8-sig")
|
|
root, loaded = _parse_root_mapping(text)
|
|
if "use_default_settings" in loaded:
|
|
return False
|
|
|
|
if root is not None and root.flow_style:
|
|
start = _flow_mapping_start(text)
|
|
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
|
offset = bom_length + len(text[: start + 1].encode("utf-8"))
|
|
separator = b", " if root.value else b""
|
|
updated = (
|
|
contents[:offset]
|
|
+ b"use_default_settings: true"
|
|
+ separator
|
|
+ contents[offset:]
|
|
)
|
|
else:
|
|
updated = _add_block_default_inheritance(contents, text, root)
|
|
fd, temporary_name = tempfile.mkstemp(
|
|
prefix=f".{path.name}.odysseus-", dir=path.parent
|
|
)
|
|
temporary = Path(temporary_name)
|
|
try:
|
|
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
|
|
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
|
|
# file belongs to searxng:searxng — which every retained settings file
|
|
# does, because searxng's entrypoint chowns /etc/searxng — root can no
|
|
# longer chmod it and the migration dies with EPERM.
|
|
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
|
|
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
|
|
with os.fdopen(fd, "wb") as handle:
|
|
fd = -1
|
|
handle.write(updated)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, path)
|
|
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
temporary.unlink(missing_ok=True)
|
|
return True
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if len(argv) > 2:
|
|
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
|
|
return 2
|
|
|
|
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
|
|
try:
|
|
changed = migrate_settings(path)
|
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
if changed:
|
|
print("Added use_default_settings inheritance to retained SearXNG settings")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|