mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
* fix(config): never rewrite an unparseable config.json as empty (#730)
Four call sites read config.json, treated a parse failure as an empty dict and
wrote that back, destroying every other key. One trailing comma plus one
`octop run --port 8088` wiped 17 of 18 keys including the whole `database`
section; the next boot silently came up as a greenfield SQLite instance (fresh
setup-wizard password, zero warnings) while the PostgreSQL data sat unreachable.
Losing `bind_host` the same way is what makes #730's reporter unable to reach
the server from the LAN.
A torn write creates the same precondition with no user error: three of the four
sites wrote config.json with a bare write_text, so a crash / OOM / full disk
mid-write left invalid JSON that the next plugin toggle then wiped.
One shared policy in a new leaf helper, infra/utils/json_file.py:
absent file -> {} and write (first run, unchanged)
valid JSON object -> merge and write (unchanged)
unparseable/non-object -> raise CONFIG_FILE_CORRUPT, write nothing
The error names the file and the parser line/column but never the contents,
which hold database credentials. Writes now go through write_json_atomic (temp +
os.replace, fd closed on every path, temp removed on failure) so no reader can
observe a torn file.
Sites: cli/commands/run.py (_save_configfile_overrides, plus
_load_configfile_overrides which used to fall back to launch defaults),
infra/agents/plugins/manager.py (_write_global_plugin_enabled = the dashboard
toggle), infra/setup/service.py (persist_bind_options),
infra/agents/plugins/seed.py. `octop run` / `octop service start` report the
error and exit 1 via fail_octop (now NoReturn) instead of a traceback.
Supersedes the pinned "treat a malformed file as empty so the next run rewrites
it cleanly" contract: its fixture (`{not valid json`) had nothing to lose, so
the test could not observe the destruction. Rewritten as
test_run_reports_corrupt_config_cleanly and
test_run_does_not_overwrite_corrupt_config.
Verified end-to-end against a real OctopServer + real PostgreSQL. Before: one
`octop run --port` flipped driver postgresql -> sqlite, the instance reverted to
the first-run wizard, and users=1/agents=1 were orphaned in PG with no warning.
After: exit 1, config.json byte-identical, boot fails loudly instead of
switching backends. Mutation check: restoring the lenient policy in the helper
reddens 9 tests spanning all four sites.
make all green (3299 passed, 16 skipped); mypy --strict clean; dashboard
`npx tsc --noEmit` clean.
* fix(config): name the file when config.json is corrupt (#730)
`load_config` let a bare `json.JSONDecodeError` escape: it reports a line and
column but never says *which* file failed, and this entry point is reached from
boot, backup and several CLI commands — not only `octop run`. Raise a ValueError
naming the path and the parser position, refuse a non-object document, and never
echo file contents (config.json holds database credentials).
Same policy as infra/utils/json_file.py, duplicated here because octop.config
must stay free of infra imports (AGENTS.md §5).
`octop run` already exits cleanly on a corrupt config through resolve_bind; this
covers the remaining entry points.
make all green (3301 passed, 16 skipped); mypy --strict clean.
* fix(config): preserve config.json permissions in atomic writes
Self-review against CONTRIBUTING.md / AGENTS.md §7 caught a regression in the
atomic writer added earlier in this branch: tempfile.mkstemp creates the temp
file 0600, so replacing config.json silently tightened an existing 0644 file to
0600. Under `octop service start --scope system` that runs as root and writes
into the service user's home, a root-owned 0600 config.json is unreadable by the
service user and the unit cannot start; the bare write_text it replaced produced
the umask default.
write_json_atomic now chmods the temp file to the mode of the file being
replaced, or to `0o666 & ~umask` for a new file — matching the two pre-existing
config.json writers (infra/db/rebind.py, infra/backup/auto.py; both
write_text + replace) rather than mkstemp's 0600. POSIX-guarded, so Windows
behaviour is unchanged and no test asserts mode bits off POSIX (AGENTS.md §7).
Also adds the missing CHANGELOG line for the load_config error-message change.
Verified: new file -> 0644, existing 0644 -> 0644, existing 0600 -> 0600.
make all green (3303 passed, 16 skipped); make typecheck-frontend green;
eslint 0 errors (67 pre-existing react-hooks warnings).
Note: `make check-all` is red on develop itself — prettier --check fails on
dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx exactly as committed
by 83378876. Left untouched here; confirmed pre-existing by checking develop's
version of that file in isolation.
433 lines
15 KiB
Python
433 lines
15 KiB
Python
"""tests/unit/test_config.py"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from octop.config import load_config, parse_database_config
|
|
|
|
|
|
def test_defaults_when_missing(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.bind_host == "127.0.0.1"
|
|
assert cfg.port == 8088
|
|
assert cfg.access_token_ttl_seconds == 86400
|
|
assert cfg.enable_dashboard is True
|
|
assert cfg.enable_api_docs is False
|
|
assert cfg.require_setup_password is True
|
|
assert cfg.database.driver == "sqlite"
|
|
assert cfg.database.sqlite_path == "octop.db"
|
|
assert cfg.database.is_sqlite
|
|
assert cfg.database_in_file is False
|
|
assert cfg.backup.auto_enabled is False
|
|
assert cfg.backup.schedule == "cron:0 4 * * *"
|
|
assert cfg.backup.retention_count == 7
|
|
assert cfg.backup.include_config is True
|
|
assert cfg.backup.include_workspaces is True
|
|
assert cfg.backup.include_skill_packages is True
|
|
assert cfg.backup.include_plugins is True
|
|
assert cfg.backup.include_knowledge is True
|
|
assert cfg.backup.include_chats is False
|
|
assert cfg.max_upload_mb == 100
|
|
assert cfg.max_upload_bytes == 100 * 1024 * 1024
|
|
assert cfg_path.exists() # file written with defaults
|
|
written = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
assert "password" not in written.get("database", {})
|
|
assert written.get("backup", {}).get("auto_enabled") is False
|
|
assert written["max_upload_mb"] == 100
|
|
|
|
|
|
def test_loads_backup_section(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"backup": {
|
|
"auto_enabled": True,
|
|
"schedule": "interval:7200",
|
|
"retention_count": 3,
|
|
"include_config": False,
|
|
"include_workspaces": False,
|
|
"include_skill_packages": False,
|
|
"include_plugins": False,
|
|
"include_knowledge": False,
|
|
"include_chats": True,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.backup.auto_enabled is True
|
|
assert cfg.backup.schedule == "interval:7200"
|
|
assert cfg.backup.retention_count == 3
|
|
assert cfg.backup.include_config is False
|
|
assert cfg.backup.include_workspaces is False
|
|
assert cfg.backup.include_skill_packages is False
|
|
assert cfg.backup.include_plugins is False
|
|
assert cfg.backup.include_knowledge is False
|
|
assert cfg.backup.include_chats is True
|
|
|
|
|
|
def test_backup_env_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_BACKUP_AUTO_ENABLED", "true")
|
|
monkeypatch.setenv("OCTOP_BACKUP_SCHEDULE", "cron:30 5 * * *")
|
|
monkeypatch.setenv("OCTOP_BACKUP_RETENTION_COUNT", "5")
|
|
monkeypatch.setenv("OCTOP_BACKUP_INCLUDE_CHATS", "true")
|
|
monkeypatch.setenv("OCTOP_BACKUP_INCLUDE_CONFIG", "false")
|
|
monkeypatch.setenv("OCTOP_BACKUP_INCLUDE_WORKSPACES", "false")
|
|
monkeypatch.setenv("OCTOP_BACKUP_INCLUDE_SKILL_PACKAGES", "false")
|
|
monkeypatch.setenv("OCTOP_BACKUP_INCLUDE_PLUGINS", "false")
|
|
monkeypatch.setenv("OCTOP_BACKUP_INCLUDE_KNOWLEDGE", "false")
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.backup.auto_enabled is True
|
|
assert cfg.backup.schedule == "cron:30 5 * * *"
|
|
assert cfg.backup.retention_count == 5
|
|
assert cfg.backup.include_chats is True
|
|
assert cfg.backup.include_config is False
|
|
assert cfg.backup.include_workspaces is False
|
|
assert cfg.backup.include_skill_packages is False
|
|
assert cfg.backup.include_plugins is False
|
|
assert cfg.backup.include_knowledge is False
|
|
|
|
|
|
def test_loads_existing(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"port": 9000, "log_level": "debug"}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.port == 9000
|
|
assert cfg.log_level == "debug"
|
|
assert cfg.bind_host == "127.0.0.1" # default fills
|
|
assert cfg.max_upload_mb == 100 # missing key uses default
|
|
|
|
|
|
def test_loads_max_upload_mb(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"max_upload_mb": 50}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.max_upload_mb == 50
|
|
assert cfg.max_upload_bytes == 50 * 1024 * 1024
|
|
|
|
|
|
def test_max_upload_mb_env_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_MAX_UPLOAD_MB", "25")
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"max_upload_mb": 50}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.max_upload_mb == 25
|
|
assert cfg.max_upload_bytes == 25 * 1024 * 1024
|
|
|
|
|
|
def test_max_upload_mb_rejects_non_positive(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"max_upload_mb": 0}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.max_upload_mb == 100
|
|
|
|
|
|
def test_loads_database_section(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"database": {
|
|
"driver": "sqlite",
|
|
"sqlite_path": "data/custom.db",
|
|
}
|
|
}
|
|
)
|
|
)
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.database.sqlite_path == "data/custom.db"
|
|
assert cfg.database_in_file is True
|
|
assert cfg.database.resolve_sqlite_path(tmp_path) == tmp_path / "data" / "custom.db"
|
|
|
|
|
|
def test_env_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_PORT", "7000")
|
|
monkeypatch.setenv("OCTOP_LOG_LEVEL", "warning")
|
|
monkeypatch.setenv("OCTOP_BROWSER_IDLE_TIMEOUT_MINUTES", "12")
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"port": 8088}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.port == 7000
|
|
assert cfg.log_level == "warning"
|
|
assert cfg.browser_idle_timeout_minutes == 12
|
|
|
|
|
|
def test_browser_idle_timeout_defaults_and_can_be_disabled(tmp_path: Path):
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.browser_idle_timeout_minutes == 30
|
|
|
|
cfg_path = tmp_path / "disabled.json"
|
|
cfg_path.write_text(json.dumps({"browser_idle_timeout_minutes": 0}))
|
|
assert load_config(cfg_path).browser_idle_timeout_minutes == 0
|
|
|
|
|
|
def test_database_env_sqlite_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_DATABASE_SQLITE_PATH", "/tmp/octop-test.db")
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.database.sqlite_path == "/tmp/octop-test.db"
|
|
resolved = cfg.database.resolve_sqlite_path(tmp_path)
|
|
assert resolved.is_absolute()
|
|
assert resolved.as_posix().endswith("/tmp/octop-test.db")
|
|
|
|
|
|
def test_database_env_url_postgresql(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_DATABASE_URL", "postgresql://alice:secret@db.example.com:5433/mydb")
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.database.is_postgresql
|
|
assert cfg.database.host == "db.example.com"
|
|
assert cfg.database.port == 5433
|
|
assert cfg.database.database == "mydb"
|
|
assert cfg.database.user == "alice"
|
|
assert cfg.database.password == "secret"
|
|
|
|
|
|
def test_database_url_preserves_query_for_conninfo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv(
|
|
"OCTOP_DATABASE_URL",
|
|
"postgresql://alice:secret@db.example.com:5433/mydb?sslmode=require",
|
|
)
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.database.is_postgresql
|
|
assert cfg.database.url == "postgresql://alice:secret@db.example.com:5433/mydb?sslmode=require"
|
|
assert "sslmode=require" in cfg.database.postgresql_conninfo()
|
|
|
|
|
|
def test_postgresql_conninfo_from_discrete_fields(tmp_path: Path):
|
|
(tmp_path / "config.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"database": {
|
|
"driver": "postgresql",
|
|
"host": "127.0.0.1",
|
|
"port": 5432,
|
|
"database": "octop",
|
|
"user": "octop",
|
|
"password": "s3cret",
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
cfg = load_config(tmp_path / "config.json")
|
|
info = cfg.database.postgresql_conninfo()
|
|
assert info.startswith("postgresql://")
|
|
assert "octop" in info
|
|
assert "s3cret" in info
|
|
|
|
|
|
def test_database_env_password_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"database": {
|
|
"driver": "postgresql",
|
|
"host": "localhost",
|
|
"database": "octop",
|
|
"user": "octop",
|
|
}
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setenv("OCTOP_DATABASE_PASSWORD", "from-env")
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.database.password == "from-env"
|
|
|
|
|
|
def test_invalid_port_falls_back(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_PORT", "not-a-number")
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.port == 8088
|
|
|
|
|
|
def test_invalid_database_driver_raises(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"database": {"driver": "mysql"}}))
|
|
with pytest.raises(ValueError, match="database.driver"):
|
|
load_config(cfg_path)
|
|
|
|
|
|
def test_postgresql_missing_user_raises(tmp_path: Path):
|
|
with pytest.raises(ValueError, match="database.user"):
|
|
parse_database_config(
|
|
{
|
|
"driver": "postgresql",
|
|
"host": "localhost",
|
|
"database": "octop",
|
|
"user": "",
|
|
}
|
|
)
|
|
|
|
|
|
def test_feature_flags_env_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_ENABLE_DASHBOARD", "false")
|
|
monkeypatch.setenv("OCTOP_ENABLE_API_DOCS", "true")
|
|
monkeypatch.setenv("OCTOP_REQUIRE_SETUP_PASSWORD", "0")
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.enable_dashboard is False
|
|
assert cfg.enable_api_docs is True
|
|
assert cfg.require_setup_password is False
|
|
|
|
|
|
def test_feature_flags_from_file(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"enable_dashboard": False,
|
|
"enable_api_docs": True,
|
|
"require_setup_password": False,
|
|
}
|
|
)
|
|
)
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.enable_dashboard is False
|
|
assert cfg.enable_api_docs is True
|
|
assert cfg.require_setup_password is False
|
|
|
|
|
|
def test_database_section_must_be_object(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"database": "sqlite"}))
|
|
with pytest.raises(ValueError, match="config.database must be an object"):
|
|
load_config(cfg_path)
|
|
|
|
|
|
def test_tls_defaults(tmp_path: Path):
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.tls.enabled is False
|
|
assert cfg.tls.domains == []
|
|
assert cfg.tls.http_port == 80
|
|
|
|
|
|
def test_tls_from_file(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"tls": {
|
|
"enabled": True,
|
|
"mode": "letsencrypt",
|
|
"domains": ["a.example.com"],
|
|
"cert_file": "ssl/fullchain.pem",
|
|
"key_file": "ssl/privkey.pem",
|
|
"expires_at": "2030-01-01T00:00:00+00:00",
|
|
}
|
|
}
|
|
)
|
|
)
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.tls.enabled is True
|
|
assert cfg.tls.mode == "letsencrypt"
|
|
assert cfg.tls.domains == ["a.example.com"]
|
|
|
|
|
|
def test_default_timezone_written_on_fresh_config(tmp_path: Path):
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.default_timezone == "Asia/Shanghai"
|
|
written = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
|
assert written["default_timezone"] == "Asia/Shanghai"
|
|
assert "cron_timezone" not in written
|
|
|
|
|
|
def test_default_timezone_from_file(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"default_timezone": "America/New_York"}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.default_timezone == "America/New_York"
|
|
|
|
|
|
def test_legacy_cron_timezone_key_still_loads(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"cron_timezone": "Europe/Berlin"}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.default_timezone == "Europe/Berlin"
|
|
|
|
|
|
def test_default_timezone_prefers_new_key_over_legacy(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"default_timezone": "UTC",
|
|
"cron_timezone": "Europe/Berlin",
|
|
}
|
|
)
|
|
)
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.default_timezone == "UTC"
|
|
|
|
|
|
def test_default_timezone_env_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_DEFAULT_TIMEZONE", "Pacific/Auckland")
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"default_timezone": "UTC"}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.default_timezone == "Pacific/Auckland"
|
|
|
|
|
|
def test_legacy_cron_timezone_env_still_works(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.delenv("OCTOP_DEFAULT_TIMEZONE", raising=False)
|
|
monkeypatch.setenv("OCTOP_CRON_TIMEZONE", "Asia/Tokyo")
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(json.dumps({"default_timezone": "UTC"}))
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.default_timezone == "Asia/Tokyo"
|
|
|
|
|
|
def test_new_timezone_env_wins_over_legacy_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("OCTOP_DEFAULT_TIMEZONE", "UTC")
|
|
monkeypatch.setenv("OCTOP_CRON_TIMEZONE", "Asia/Tokyo")
|
|
cfg = load_config(tmp_path / "config.json")
|
|
assert cfg.default_timezone == "UTC"
|
|
|
|
|
|
def test_loads_mobile_capabilities(tmp_path: Path) -> None:
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"capabilities": {
|
|
"mobile": {
|
|
"enabled": True,
|
|
"backend": "physical",
|
|
"probed_at": "2026-01-01T00:00:00Z",
|
|
"reason": "",
|
|
}
|
|
}
|
|
}
|
|
)
|
|
)
|
|
cfg = load_config(cfg_path)
|
|
assert cfg.capabilities.mobile.enabled is True
|
|
assert cfg.capabilities.mobile.backend == "physical"
|
|
|
|
|
|
def test_corrupt_config_names_file_and_position_without_leaking_contents(tmp_path: Path):
|
|
"""issue #730: a bare JSONDecodeError never says *which* config file failed."""
|
|
cfg_path = tmp_path / "config.json"
|
|
original = '{"database": {"password": "s3cret"},}' # trailing comma
|
|
cfg_path.write_text(original, encoding="utf-8")
|
|
with pytest.raises(ValueError, match=r"line \d+, column \d+") as excinfo:
|
|
load_config(cfg_path)
|
|
msg = str(excinfo.value)
|
|
assert str(cfg_path) in msg
|
|
assert "s3cret" not in msg
|
|
# the file is left exactly as the user wrote it
|
|
assert cfg_path.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_non_object_config_raises(tmp_path: Path):
|
|
cfg_path = tmp_path / "config.json"
|
|
cfg_path.write_text("[1, 2]", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="JSON object"):
|
|
load_config(cfg_path)
|
|
assert cfg_path.read_text(encoding="utf-8") == "[1, 2]"
|