64266f40e9
CONFIG_PATH now accepts a directory path (all *.yaml files merged) or a main file with a config_dir key pointing to a drop-in directory. Remotes are merged alphabetically across files; later files win on conflicts.
541 lines
21 KiB
Python
541 lines
21 KiB
Python
"""Tests for ConfigManager, focusing on get_mutable_patterns and get_immutable_patterns."""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from artifactapi.config import ConfigManager
|
|
|
|
|
|
@pytest.fixture
|
|
def make_config(tmp_path):
|
|
"""Factory: write a remotes dict to a temp YAML and return a ConfigManager."""
|
|
|
|
def _make(remotes_dict):
|
|
cfg_file = tmp_path / "remotes.yaml"
|
|
cfg_file.write_text(yaml.dump({"remotes": remotes_dict}))
|
|
return ConfigManager(str(cfg_file))
|
|
|
|
return _make
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_mutable_patterns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetMutablePatterns:
|
|
def test_alpine_returns_package_defaults(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert r"APKINDEX\.tar\.gz$" in patterns
|
|
|
|
def test_rpm_returns_package_defaults(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "rpm", "base_url": "https://x.com"}})
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert r"repomd\.xml$" in patterns
|
|
assert any("repodata" in p for p in patterns)
|
|
|
|
def test_docker_returns_package_defaults(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "docker", "base_url": "https://x.com"}})
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert any("manifests" in p for p in patterns)
|
|
assert any("tags/list" in p for p in patterns)
|
|
|
|
def test_generic_returns_empty_list(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
|
assert cfg.get_mutable_patterns("r") == []
|
|
|
|
def test_unknown_remote_returns_empty_list(self, make_config):
|
|
cfg = make_config({})
|
|
assert cfg.get_mutable_patterns("nonexistent") == []
|
|
|
|
def test_missing_package_field_defaults_to_generic(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "base_url": "https://x.com"}})
|
|
assert cfg.get_mutable_patterns("r") == []
|
|
|
|
def test_unknown_package_type_returns_empty_list(self, make_config):
|
|
# A mis-spelled package type silently returns [] — this is a known footgun
|
|
cfg = make_config({"r": {"type": "remote", "package": "deb", "base_url": "https://x.com"}})
|
|
assert cfg.get_mutable_patterns("r") == []
|
|
|
|
def test_extra_patterns_appended_after_defaults(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "alpine",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [r"custom\.json$"],
|
|
}
|
|
}
|
|
)
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert r"APKINDEX\.tar\.gz$" in patterns
|
|
assert r"custom\.json$" in patterns
|
|
# Defaults come first
|
|
assert patterns.index(r"APKINDEX\.tar\.gz$") < patterns.index(r"custom\.json$")
|
|
|
|
def test_explicit_empty_extra_patterns_returns_defaults(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "alpine",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [],
|
|
}
|
|
}
|
|
)
|
|
assert r"APKINDEX\.tar\.gz$" in cfg.get_mutable_patterns("r")
|
|
|
|
def test_duplicate_extra_pattern_not_added_twice(self, make_config):
|
|
existing = r"APKINDEX\.tar\.gz$"
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "alpine",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [existing],
|
|
}
|
|
}
|
|
)
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert patterns.count(existing) == 1
|
|
|
|
def test_generic_with_only_extra_patterns(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [r"meta\.json$", r"index\.yaml$"],
|
|
}
|
|
}
|
|
)
|
|
assert cfg.get_mutable_patterns("r") == [r"meta\.json$", r"index\.yaml$"]
|
|
|
|
def test_rpm_extra_patterns_merged(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "rpm",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [r"custom-meta\.xml$"],
|
|
}
|
|
}
|
|
)
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert r"repomd\.xml$" in patterns
|
|
assert r"custom-meta\.xml$" in patterns
|
|
|
|
def test_npm_has_no_package_defaults(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "npm", "base_url": "https://x.com"}})
|
|
assert cfg.get_mutable_patterns("r") == []
|
|
|
|
def test_npm_explicit_mutable_pattern_matches_metadata(self, make_config):
|
|
import re
|
|
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "npm",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
|
}
|
|
}
|
|
)
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert any(re.search(p, "express") for p in patterns)
|
|
assert any(re.search(p, "@babel/core") for p in patterns)
|
|
|
|
def test_helm_returns_index_yaml_as_mutable(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert r"index\.yaml$" in patterns
|
|
|
|
def test_helm_chart_tarballs_not_mutable_by_default(self, make_config):
|
|
import re
|
|
|
|
cfg = make_config({"r": {"type": "remote", "package": "helm", "base_url": "https://helm.example.com"}})
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
# Only index.yaml is mutable; .tgz chart tarballs are not
|
|
assert not any(re.search(p, "vault-0.29.1.tgz") for p in patterns)
|
|
assert not any(re.search(p, "consul-1.5.0.tgz") for p in patterns)
|
|
|
|
def test_npm_explicit_mutable_pattern_excludes_tarballs(self, make_config):
|
|
import re
|
|
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "npm",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [r"^(?!.*\.tgz$).*"],
|
|
}
|
|
}
|
|
)
|
|
patterns = cfg.get_mutable_patterns("r")
|
|
assert not any(re.search(p, "express-4.18.2.tgz") for p in patterns)
|
|
assert not any(re.search(p, "express/-/express-4.18.2.tgz") for p in patterns)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_immutable_patterns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetImmutablePatterns:
|
|
def test_returns_immutable_patterns(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
|
}
|
|
}
|
|
)
|
|
assert cfg.get_immutable_patterns("r") == [r".*\.tar\.gz$"]
|
|
|
|
def test_returns_empty_for_missing_remote(self, make_config):
|
|
cfg = make_config({})
|
|
assert cfg.get_immutable_patterns("nonexistent") == []
|
|
|
|
def test_returns_empty_when_no_patterns_configured(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
|
assert cfg.get_immutable_patterns("r") == []
|
|
|
|
def test_multiple_patterns_returned(self, make_config):
|
|
patterns = [r".*\.rpm$", r".*/repodata/.*$"]
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "rpm",
|
|
"base_url": "https://x.com",
|
|
"immutable_patterns": patterns,
|
|
}
|
|
}
|
|
)
|
|
assert cfg.get_immutable_patterns("r") == patterns
|
|
|
|
def test_dict_keyed_repositories_returns_per_repo_patterns(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
|
"repositories": {
|
|
"/path/to/repo": {"immutable_patterns": [r".*\.rpm$"]},
|
|
},
|
|
}
|
|
}
|
|
)
|
|
assert cfg.get_immutable_patterns("r", "/path/to/repo") == [r".*\.rpm$"]
|
|
|
|
def test_dict_keyed_repositories_falls_back_to_remote_patterns(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"immutable_patterns": [r".*\.tar\.gz$"],
|
|
"repositories": {
|
|
"/path/to/repo": {"immutable_patterns": [r".*\.rpm$"]},
|
|
},
|
|
}
|
|
}
|
|
)
|
|
assert cfg.get_immutable_patterns("r", "/unknown/path") == [r".*\.tar\.gz$"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_user_mutable_patterns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetUserMutablePatterns:
|
|
def test_returns_only_user_patterns(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "alpine",
|
|
"base_url": "https://x.com",
|
|
"mutable_patterns": [r"custom\.json$"],
|
|
}
|
|
}
|
|
)
|
|
assert cfg.get_user_mutable_patterns("r") == [r"custom\.json$"]
|
|
|
|
def test_excludes_package_defaults(self, make_config):
|
|
# Package defaults (APKINDEX etc.) must NOT appear here
|
|
cfg = make_config({"r": {"type": "remote", "package": "alpine", "base_url": "https://x.com"}})
|
|
assert cfg.get_user_mutable_patterns("r") == []
|
|
|
|
def test_returns_empty_for_missing_remote(self, make_config):
|
|
cfg = make_config({})
|
|
assert cfg.get_user_mutable_patterns("nonexistent") == []
|
|
|
|
def test_returns_empty_when_key_absent(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
|
assert cfg.get_user_mutable_patterns("r") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_cache_config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetCacheConfig:
|
|
def test_returns_cache_section(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 7200},
|
|
}
|
|
}
|
|
)
|
|
assert cfg.get_cache_config("r") == {"immutable_ttl": 0, "mutable_ttl": 7200}
|
|
|
|
def test_returns_empty_dict_for_missing_remote(self, make_config):
|
|
cfg = make_config({})
|
|
assert cfg.get_cache_config("nonexistent") == {}
|
|
|
|
def test_returns_empty_dict_when_no_cache_key(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
|
assert cfg.get_cache_config("r") == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config file reload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigReload:
|
|
def test_reloads_when_file_mtime_advances(self, tmp_path):
|
|
cfg_file = tmp_path / "remotes.yaml"
|
|
cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}}))
|
|
cfg = ConfigManager(str(cfg_file))
|
|
assert "repo-a" in cfg.config["remotes"]
|
|
|
|
cfg_file.write_text(yaml.dump({"remotes": {"repo-b": {"type": "remote", "package": "generic", "base_url": "https://y.com"}}}))
|
|
future_mtime = cfg._last_modified + 1
|
|
os.utime(str(cfg_file), (future_mtime, future_mtime))
|
|
|
|
cfg._check_reload()
|
|
|
|
assert "repo-b" in cfg.config["remotes"]
|
|
assert "repo-a" not in cfg.config["remotes"]
|
|
|
|
def test_no_reload_when_file_unchanged(self, tmp_path):
|
|
cfg_file = tmp_path / "remotes.yaml"
|
|
cfg_file.write_text(yaml.dump({"remotes": {"repo-a": {"type": "remote", "package": "generic", "base_url": "https://x.com"}}}))
|
|
cfg = ConfigManager(str(cfg_file))
|
|
|
|
# Call check_reload without touching the file — should not reload
|
|
cfg._check_reload()
|
|
|
|
assert "repo-a" in cfg.config["remotes"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_quarantine_config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetQuarantineConfig:
|
|
def test_returns_false_zero_when_not_configured(self, make_config):
|
|
cfg = make_config({"r": {"type": "remote", "package": "generic", "base_url": "https://x.com"}})
|
|
enabled, days = cfg.get_quarantine_config("r")
|
|
assert enabled is False
|
|
assert days == 0
|
|
|
|
def test_returns_false_zero_for_missing_remote(self, make_config):
|
|
cfg = make_config({})
|
|
enabled, days = cfg.get_quarantine_config("nonexistent")
|
|
assert enabled is False
|
|
assert days == 0
|
|
|
|
def test_enabled_true_and_days_returned(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"quarantine_new": True,
|
|
"quarantine_days": 7,
|
|
}
|
|
}
|
|
)
|
|
enabled, days = cfg.get_quarantine_config("r")
|
|
assert enabled is True
|
|
assert days == 7
|
|
|
|
def test_quarantine_new_false_returns_disabled(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"quarantine_new": False,
|
|
"quarantine_days": 7,
|
|
}
|
|
}
|
|
)
|
|
enabled, days = cfg.get_quarantine_config("r")
|
|
assert enabled is False
|
|
assert days == 7
|
|
|
|
def test_enabled_with_zero_days_returns_zero(self, make_config):
|
|
cfg = make_config(
|
|
{
|
|
"r": {
|
|
"type": "remote",
|
|
"package": "generic",
|
|
"base_url": "https://x.com",
|
|
"quarantine_new": True,
|
|
"quarantine_days": 0,
|
|
}
|
|
}
|
|
)
|
|
enabled, days = cfg.get_quarantine_config("r")
|
|
assert enabled is True
|
|
assert days == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Directory mode (CONFIG_PATH points to a directory)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _remote(base_url: str = "https://x.com") -> dict:
|
|
return {"type": "remote", "package": "generic", "base_url": base_url}
|
|
|
|
|
|
class TestConfigDirMode:
|
|
def test_loads_all_yaml_files(self, tmp_path):
|
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
|
(tmp_path / "b.yaml").write_text(yaml.dump({"remotes": {"repo-b": _remote("https://y.com")}}))
|
|
cfg = ConfigManager(str(tmp_path))
|
|
assert "repo-a" in cfg.config["remotes"]
|
|
assert "repo-b" in cfg.config["remotes"]
|
|
|
|
def test_later_file_overrides_earlier_on_same_key(self, tmp_path):
|
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://first.com")}}))
|
|
(tmp_path / "b.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://second.com")}}))
|
|
cfg = ConfigManager(str(tmp_path))
|
|
assert cfg.config["remotes"]["r"]["base_url"] == "https://second.com"
|
|
|
|
def test_empty_directory_returns_empty_remotes(self, tmp_path):
|
|
cfg = ConfigManager(str(tmp_path))
|
|
assert cfg.config == {"remotes": {}}
|
|
|
|
def test_ignores_non_yaml_files(self, tmp_path):
|
|
(tmp_path / "notes.txt").write_text("not yaml")
|
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
|
cfg = ConfigManager(str(tmp_path))
|
|
assert list(cfg.config["remotes"].keys()) == ["repo-a"]
|
|
|
|
def test_reload_picks_up_new_file(self, tmp_path):
|
|
(tmp_path / "a.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
|
cfg = ConfigManager(str(tmp_path))
|
|
assert "repo-a" in cfg.config["remotes"]
|
|
assert "repo-b" not in cfg.config["remotes"]
|
|
|
|
new_file = tmp_path / "b.yaml"
|
|
new_file.write_text(yaml.dump({"remotes": {"repo-b": _remote("https://y.com")}}))
|
|
future_mtime = cfg._last_modified + 1
|
|
os.utime(str(new_file), (future_mtime, future_mtime))
|
|
|
|
cfg._check_reload()
|
|
|
|
assert "repo-a" in cfg.config["remotes"]
|
|
assert "repo-b" in cfg.config["remotes"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# config_dir key (main file contains a config_dir pointer)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigDirKey:
|
|
def test_merges_remotes_from_config_dir(self, tmp_path):
|
|
conf_d = tmp_path / "conf.d"
|
|
conf_d.mkdir()
|
|
(conf_d / "remotes.yaml").write_text(yaml.dump({"remotes": {"repo-extra": _remote("https://extra.com")}}))
|
|
main = tmp_path / "config.yaml"
|
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"repo-main": _remote()}}))
|
|
cfg = ConfigManager(str(main))
|
|
assert "repo-main" in cfg.config["remotes"]
|
|
assert "repo-extra" in cfg.config["remotes"]
|
|
|
|
def test_relative_config_dir_resolved_from_main_file(self, tmp_path):
|
|
conf_d = tmp_path / "conf.d"
|
|
conf_d.mkdir()
|
|
(conf_d / "r.yaml").write_text(yaml.dump({"remotes": {"repo-a": _remote()}}))
|
|
main = tmp_path / "config.yaml"
|
|
main.write_text(yaml.dump({"config_dir": "conf.d", "remotes": {}}))
|
|
cfg = ConfigManager(str(main))
|
|
assert "repo-a" in cfg.config["remotes"]
|
|
|
|
def test_config_dir_key_not_present_in_loaded_config(self, tmp_path):
|
|
conf_d = tmp_path / "conf.d"
|
|
conf_d.mkdir()
|
|
main = tmp_path / "config.yaml"
|
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {}}))
|
|
cfg = ConfigManager(str(main))
|
|
assert "config_dir" not in cfg.config
|
|
|
|
def test_dir_remote_overrides_main_file_remote(self, tmp_path):
|
|
conf_d = tmp_path / "conf.d"
|
|
conf_d.mkdir()
|
|
(conf_d / "override.yaml").write_text(yaml.dump({"remotes": {"r": _remote("https://new.com")}}))
|
|
main = tmp_path / "config.yaml"
|
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"r": _remote("https://old.com")}}))
|
|
cfg = ConfigManager(str(main))
|
|
assert cfg.config["remotes"]["r"]["base_url"] == "https://new.com"
|
|
|
|
def test_empty_config_dir_uses_main_file_only(self, tmp_path):
|
|
conf_d = tmp_path / "conf.d"
|
|
conf_d.mkdir()
|
|
main = tmp_path / "config.yaml"
|
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {"repo-main": _remote()}}))
|
|
cfg = ConfigManager(str(main))
|
|
assert list(cfg.config["remotes"].keys()) == ["repo-main"]
|
|
|
|
def test_reload_picks_up_changed_dir_file(self, tmp_path):
|
|
conf_d = tmp_path / "conf.d"
|
|
conf_d.mkdir()
|
|
dir_file = conf_d / "r.yaml"
|
|
dir_file.write_text(yaml.dump({"remotes": {"repo-v1": _remote()}}))
|
|
main = tmp_path / "config.yaml"
|
|
main.write_text(yaml.dump({"config_dir": str(conf_d), "remotes": {}}))
|
|
cfg = ConfigManager(str(main))
|
|
assert "repo-v1" in cfg.config["remotes"]
|
|
|
|
dir_file.write_text(yaml.dump({"remotes": {"repo-v2": _remote("https://v2.com")}}))
|
|
future_mtime = cfg._last_modified + 1
|
|
os.utime(str(dir_file), (future_mtime, future_mtime))
|
|
|
|
cfg._check_reload()
|
|
|
|
assert "repo-v2" in cfg.config["remotes"]
|
|
assert "repo-v1" not in cfg.config["remotes"]
|