feat(#56): 补 azkv SECRET_RESOLVER —— 用 Pod workload identity 从 heicode-vault 读模型 key
#56 缺口:`_resolve_secret_ref` 此前只读 dev 环境映射 `HEICODE_SECRET_<name>`, 没有生产从 Azure Key Vault 取 key 的实现(原注释写"适配器在仓外",实际缺)。 本次在仓内补上,走刚建好的 Pod workload identity: - `_resolve_from_keyvault`:`DefaultAzureCredential` + `SecretClient` 读 azkv:// ref; **lazy import** azure SDK,任何失败(未启用/不可解析/SDK 缺/无凭证/网络/secret 不存在) 返回 None —— 不伪造、不抛。 - `_azkv_enabled`:**仅当** Pod 注入了 workload identity(`AZURE_FEDERATED_TOKEN_FILE`) 或显式 `SECRET_RESOLVER=azkv` 才真连 KV —— dev/CI/测试保持 hermetic、不碰网络。 - `_parse_azkv_ref`:解析 `azkv://<vault>/secrets/<name>[/<ver>]`(裸名→`https://<name>.vault.azure.net`, 全 host 保留,兼容短形式)。 - `_resolve_secret_ref` 顺序:dev 环境映射 → KV(workload identity),保持既有 dev 行为不变。 - requirements:加 `azure-keyvault-secrets`(lazy import;`azure-identity` 已在)。 测试 `test-agent-launcher.py` 新增:azkv 解析、gating(默认关、两种开关)、disabled→None。 `test-agent-launcher` / `test-key-injection-contract` 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
185c0033b1
commit
207e027fbb
@@ -123,14 +123,82 @@ def _extract_model_key(secret_value: str) -> Optional[str]:
|
||||
def _resolve_secret_ref(secret_ref: str) -> Optional[str]:
|
||||
"""Resolve an azkv:// ref to the model key.
|
||||
|
||||
Production: an Azure Key Vault adapter (out of this repo; wire via deployment) reads the secret
|
||||
named by the ref's trailing segment and returns its value. Dev/CI: read env
|
||||
`HEICODE_SECRET_<name>`. Either way the value is the HM #60 JSON ``{"openai_api_key": "sk-..."}``
|
||||
(bare string also accepted). Returns None when unavailable — we never fabricate a key.
|
||||
Order: (1) dev/CI env map ``HEICODE_SECRET_<name>`` — keeps tests/dev hermetic and offline;
|
||||
(2) production Azure Key Vault read via the pod's **workload identity** (``_resolve_from_keyvault``,
|
||||
agent_swarm#56). Either source yields the HM #60 JSON ``{"openai_api_key": "sk-..."}`` (bare string
|
||||
also accepted). Returns None when unavailable — we never fabricate a key.
|
||||
"""
|
||||
name = secret_ref.rstrip("/").rsplit("/", 1)[-1]
|
||||
raw = os.getenv(f"HEICODE_SECRET_{name}")
|
||||
return _extract_model_key(raw) if raw is not None else None
|
||||
if raw is not None:
|
||||
return _extract_model_key(raw)
|
||||
return _resolve_from_keyvault(secret_ref)
|
||||
|
||||
|
||||
def _azkv_enabled() -> bool:
|
||||
"""Whether to attempt a real Key Vault read.
|
||||
|
||||
Only when the orchestrator pod has **workload identity** injected (the AKS webhook sets
|
||||
``AZURE_FEDERATED_TOKEN_FILE`` when the SA is annotated + the pod is labelled
|
||||
``azure.workload.identity/use: "true"``), or a deployment explicitly opts in with
|
||||
``SECRET_RESOLVER=azkv``. Keeps dev/CI/tests hermetic: without these the resolver never imports
|
||||
the azure SDK and never touches the network.
|
||||
"""
|
||||
return bool(
|
||||
os.getenv("AZURE_FEDERATED_TOKEN_FILE")
|
||||
or os.getenv("SECRET_RESOLVER", "").strip().lower() == "azkv"
|
||||
)
|
||||
|
||||
|
||||
def _parse_azkv_ref(secret_ref: str):
|
||||
"""``azkv://<vault>/secrets/<name>[/<version>]`` → ``(vault_url, secret_name, version|None)``.
|
||||
|
||||
``<vault>`` may be a bare name (→ ``https://<name>.vault.azure.net``) or a full host. Also
|
||||
tolerates the short ``azkv://<vault>/<name>`` form. Returns None if it can't parse.
|
||||
"""
|
||||
rest = secret_ref[len("azkv://"):].strip("/") if secret_ref.startswith("azkv://") else ""
|
||||
parts = [p for p in rest.split("/") if p]
|
||||
if len(parts) >= 3 and parts[1] == "secrets":
|
||||
host, name, version = parts[0], parts[2], (parts[3] if len(parts) > 3 else None)
|
||||
elif len(parts) == 2:
|
||||
host, name, version = parts[0], parts[1], None
|
||||
else:
|
||||
return None
|
||||
if not host or not name:
|
||||
return None
|
||||
vault_url = host if host.startswith("http") else (
|
||||
f"https://{host}" if "." in host else f"https://{host}.vault.azure.net")
|
||||
return vault_url, name, version
|
||||
|
||||
|
||||
def _resolve_from_keyvault(secret_ref: str) -> Optional[str]:
|
||||
"""Read the model key from Azure Key Vault using the pod's workload identity.
|
||||
|
||||
`DefaultAzureCredential` picks up the federated token the AKS workload-identity webhook injects
|
||||
(see `_azkv_enabled`). Lazy-imports the azure SDK so dev/CI without it are unaffected. Returns
|
||||
None on ANY failure (not enabled / unparseable ref / SDK missing / no credential / network /
|
||||
secret absent) — never fabricates, never raises.
|
||||
"""
|
||||
if not _azkv_enabled():
|
||||
return None
|
||||
parsed = _parse_azkv_ref(secret_ref)
|
||||
if not parsed:
|
||||
logger.warning("azkv resolver: unparseable secret_ref")
|
||||
return None
|
||||
vault_url, name, version = parsed
|
||||
try:
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from azure.keyvault.secrets import SecretClient
|
||||
except Exception as exc: # SDK not installed
|
||||
logger.warning("azkv resolver: azure SDK unavailable (%s); add azure-identity + azure-keyvault-secrets", exc)
|
||||
return None
|
||||
try:
|
||||
client = SecretClient(vault_url=vault_url, credential=DefaultAzureCredential())
|
||||
secret = client.get_secret(name, version) if version else client.get_secret(name)
|
||||
return _extract_model_key(secret.value)
|
||||
except Exception as exc: # no credential / RBAC / network / missing secret
|
||||
logger.warning("azkv resolver: failed to read secret '%s' from %s: %s", name, vault_url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def model_api_base() -> str:
|
||||
|
||||
@@ -19,3 +19,6 @@ opentelemetry-instrumentation-logging==0.45b0
|
||||
azure-cosmos==4.7.0
|
||||
azure-storage-blob==12.23.1
|
||||
azure-identity==1.19.0
|
||||
# model-key resolution from Key Vault via Pod workload identity (lazy-imported in
|
||||
# agent_launcher._resolve_from_keyvault; only when AZURE_FEDERATED_TOKEN_FILE / SECRET_RESOLVER=azkv)
|
||||
azure-keyvault-secrets==4.9.0
|
||||
|
||||
@@ -80,6 +80,36 @@ def test_resolve_model_key():
|
||||
check("unresolved -> None (never fabricated)", al.resolve_model_key({"billing_context": {"secret_ref": "azkv://kv/secrets/missing"}}) is None)
|
||||
|
||||
|
||||
def test_azkv_resolver():
|
||||
# ── parse azkv:// refs (pure) ──
|
||||
check("azkv parse: bare vault name -> https URL",
|
||||
al._parse_azkv_ref("azkv://heicode-vault/secrets/swarm-model-key-u1")
|
||||
== ("https://heicode-vault.vault.azure.net", "swarm-model-key-u1", None))
|
||||
check("azkv parse: full host preserved",
|
||||
al._parse_azkv_ref("azkv://heicode-vault.vault.azure.net/secrets/res_git_1")
|
||||
== ("https://heicode-vault.vault.azure.net", "res_git_1", None))
|
||||
check("azkv parse: version captured",
|
||||
al._parse_azkv_ref("azkv://heicode-vault/secrets/swarm-model-key-u1/abc")
|
||||
== ("https://heicode-vault.vault.azure.net", "swarm-model-key-u1", "abc"))
|
||||
check("azkv parse: short form azkv://<vault>/<name>",
|
||||
al._parse_azkv_ref("azkv://heicode-vault/swarm-model-key-u1")
|
||||
== ("https://heicode-vault.vault.azure.net", "swarm-model-key-u1", None))
|
||||
check("azkv parse: bad ref -> None", al._parse_azkv_ref("azkv://heicode-vault") is None)
|
||||
|
||||
# ── gating: hermetic unless workload identity injected / opt-in ──
|
||||
for k in ("AZURE_FEDERATED_TOKEN_FILE", "SECRET_RESOLVER"):
|
||||
os.environ.pop(k, None)
|
||||
check("azkv disabled without workload identity / opt-in", al._azkv_enabled() is False)
|
||||
check("disabled -> _resolve_from_keyvault returns None (no SDK/network touched)",
|
||||
al._resolve_from_keyvault("azkv://heicode-vault/secrets/swarm-model-key-u1") is None)
|
||||
os.environ["SECRET_RESOLVER"] = "azkv"
|
||||
check("azkv enabled via SECRET_RESOLVER=azkv", al._azkv_enabled() is True)
|
||||
os.environ.pop("SECRET_RESOLVER")
|
||||
os.environ["AZURE_FEDERATED_TOKEN_FILE"] = "/var/run/secrets/azure/tokens/azure-identity-token"
|
||||
check("azkv enabled via AZURE_FEDERATED_TOKEN_FILE", al._azkv_enabled() is True)
|
||||
os.environ.pop("AZURE_FEDERATED_TOKEN_FILE")
|
||||
|
||||
|
||||
def test_command_backend_build():
|
||||
os.environ["AGENT_LAUNCH_CMD"] = "launch-agent.sh --id {agent_id} --caps {capabilities}"
|
||||
spec = al.AgentLaunchSpec(agent_id="swarm-abc-agent-1", capabilities="python,general",
|
||||
@@ -143,6 +173,7 @@ def main():
|
||||
test_launch_count()
|
||||
test_plan_specs()
|
||||
test_resolve_model_key()
|
||||
test_azkv_resolver()
|
||||
test_command_backend_build()
|
||||
test_k8s_manifests()
|
||||
asyncio.run(test_backend_none_noop())
|
||||
|
||||
Reference in New Issue
Block a user