Files
2026-07-13 13:12:00 +08:00

145 lines
5.3 KiB
Python

"""Server-side YAML config for the non-CLI entrypoints.
The ``omnigent server`` CLI already takes ``-c/--config`` and reads a
YAML file (see ``omnigent/cli.py``). The hosted entrypoints —
``deploy/docker/entrypoint.py`` and ``deploy/databricks/src/app.py`` —
don't go through that CLI; they build the app directly from env vars.
This module gives those entrypoints the *same* config-file experience a
laptop gets from ``-c``, so a deployment can keep most of its settings
(admins, allowed domains, policy modules, artifact location, host/port,
database URI) in one file on the persistent volume instead of a pile of
env vars.
**Secrets stay in the environment, not this file.** ``DATABASE_URL``,
the session cookie secret, and the OIDC client secret are injected by
compose / ``bootstrap.sh`` / the platform — keeping them out of a
mounted YAML is deliberate (12-factor; the file is operator-editable
and often world-readable on the box). This config holds non-secret
*settings* only.
Resolution order for the config path:
1. ``OMNIGENT_CONFIG`` env var, if set (explicit path).
2. ``<data_dir>/config.yaml`` if it exists — ``<data_dir>`` is the same
directory the admin list / credentials use (``/data`` in the Docker
stack, ``~/.omnigent`` on a laptop; see
:func:`omnigent.server.admin_list.resolve_data_dir`).
3. Otherwise ``None`` — no file, pure env config (back-compat: existing
env-only deploys keep working unchanged).
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
import yaml
from omnigent.server.admin_list import resolve_data_dir
logger = logging.getLogger(__name__)
def resolve_config_path() -> Path | None:
"""Resolve the server config file path, or ``None`` if there is none.
:returns: ``OMNIGENT_CONFIG`` if set; else ``<data_dir>/config.yaml``
when that file exists; else ``None``.
"""
explicit = os.environ.get("OMNIGENT_CONFIG", "").strip()
if explicit:
return Path(explicit)
default = resolve_data_dir() / "config.yaml"
return default if default.is_file() else None
def load_server_config() -> dict[str, Any]:
"""Load the resolved server config file into a dict.
:returns: The parsed mapping, or an empty dict when no config file is
resolved. A present-but-unreadable / malformed file logs a
warning and returns ``{}`` rather than crashing startup — the
entrypoint then falls back to env + defaults.
"""
path = resolve_config_path()
if path is None:
return {}
try:
with open(path, encoding="utf-8") as handle:
data = yaml.safe_load(handle)
except (OSError, yaml.YAMLError) as exc:
logger.warning("server config %s unreadable/invalid: %s — falling back to env", path, exc)
return {}
if not isinstance(data, dict):
logger.warning("server config %s is not a mapping — ignoring", path)
return {}
logger.info("loaded server config from %s", path)
return data
def config_str_list(value: Any) -> list[str]:
"""Coerce a config value into a list of non-empty strings.
Accepts a YAML list (``["a", "b"]``) or a single scalar (``"a"``);
anything else yields an empty list. Used for ``admins`` /
``allowed_domains`` so a one-entry value doesn't have to be a list.
:param value: The raw config value, e.g. ``["alice@example.com"]``.
:returns: A list of stripped, non-empty strings.
"""
if value is None:
return []
items = value if isinstance(value, list) else [value]
return [str(item).strip() for item in items if str(item).strip()]
def _config_positive_int(key: str, default: int) -> int:
"""Read a positive-int setting from the server config, else *default*.
A missing, non-numeric, or non-positive value falls back to *default*
rather than crashing — the config file is operator-editable and a typo
should degrade to the safe built-in limit, not take the server down.
:param key: Top-level config key, e.g. ``"copy_max_files"``.
:param default: Value used when the key is absent or invalid.
:returns: The configured positive int, or *default*.
"""
raw = load_server_config().get(key)
if raw is None:
return default
try:
value = int(raw)
except (TypeError, ValueError):
logger.warning("server config %s=%r is not an int — using default %d", key, raw, default)
return default
if value <= 0:
logger.warning(
"server config %s=%d is not positive — using default %d", key, value, default
)
return default
return value
def copy_file_count_limit() -> int:
"""Max number of files a single copy-at-spawn request may copy.
Config key ``copy_max_files``; defaults to
:data:`omnigent.runtime.content_resolver.MAX_COPY_FILES`.
"""
from omnigent.runtime.content_resolver import MAX_COPY_FILES
return _config_positive_int("copy_max_files", MAX_COPY_FILES)
def copy_total_bytes_limit() -> int:
"""Max summed byte size a single copy-at-spawn request may copy.
Config key ``copy_max_total_bytes``; defaults to
:data:`omnigent.runtime.content_resolver.MAX_COPY_TOTAL_BYTES`.
"""
from omnigent.runtime.content_resolver import MAX_COPY_TOTAL_BYTES
return _config_positive_int("copy_max_total_bytes", MAX_COPY_TOTAL_BYTES)