fix(sre): reject retired host110 ollama routes
This commit is contained in:
@@ -374,6 +374,27 @@ class Settings(BaseSettings):
|
||||
if not host:
|
||||
raise ValueError(f"OLLAMA URL 缺少 hostname:{v!r}")
|
||||
|
||||
try:
|
||||
parsed_ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
parsed_ip = None
|
||||
canonical_ip = (
|
||||
parsed_ip.ipv4_mapped
|
||||
if isinstance(parsed_ip, ipaddress.IPv6Address)
|
||||
and parsed_ip.ipv4_mapped is not None
|
||||
else parsed_ip
|
||||
)
|
||||
|
||||
# Host110 的 Ollama transport/proxy 已退役。私網位址本身符合 SSRF
|
||||
# allowlist 並不代表仍是合法資產;若舊 ConfigMap/env 漂移回 Host110,
|
||||
# 必須在任何 health probe、fallback 或 Telegram 告警前 fail closed。
|
||||
# Canonical IP comparison also closes IPv4-mapped IPv6 aliases.
|
||||
if canonical_ip == ipaddress.ip_address("192.168.0.110"):
|
||||
raise ValueError(
|
||||
"asset_identity_unresolved: Host110 Ollama transport is retired; "
|
||||
f"received {v!r}"
|
||||
)
|
||||
|
||||
# Kubernetes Service hostname 白名單(K8s DNS + 開發別名)
|
||||
_ALLOWED_HOSTNAMES = {
|
||||
"localhost",
|
||||
@@ -397,14 +418,13 @@ class Settings(BaseSettings):
|
||||
return v
|
||||
|
||||
# 否則必須是 private/loopback IP
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
if parsed_ip is None:
|
||||
# hostname 不是 IP 也不在白名單 → 拒絕
|
||||
raise ValueError(
|
||||
f"OLLAMA URL host 不允許的外部域名:{host!r}(完整 URL:{v!r})"
|
||||
",必須使用私網 IP 或已知 K8s Service hostname"
|
||||
) from None
|
||||
ip = parsed_ip
|
||||
if not (ip.is_private or ip.is_loopback):
|
||||
raise ValueError(
|
||||
f"OLLAMA URL 必須是私網/loopback IP、已知 K8s SVC 或 GCP 白名單 IP,"
|
||||
|
||||
@@ -108,6 +108,21 @@ def test_private_ip_192_168_accepted():
|
||||
assert s.OLLAMA_URL == "http://192.168.0.111:11434"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "url"),
|
||||
(
|
||||
("OLLAMA_URL", "http://192.168.0.110:11435"),
|
||||
("OLLAMA_SECONDARY_URL", "http://192.168.0.110:11436"),
|
||||
("OLLAMA_FALLBACK_URL", "http://192.168.0.110:11437"),
|
||||
("OLLAMA_URL", "http://[::ffff:192.168.0.110]:11435"),
|
||||
),
|
||||
)
|
||||
def test_retired_host110_ollama_transport_is_rejected(field: str, url: str):
|
||||
"""Retired Host110 must never re-enter any provider slot via stale env/config."""
|
||||
with pytest.raises(ValidationError, match="asset_identity_unresolved"):
|
||||
_make_settings(**{field: url})
|
||||
|
||||
|
||||
def test_private_ip_10_x_accepted():
|
||||
"""10.x.x.x 是 RFC1918 私網 IP,應通過"""
|
||||
s = _make_settings(OLLAMA_URL="http://10.0.0.5:11434")
|
||||
|
||||
@@ -10,9 +10,9 @@ from src.services.ollama_endpoint_resolver import (
|
||||
|
||||
def _settings(
|
||||
*,
|
||||
primary: str = "http://192.168.0.110:11435",
|
||||
secondary: str = "http://192.168.0.110:11436",
|
||||
fallback: str = "http://192.168.0.110:11437",
|
||||
primary: str = "http://34.143.170.20:11434",
|
||||
secondary: str = "http://34.21.145.224:11434",
|
||||
fallback: str = "http://192.168.0.111:11434",
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
OLLAMA_URL=primary,
|
||||
@@ -42,7 +42,7 @@ def test_all_workloads_prefer_gcp_a_lane() -> None:
|
||||
"dr",
|
||||
):
|
||||
selection = resolve_ollama_selection(workload, config=cfg)
|
||||
assert selection.url == "http://192.168.0.110:11435"
|
||||
assert selection.url == "http://34.143.170.20:11434"
|
||||
assert selection.provider_name == "ollama_gcp_a"
|
||||
assert selection.reason == "global_primary_gcp_a"
|
||||
|
||||
@@ -53,9 +53,9 @@ def test_general_workloads_share_global_ollama_order() -> None:
|
||||
for workload in ("interactive", "deep_rca", "local_required", "privacy_sensitive", "dr"):
|
||||
order = resolve_ollama_order(workload, config=cfg)
|
||||
assert [selection.url for selection in order] == [
|
||||
"http://192.168.0.110:11435",
|
||||
"http://192.168.0.110:11436",
|
||||
"http://192.168.0.110:11437",
|
||||
"http://34.143.170.20:11434",
|
||||
"http://34.21.145.224:11434",
|
||||
"http://192.168.0.111:11434",
|
||||
]
|
||||
assert [selection.provider_name for selection in order] == [
|
||||
"ollama_gcp_a",
|
||||
@@ -78,14 +78,14 @@ def test_alert_fast_uses_gcp_b_when_gcp_a_is_not_configured() -> None:
|
||||
selection = resolve_ollama_selection("alert_fast", config=_settings(primary=""))
|
||||
|
||||
assert selection.provider_name == "ollama_gcp_b"
|
||||
assert selection.url == "http://192.168.0.110:11436"
|
||||
assert selection.url == "http://34.21.145.224:11434"
|
||||
|
||||
|
||||
def test_non_sensitive_workloads_fall_back_to_gcp_b_when_primary_missing() -> None:
|
||||
cfg = _settings(primary="")
|
||||
|
||||
selection = resolve_ollama_selection("embedding", config=cfg)
|
||||
assert selection.url == "http://192.168.0.110:11436"
|
||||
assert selection.url == "http://34.21.145.224:11434"
|
||||
assert selection.provider_name == "ollama_gcp_b"
|
||||
assert selection.reason == "global_secondary_gcp_b"
|
||||
|
||||
|
||||
@@ -335,7 +335,10 @@
|
||||
"docs/operations/portfolio-infrastructure-asset-reconciliation.snapshot.json",
|
||||
"docs/operations/portfolio-infrastructure-asset-reconciliation-handoff.md",
|
||||
"apps/api/src/services/portfolio_infrastructure_asset_reconciliation.py",
|
||||
"apps/api/src/core/config.py",
|
||||
"apps/api/src/api/v1/agents.py",
|
||||
"apps/api/tests/test_config_url_validation.py",
|
||||
"apps/api/tests/test_ollama_endpoint_resolver.py",
|
||||
"apps/api/tests/test_portfolio_infrastructure_asset_reconciliation_api.py",
|
||||
"scripts/ops/retire-host110-ollama-proxy.sh",
|
||||
"scripts/ops/render-service-registry-configmap.py",
|
||||
@@ -348,16 +351,18 @@
|
||||
"confirmed_truth": [
|
||||
"Host110 Ollama has been removed and must remain a retired tombstone, never a fallback endpoint",
|
||||
"AIA-SRE-002 run-aia-sre-002-20260716-1752 removed the exact stale Nginx 11435 proxy with rollback backup and independently verified listeners 11435/11434=0, Ollama containers=0, stale config paths=0, nginx active/test pass and Prometheus host110 Ollama targets=0",
|
||||
"The Settings asset gate rejects native and IPv4-mapped IPv6 Host110 Ollama URLs as asset_identity_unresolved before any health probe, fallback decision or Telegram alert; focused URL/order/manifest tests pass locally",
|
||||
"Host111 is the third provider hop and runs as a macOS LaunchAgent, not systemd",
|
||||
"GCP-A and GCP-B source targets are public HTTP sanitized candidates only",
|
||||
"gitea-native source target is not production runtime evidence while the legacy GitHub exporter remains visible"
|
||||
],
|
||||
"runtime_gaps": [
|
||||
"The Host110 stale-endpoint asset gate has source and local-test evidence only; production startup/readback has not yet proven the retired endpoint cannot recur",
|
||||
"Host111 LaunchAgent lacks completed local plus host120/host121 origin readback and a fresh exact sensor series",
|
||||
"GCP-A/GCP-B exact Prometheus target series are missing in production",
|
||||
"gitea-native exact target is missing and legacy GitHub exporter runtime drift is unresolved"
|
||||
],
|
||||
"next_action": "deploy the reconciled tombstone/targets, preserve the verified Host110 absence receipt, then verify Host111 LaunchAgent from local and host120/121, GCP-A/GCP-B exact Prometheus series, gitea-native target freshness and legacy exporter absence under one source SHA"
|
||||
"next_action": "deploy the Host110 asset gate with the reconciled tombstone/targets, preserve the verified Host110 absence receipt, then verify Host111 LaunchAgent from local and host120/121, GCP-A/GCP-B exact Prometheus series, gitea-native target freshness and legacy exporter absence under one source SHA"
|
||||
},
|
||||
{
|
||||
"id": "AIA-SRE-003",
|
||||
|
||||
Reference in New Issue
Block a user