08a529fa63
ci / lint (push) Successful in 24s
ci / types (push) Successful in 34s
ci / unit (push) Successful in 26s
ci / security (push) Successful in 37s
ci / dockerfile (push) Successful in 6s
ci / chart (push) Successful in 7s
ci / integration (push) Successful in 41s
ci / image (api) (push) Successful in 2m9s
ci / image (reconciler) (push) Successful in 2m1s
ci / image (worker) (push) Successful in 2m7s
ci / bump (push) Successful in 13s
Docker Hub rate-limits anonymous pulls per source IP and every node here shares one NAT address, so a busy afternoon fails an unrelated build with `toomanyrequests`. Nothing in this repo needs to be there. Every base image now comes from mirror.gcr.io (python, alpine/helm, postgres) or ghcr.io (uv, trivy). Verified digest-for-digest against Docker Hub before switching, including the superseded postgres digest this repo still pins, so every existing pin stays valid — same bytes, different transport. catalog.yaml: the three bitnami entries named `bitnamilegacy/<chart>`, a repo alias nothing in the worker image configures, so they could never resolve at provision time. All five entries are now `oci://` refs, which need no `helm repo add`, and all are on latest stable: elasticsearch 21.3.15 -> 22.1.6 redis 20.6.2 -> 27.0.15 postgresql 16.4.5 -> 18.8.0 podinfo 6.7.1 -> 6.14.0 Moving the chart pull is only half of it, though: a bitnami chart defaults its own images to registry-1.docker.io. CatalogEntry gains a `values:` dict, merged under the size's replicas and resources, so an entry can set `global.imageRegistry` and move the image pull too. Size wins on conflict — otherwise an entry setting replicaCount would make every size deploy the same shape. Deep merge, because a shallow one drops sibling keys of a shared nested map. Bitnami charts reject a substituted registry unless `global.security.allowInsecureImages` is set. That check is about provenance, and the mirror serves byte-identical manifests, so it is set deliberately and only for entries whose digests were verified. The dind prune had `--filter until=168h` on both prunes, and it got both cases exactly backwards. `until` reads an image's CREATED time, so it deleted trivy every leg (a released tool image is always older than any window) while protecting the dangling build layers it existed to remove. Measured on node0: 21 dangling images / 5.96GB, and exactly 1 of them older than 168h. Trivy is protected by a tag now, so the image prune drops the filter; buildx keeps it, where age genuinely matters. Tests: +10 unit (deep merge, precedence, no-mutation, and a guard that fails if any catalog entry points at Docker Hub). Both new guards were control-tested by breaking the code and watching them fail. The API test that hardcoded `21.3.15` now reads the catalog — its subject is where the value comes from, not what it is.
210 lines
6.9 KiB
Python
210 lines
6.9 KiB
Python
"""Unit tests for catalog loading and validation."""
|
|
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from svcforge_core.domain.catalog import CatalogError, load_catalog
|
|
from svcforge_core.domain.models import CatalogEntry
|
|
|
|
VALID_YAML = textwrap.dedent("""
|
|
services:
|
|
redis:
|
|
chart: bitnamilegacy/redis
|
|
chart_version: 20.6.2
|
|
sizes:
|
|
small:
|
|
replicas: 1
|
|
resources:
|
|
requests: {cpu: 100m, memory: 256Mi}
|
|
medium:
|
|
replicas: 3
|
|
resources:
|
|
requests: {cpu: 500m, memory: 1Gi}
|
|
postgres:
|
|
chart: bitnamilegacy/postgresql
|
|
chart_version: 16.4.5
|
|
sizes:
|
|
small:
|
|
replicas: 1
|
|
resources:
|
|
requests: {cpu: 250m, memory: 512Mi}
|
|
""")
|
|
|
|
MISSING_CHART_VERSION_YAML = textwrap.dedent("""
|
|
services:
|
|
redis:
|
|
chart: bitnamilegacy/redis
|
|
sizes:
|
|
small:
|
|
replicas: 1
|
|
resources: {}
|
|
""")
|
|
|
|
ZERO_REPLICAS_YAML = textwrap.dedent("""
|
|
services:
|
|
redis:
|
|
chart: bitnamilegacy/redis
|
|
chart_version: 20.6.2
|
|
sizes:
|
|
small:
|
|
replicas: 0
|
|
resources: {}
|
|
""")
|
|
|
|
|
|
def _write(tmp_path: Path, body: str) -> Path:
|
|
path = tmp_path / "catalog.yaml"
|
|
path.write_text(body)
|
|
return path
|
|
|
|
|
|
def test_valid_yaml_loads_to_catalog_entries(tmp_path: Path) -> None:
|
|
catalog = load_catalog(_write(tmp_path, VALID_YAML))
|
|
|
|
assert set(catalog) == {"redis", "postgres"}
|
|
assert all(isinstance(entry, CatalogEntry) for entry in catalog.values())
|
|
redis = catalog["redis"]
|
|
assert redis.service_type == "redis"
|
|
assert redis.chart_version == "20.6.2"
|
|
assert set(redis.sizes) == {"small", "medium"}
|
|
assert redis.sizes["medium"].replicas == 3
|
|
|
|
|
|
def test_missing_chart_version_raises_catalog_error(tmp_path: Path) -> None:
|
|
with pytest.raises(CatalogError) as excinfo:
|
|
load_catalog(_write(tmp_path, MISSING_CHART_VERSION_YAML))
|
|
|
|
assert excinfo.value.key == "redis"
|
|
assert "redis" in str(excinfo.value)
|
|
|
|
|
|
def test_zero_replicas_raises_catalog_error(tmp_path: Path) -> None:
|
|
with pytest.raises(CatalogError) as excinfo:
|
|
load_catalog(_write(tmp_path, ZERO_REPLICAS_YAML))
|
|
|
|
assert excinfo.value.key == "redis"
|
|
|
|
|
|
def test_missing_file_raises_catalog_error(tmp_path: Path) -> None:
|
|
with pytest.raises(CatalogError, match="cannot read catalog"):
|
|
load_catalog(tmp_path / "nope.yaml")
|
|
|
|
|
|
def test_unparseable_yaml_raises_catalog_error(tmp_path: Path) -> None:
|
|
with pytest.raises(CatalogError, match="not valid YAML"):
|
|
load_catalog(_write(tmp_path, "services: [unclosed\n"))
|
|
|
|
|
|
def test_scalar_root_raises_catalog_error(tmp_path: Path) -> None:
|
|
with pytest.raises(CatalogError, match="must be a mapping"):
|
|
load_catalog(_write(tmp_path, "just-a-string\n"))
|
|
|
|
|
|
def test_non_mapping_services_raises_catalog_error(tmp_path: Path) -> None:
|
|
with pytest.raises(CatalogError, match="'services' must be a mapping"):
|
|
load_catalog(_write(tmp_path, "services:\n - redis\n"))
|
|
|
|
|
|
def test_non_mapping_entry_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
|
|
with pytest.raises(CatalogError) as excinfo:
|
|
load_catalog(_write(tmp_path, "services:\n redis: just-a-string\n"))
|
|
|
|
assert excinfo.value.key == "redis"
|
|
assert "must be a mapping" in str(excinfo.value)
|
|
|
|
|
|
def test_non_string_field_key_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
|
|
"""A non-string YAML key inside an entry breaks `**body`; it must surface as CatalogError."""
|
|
body = textwrap.dedent("""
|
|
services:
|
|
redis:
|
|
1: oops
|
|
chart: bitnamilegacy/redis
|
|
chart_version: 20.6.2
|
|
sizes: {}
|
|
""")
|
|
with pytest.raises(CatalogError) as excinfo:
|
|
load_catalog(_write(tmp_path, body))
|
|
|
|
assert excinfo.value.key == "redis"
|
|
|
|
|
|
def test_bare_mapping_without_services_key_is_accepted(tmp_path: Path) -> None:
|
|
"""The top-level `services:` wrapper is optional; a bare service_type mapping also loads."""
|
|
body = textwrap.dedent("""
|
|
redis:
|
|
chart: bitnamilegacy/redis
|
|
chart_version: 20.6.2
|
|
sizes:
|
|
small:
|
|
replicas: 1
|
|
resources: {}
|
|
""")
|
|
catalog = load_catalog(_write(tmp_path, body))
|
|
|
|
assert set(catalog) == {"redis"}
|
|
|
|
|
|
def test_repo_catalog_yaml_is_valid() -> None:
|
|
catalog = load_catalog(Path(__file__).parents[2] / "catalog.yaml")
|
|
|
|
assert set(catalog) == {"elasticsearch", "redis", "postgres", "podinfo", "nginx"}
|
|
for entry in catalog.values():
|
|
assert set(entry.sizes) == {"small", "medium"}
|
|
|
|
|
|
def test_entry_values_default_to_empty(tmp_path: Path) -> None:
|
|
"""`values:` is optional — an entry that needs no chart knobs says nothing."""
|
|
body = textwrap.dedent("""
|
|
services:
|
|
podinfo:
|
|
chart: oci://ghcr.io/stefanprodan/charts/podinfo
|
|
chart_version: "6.14.0"
|
|
sizes:
|
|
small: {replicas: 1, resources: {}}
|
|
""")
|
|
assert load_catalog(_write(tmp_path, body))["podinfo"].values == {}
|
|
|
|
|
|
def test_entry_values_are_parsed(tmp_path: Path) -> None:
|
|
"""Nested values survive the load, which is what `global.imageRegistry` needs."""
|
|
body = textwrap.dedent("""
|
|
services:
|
|
redis:
|
|
chart: oci://mirror.gcr.io/bitnamicharts/redis
|
|
chart_version: "27.0.15"
|
|
values:
|
|
global:
|
|
imageRegistry: mirror.gcr.io
|
|
sizes:
|
|
small: {replicas: 1, resources: {}}
|
|
""")
|
|
assert load_catalog(_write(tmp_path, body))["redis"].values == {
|
|
"global": {"imageRegistry": "mirror.gcr.io"}
|
|
}
|
|
|
|
|
|
def test_no_catalog_entry_pulls_from_docker_hub() -> None:
|
|
"""Every chart, and every image registry an entry pins, avoids Docker Hub.
|
|
|
|
Docker Hub rate-limits anonymous pulls per source IP and the whole cluster shares one
|
|
NAT address, so a Docker Hub reference here is a provision that fails under load for a
|
|
reason no log in this repo will explain.
|
|
"""
|
|
catalog = load_catalog(Path(__file__).parents[2] / "catalog.yaml")
|
|
banned = ("docker.io", "registry-1.docker.io", "index.docker.io")
|
|
|
|
for name, entry in catalog.items():
|
|
assert not entry.chart.startswith(banned), f"{name}: chart on Docker Hub"
|
|
assert "docker.io" not in entry.chart, f"{name}: chart on Docker Hub"
|
|
|
|
registry = entry.values.get("global", {}).get("imageRegistry")
|
|
# A bitnami chart defaults its images to Docker Hub, so any entry pointing at one
|
|
# has to redirect them. podinfo's chart already names ghcr.io and needs nothing.
|
|
if "bitnamicharts" in entry.chart:
|
|
assert registry == "mirror.gcr.io", f"{name}: bitnami chart without a registry override"
|
|
if registry is not None:
|
|
assert "docker.io" not in registry, f"{name}: imageRegistry on Docker Hub"
|