test(e2e): remove the secret manager suite (#51241)

The secret manager lanes ran the proxy against a Vault dev container and a
Conjur container that we start ourselves, so nothing in them needed a real
third-party service. They have not run a single test since late October,
because the co-located proxy cannot boot in the lane pod, and they were
soft_fail outside the promotion gate, so they only added noise.

The AWS test in the folder was skipped on every lane, since no aws lane
ever existed.

The four coverage registry rows go with the tests, because the registry is
the e2e denominator and this behavior belongs in the integration tier if it
gets covered again. The Vault and CyberArk clients keep their unit tests.
This commit is contained in:
yuneng-jiang 2026-11-11 14:56:36 -08:00 • committed by GitHub
parent 63d2f4fef7
commit 587902becf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 0 additions and 681 deletions

File diff suppressed because one or more lines are too long

View file

@ -92,31 +92,6 @@ The suites run against a live proxy, so bring one up first by running the litell
Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and start the proxy with `LITELLM_OTEL_V2=true`, the `otel` callback, and `OTEL_EXPORTER_OTLP_ENDPOINT` pointing at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy.
### Secret manager lanes
`key_management_system` is global to the proxy, so the `secret_manager/` tests run once per backend, each against its own proxy. The backends are `hashicorp_vault` and `cyberark` (CyberArk Conjur). `E2E_SECRET_MANAGER` opts in and names the backend (a key of `secret_backends.BACKENDS`). The proxy boots from `gateway/secret_manager_<system>_ci_config.yml`, and the tests reach the same manager through that backend's `SecretStore`. The managers are enterprise features, so the proxy needs a license. `secret_manager/backend.sh` runs any backend in Docker and writes its env, so every lane runs the same way locally:
```bash
bash tests/e2e/secret_manager/backend.sh up cyberark
(set -a; . ~/.cache/litellm-e2e-secret-manager/cyberark/proxy.env; set +a; env -u OPENAI_API_KEY LITELLM_LICENSE=... \
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" DATABASE_URL=... uv run litellm --config tests/e2e/gateway/secret_manager_cyberark_ci_config.yml --port 4000)
(set -a; . ~/.cache/litellm-e2e-secret-manager/cyberark/tests.env; set +a; OPENAI_API_KEY=... \
uv run --group e2e-dev pytest tests/e2e/secret_manager/ -v)
bash tests/e2e/secret_manager/backend.sh down cyberark
```
`E2E_SECRET_MANAGER_PORT` moves the manager off its usual port (8200 for Vault, 8080 for Conjur), and `E2E_SECRET_MANAGER_DIR` moves the env files. Keep that directory private, because both files hold a working admin credential. Keep `OPENAI_API_KEY` out of the proxy's environment. The tests copy the runner's key into the manager under a fresh name per test, so a passing call proves the key came through the manager rather than the `os.environ` fallback `get_secret` takes when the manager errors
A backend declares what it supports in its `SecretBackend.capabilities`, and a test that needs something not every backend does carries `@pytest.mark.requires_capability(...)`, so it is deselected, not failed or skipped, on the lanes that lack it. CyberArk has no `deletes_stored_keys`, because the proxy's delete answers `not_supported` and Conjur keeps the key, so the delete test runs only on the Vault lane
To add a backend, leave the tests and markers alone and add:
1. `secret_manager/secret_store_<system>.py`: a `SecretStore` (`write`, `read` returning None when absent, idempotent `destroy`) over the manager's own API through `e2e_http`'s external helpers, read from `E2E_<SYSTEM>_*` env vars, and a `SecretBackend` whose `system` is the litellm `KeyManagementSystem` value and whose `capabilities` lists what it supports
2. its entry in `secret_backends.BACKENDS`
3. `gateway/secret_manager_<system>_ci_config.yml`, a copy of an existing lane's with only `key_management_system` changed
4. an `up_<system>` function in `secret_manager/backend.sh` that starts the manager and writes `proxy.env` and `tests.env`
5. a CI step that runs `backend.sh up <system>` (or the same containers as sidecars), boots the proxy with `proxy.env` and a license, and runs pytest with `tests.env`
### Record and replay
Record/replay scopes to the proxy's provider-bound traffic only. In `E2E_FIXTURE_MODE=record` the harness boots a local provider-edge server, edge-wired tests register their deployments with an `api_base` pointing at it, and every provider call the proxy makes is forwarded verbatim and written to a fixture bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`). `E2E_FIXTURE_MODE=replay` runs the same tests against the same live proxy and database, but the edge answers the proxy's provider calls from the bundle instead of the provider, so the run makes zero provider calls and spends nothing while key auth, routing, cost calculation, and spend-log writes all still execute for real. Unset (or `live`) behaves exactly as before the knob existed. Both record and replay need the proxy up; only the provider is taken out of the loop

View file

@ -38,7 +38,6 @@ from e2e_config import (
PROXY_WIDE_CONFIG_OPT_IN_ENV,
SAGEMAKER_NOVA2_LITE_OPT_IN_ENV,
SAGEMAKER_NOVA_OPT_IN_ENV,
SECRET_MANAGER_OPT_IN_ENV,
WEEKLY_ANOMALY_OPT_IN_ENV,
unique_marker,
)
@ -74,7 +73,6 @@ OPT_IN_MARKERS: Final = MappingProxyType(
"owned_gateway": OWNED_GATEWAY_OPT_IN_ENV,
"otel_v2": OTEL_V2_OPT_IN_ENV,
"otel_tls": OTEL_TLS_OPT_IN_ENV,
"secret_manager": SECRET_MANAGER_OPT_IN_ENV,
"sagemaker_nova": SAGEMAKER_NOVA_OPT_IN_ENV,
"sagemaker_nova2_lite": SAGEMAKER_NOVA2_LITE_OPT_IN_ENV,
"proxy_wide_config": PROXY_WIDE_CONFIG_OPT_IN_ENV,
@ -181,11 +179,6 @@ def pytest_configure(config: pytest.Config) -> None:
"markers",
"otel_tls: needs a stack whose gateway exports OTLP over TLS signed by the CA in SSL_CERT_FILE; deselected unless E2E_OTEL_EXPORTER_ENDPOINT is set",
)
config.addinivalue_line(
"markers",
"secret_manager: needs a proxy booted from gateway/secret_manager_<system>_ci_config.yml against that live "
"secret manager; deselected unless E2E_SECRET_MANAGER names the backend (see secret_manager/secret_backends.py)",
)
config.addinivalue_line(
"markers",
"sagemaker_nova: needs a deployed SageMaker Nova endpoint the proxy's AWS credentials can invoke; "

View file

@ -45,10 +45,6 @@
- {id: other.config.runtime_update.applies_at_runtime, module: other, tier: P0, area: config, assertions: [applies_at_runtime], source: "proxy_server.py:14014-14060", rationale: "/config/update persists to DB + invalidates cache"}
- {id: other.config.passthrough.headers_forwarded, module: other, tier: P0, area: config, assertions: [headers_forwarded], source: "passthrough/utils.py forward_headers_from_request", rationale: "Custom pass-through static headers and x-pass-* client headers reach the upstream"}
- {id: other.config.general_settings.alert_webhook_side_effect, module: other, tier: P1, area: config, assertions: [alert_webhook_side_effect], source: "proxy_server.py:14215", rationale: "alert_to_webhook_url auto-enables slack alerting"}
- {id: other.config.secret_resolution.kms_integration, module: other, tier: P1, area: config, assertions: [kms_integration], source: "secret_managers/main.py get_secret / secret_manager/test_secret_manager_e2e.py", rationale: "A deployment whose api_key is os.environ/<name> gets its key from the configured secret manager when that name exists only in the manager"}
- {id: other.config.secret_resolution.manager_value_used, module: other, tier: P1, area: config, assertions: [manager_value_used], source: "secret_managers/main.py get_secret / secret_manager/test_secret_manager_e2e.py", rationale: "The value the manager holds is what reaches the provider: a bogus key in the manager is rejected by the provider with 401, so a passing resolution test cannot be an env fallback"}
- {id: other.config.secret_manager.virtual_key_stored, module: other, tier: P1, area: config, assertions: [virtual_key_stored], source: "key_management_event_hooks.py _store_virtual_key_in_secret_manager", rationale: "With store_virtual_keys, /key/generate writes the new key under prefix_for_stored_virtual_keys + key_alias in the manager"}
- {id: other.config.secret_manager.virtual_key_deleted, module: other, tier: P1, area: config, assertions: [virtual_key_deleted], source: "key_management_event_hooks.py _delete_virtual_keys_from_secret_manager", rationale: "/key/delete removes the stored key from the manager, so a revoked key does not linger there"}
- {id: other.config.overrides.audit_logged, module: other, tier: P1, area: config, assertions: [audit_logged], source: "config_override_endpoints.py:67-100", rationale: "Config override mutations audit-logged, values redacted"}
- {id: other.key_mgmt.regenerate.grace_period_honored, module: other, tier: P1, area: auth, assertions: [grace_period_honored], source: "key_management_endpoints.py:4503-4560", rationale: "Old key valid during grace_period then revoked"}
- {id: other.key_mgmt.spend_reset.resets_to_value, module: other, tier: P1, area: auth, assertions: [resets_to_value], source: "key_management_endpoints.py:4841", rationale: "reset_spend resets accumulated spend"}

View file

@ -207,7 +207,6 @@ PROVIDER_EDGE_HOST_OPT_IN_ENV: Final = "E2E_PROVIDER_EDGE_HOST_REACHABLE"
OWNED_GATEWAY_OPT_IN_ENV: Final = "E2E_OWNED_GATEWAY"
OTEL_V2_OPT_IN_ENV: Final = "E2E_OTEL_V2"
OTEL_TLS_OPT_IN_ENV: Final = "E2E_OTEL_EXPORTER_ENDPOINT"
SECRET_MANAGER_OPT_IN_ENV: Final = "E2E_SECRET_MANAGER"
SAGEMAKER_NOVA_OPT_IN_ENV: Final = "SAGEMAKER_NOVA_ENDPOINT"
SAGEMAKER_NOVA2_LITE_OPT_IN_ENV: Final = "SAGEMAKER_NOVA2_LITE_ENDPOINT"
PROXY_WIDE_CONFIG_OPT_IN_ENV: Final = "E2E_PROXY_WIDE_CONFIG_STACK"

View file

@ -1,8 +0,0 @@
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
store_model_in_db: true
key_management_system: cyberark
key_management_settings:
access_mode: read_and_write
store_virtual_keys: true
prefix_for_stored_virtual_keys: litellm-e2e/virtual-keys/

View file

@ -1,8 +0,0 @@
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
store_model_in_db: true
key_management_system: hashicorp_vault
key_management_settings:
access_mode: read_and_write
store_virtual_keys: true
prefix_for_stored_virtual_keys: litellm-e2e/virtual-keys/

View file

@ -17,7 +17,6 @@ markers =
owned_gateway: boots its own proxy from source against the stack's Postgres, so it needs DATABASE_URL on the pytest host; deselected unless E2E_OWNED_GATEWAY is set
otel_v2: needs a proxy running with LITELLM_OTEL_V2=true; deselected unless E2E_OTEL_V2 is set
otel_tls: needs a stack whose gateway exports OTLP over TLS signed by the CA in SSL_CERT_FILE; deselected unless E2E_OTEL_EXPORTER_ENDPOINT is set
secret_manager: needs a proxy booted from gateway/secret_manager_<system>_ci_config.yml against that live secret manager; deselected unless E2E_SECRET_MANAGER names the backend (see secret_manager/secret_backends.py)
sagemaker_nova: needs a deployed SageMaker Nova endpoint the proxy's AWS credentials can invoke; deselected unless SAGEMAKER_NOVA_ENDPOINT names it
sagemaker_nova2_lite: needs a deployed SageMaker Nova 2 Lite endpoint the proxy's AWS credentials can invoke; deselected unless SAGEMAKER_NOVA2_LITE_ENDPOINT names it
proxy_wide_config: turns on a proxy-wide setting through /config/update, so it runs on a stack no other suite is driving; deselected unless E2E_PROXY_WIDE_CONFIG_STACK is set

View file

@ -1,76 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
usage() {
local systems
systems=$(declare -F | sed -n 's/^declare -f up_//p' | paste -sd '|' -)
echo "usage: $0 up|down $systems" >&2
exit 2
}
action=${1:-}
system=${2:-}
dir=${E2E_SECRET_MANAGER_DIR:-$HOME/.cache/litellm-e2e-secret-manager}/$system
name=litellm-e2e-$system
wait_for() {
local url=$1
for _ in $(seq 1 90); do
if curl -sf -o /dev/null "$url"; then
return 0
fi
sleep 2
done
echo "$system did not answer at $url" >&2
return 1
}
down() {
docker rm -f "$name" "$name-db" >/dev/null 2>&1 || true
docker network rm "$name" >/dev/null 2>&1 || true
rm -rf "$dir"
}
up_hashicorp_vault() {
local port=${E2E_SECRET_MANAGER_PORT:-8200}
local token
token=e2e-$(openssl rand -hex 16)
docker run -d --name "$name" -p "127.0.0.1:$port:8200" --cap-add IPC_LOCK \
-e VAULT_DEV_ROOT_TOKEN_ID="$token" hashicorp/vault:1.20 >/dev/null
wait_for "http://127.0.0.1:$port/v1/sys/health"
printf 'HCP_VAULT_ADDR=http://127.0.0.1:%s\nHCP_VAULT_TOKEN=%s\n' "$port" "$token" >"$dir/proxy.env"
printf 'E2E_VAULT_ADDR=http://127.0.0.1:%s\nE2E_VAULT_TOKEN=%s\n' "$port" "$token" >"$dir/tests.env"
}
up_cyberark() {
local port=${E2E_SECRET_MANAGER_PORT:-8080}
local data_key api_key
docker network create "$name" >/dev/null
docker run -d --name "$name-db" --network "$name" -e POSTGRES_HOST_AUTH_METHOD=trust postgres:15 >/dev/null
data_key=$(docker run --rm cyberark/conjur:1.24 data-key generate)
docker run -d --name "$name" --network "$name" -p "127.0.0.1:$port:80" \
-e DATABASE_URL="postgres://postgres@$name-db/postgres" -e CONJUR_DATA_KEY="$data_key" \
-e CONJUR_AUTHENTICATORS=authn cyberark/conjur:1.24 server >/dev/null
wait_for "http://127.0.0.1:$port/"
docker exec "$name" conjurctl account create --name default >/dev/null
api_key=$(docker exec "$name" conjurctl role retrieve-key default:user:admin | tr -d '\r\n')
printf 'CYBERARK_API_BASE=http://127.0.0.1:%s\nCYBERARK_ACCOUNT=default\nCYBERARK_USERNAME=admin\nCYBERARK_API_KEY=%s\n' \
"$port" "$api_key" >"$dir/proxy.env"
printf 'E2E_CYBERARK_API_BASE=http://127.0.0.1:%s\nE2E_CYBERARK_ACCOUNT=default\nE2E_CYBERARK_USERNAME=admin\nE2E_CYBERARK_API_KEY=%s\n' \
"$port" "$api_key" >"$dir/tests.env"
}
[[ $# -eq 2 && -n $system ]] && declare -F "up_$system" >/dev/null || usage
case $action in
up)
down
mkdir -p "$dir"
"up_$system"
echo "E2E_SECRET_MANAGER=$system" >>"$dir/tests.env"
echo "$system is up; env in $dir/proxy.env (proxy) and $dir/tests.env (pytest)"
;;
down) down ;;
*) usage ;;
esac

View file

@ -1,57 +0,0 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Final
import pytest
from e2e_config import SECRET_MANAGER_OPT_IN_ENV
from proxy_client import ProxyClient
from secret_backends import BACKENDS, selected_backend
from secret_store import SecretBackend, SecretStore
REQUIRES_CAPABILITY: Final = "requires_capability"
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
f"{REQUIRES_CAPABILITY}(capability): secret_manager test deselected when the backend "
f"{SECRET_MANAGER_OPT_IN_ENV} names lacks the capability (secret_store.Capability)",
)
def _lacks_capability(item: pytest.Item, backend: SecretBackend) -> bool:
marker: Final = item.get_closest_marker(REQUIRES_CAPABILITY)
return marker is not None and marker.args[0] not in backend.capabilities
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
backend: Final = BACKENDS.get(os.environ.get(SECRET_MANAGER_OPT_IN_ENV, "").strip())
if backend is None:
return
deselected: Final = [item for item in items if _lacks_capability(item, backend)]
if deselected:
config.hook.pytest_deselected(items=deselected)
items[:] = [item for item in items if not _lacks_capability(item, backend)]
@dataclass(frozen=True, slots=True)
class SecretManagerClient:
proxy: ProxyClient
@pytest.fixture(scope="session")
def client(proxy: ProxyClient) -> SecretManagerClient:
return SecretManagerClient(proxy)
@pytest.fixture(scope="session")
def backend() -> SecretBackend:
return selected_backend()
@pytest.fixture(scope="session")
def store(backend: SecretBackend) -> SecretStore:
return backend.from_env()

View file

@ -1,25 +0,0 @@
from __future__ import annotations
import os
from types import MappingProxyType
from typing import Final
import pytest
from e2e_config import SECRET_MANAGER_OPT_IN_ENV
from secret_store import SecretBackend
from secret_store_cyberark import CYBERARK
from secret_store_hashicorp_vault import HASHICORP_VAULT
BACKENDS: Final = MappingProxyType({backend.system: backend for backend in (HASHICORP_VAULT, CYBERARK)})
def selected_backend() -> SecretBackend:
system: Final = os.environ.get(SECRET_MANAGER_OPT_IN_ENV, "").strip()
backend: Final = BACKENDS.get(system)
if backend is None:
pytest.fail(
f"{SECRET_MANAGER_OPT_IN_ENV}={system!r} names no secret manager backend; "
f"set it to one of {sorted(BACKENDS)}"
)
return backend

View file

@ -1,29 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final, Literal, Protocol
SECRET_MANAGER_CONFIG_DIR: Final = "gateway"
class SecretStore(Protocol):
def write(self, name: str, value: str) -> None: ...
def read(self, name: str) -> str | None: ...
def destroy(self, name: str) -> None: ...
Capability = Literal["deletes_stored_keys"]
@dataclass(frozen=True, slots=True)
class SecretBackend:
system: str
from_env: Callable[[], SecretStore]
capabilities: frozenset[Capability]
@property
def proxy_config(self) -> str:
return f"{SECRET_MANAGER_CONFIG_DIR}/secret_manager_{self.system}_ci_config.yml"

View file

@ -1,133 +0,0 @@
from __future__ import annotations
import base64
import os
import time
from dataclasses import dataclass, field
from typing import Final, Literal
from urllib.parse import quote
import pytest
import yaml
from e2e_http import ExternalWrite, Headers, send_text_external
from e2e_metadata import step
from pydantic import Field
from secret_store import SecretBackend
CYBERARK_API_BASE_ENV: Final = "E2E_CYBERARK_API_BASE"
CYBERARK_ACCOUNT_ENV: Final = "E2E_CYBERARK_ACCOUNT"
CYBERARK_USERNAME_ENV: Final = "E2E_CYBERARK_USERNAME"
CYBERARK_API_KEY_ENV: Final = "E2E_CYBERARK_API_KEY"
# The same defaults CyberArkSecretManager falls back to for CYBERARK_*.
DEFAULT_API_BASE: Final = "http://127.0.0.1:8080"
DEFAULT_ACCOUNT: Final = "default"
DEFAULT_USERNAME: Final = "admin"
SYSTEM: Final = "cyberark"
_POLICY_LOAD_ATTEMPTS: Final = 5
_POLICY_LOAD_RETRY_DELAY_SECONDS: Final = 0.2
_START_HINT: Final = (
f"Start one with `bash tests/e2e/secret_manager/backend.sh up {SYSTEM}`, which writes the env for "
f"the proxy (booted from gateway/secret_manager_{SYSTEM}_ci_config.yml) and for the tests"
)
class ConjurHeaders(Headers):
authorization: str = Field(repr=False)
content_type: str | None = Field(default=None, serialization_alias="Content-Type")
def _policy_scalar(name: str) -> str:
# Quoted the way CyberArkSecretManager._ensure_variable_exists quotes it.
return yaml.safe_dump(name, default_style='"').strip()
@dataclass(frozen=True, slots=True)
class Conjur:
base_url: str
account: str
username: str
api_key: str = field(repr=False)
def _fail_unless_reached(self, result: ExternalWrite, action: str) -> None:
if result.status_code == -1:
pytest.fail(f"No live Conjur at {self.base_url}: {result.body}. {_START_HINT}")
if result.status_code == 401:
pytest.fail(f"Conjur rejected {self.username}'s credentials while trying to {action}. {_START_HINT}")
def _headers(self, content_type: str | None = None) -> ConjurHeaders:
# Tokens last about eight minutes, so each call authenticates afresh rather than
# letting a long session outlive a cached one.
auth: Final = send_text_external(
"POST",
f"{self.base_url}/authn/{self.account}/{quote(self.username, safe='')}/authenticate",
headers=Headers(),
content=self.api_key,
)
self._fail_unless_reached(auth, "authenticate")
if not auth.ok:
pytest.fail(f"Conjur refused to authenticate {self.username}: HTTP {auth.status_code} {auth.body[:300]}")
token: Final = base64.b64encode(auth.body.encode()).decode()
return ConjurHeaders(authorization=f'Token token="{token}"', content_type=content_type)
def _secret_url(self, name: str) -> str:
return f"{self.base_url}/secrets/{self.account}/variable/{quote(name, safe='')}"
def _load_root_policy(self, method: Literal["POST", "PATCH"], policy: str, attempt: int = 0) -> ExternalWrite:
result: Final = send_text_external(
method,
f"{self.base_url}/policies/{self.account}/policy/root",
headers=self._headers(content_type="application/x-yaml"),
content=policy,
)
if result.status_code != 409 or attempt + 1 == _POLICY_LOAD_ATTEMPTS:
return result
time.sleep(_POLICY_LOAD_RETRY_DELAY_SECONDS * (1 << attempt))
return self._load_root_policy(method, policy, attempt + 1)
def _update_root_policy(self, method: Literal["POST", "PATCH"], policy: str, action: str) -> None:
result: Final = self._load_root_policy(method, policy)
self._fail_unless_reached(result, action)
if not result.ok:
pytest.fail(f"Conjur refused to {action}: HTTP {result.status_code} {result.body[:300]}")
@step("Write the secret {name} to CyberArk Conjur")
def write(self, name: str, value: str) -> None:
self._update_root_policy("POST", f"- !variable {_policy_scalar(name)}\n", f"declare {name}")
result: Final = send_text_external("POST", self._secret_url(name), headers=self._headers(), content=value)
self._fail_unless_reached(result, f"write {name}")
if not result.ok:
pytest.fail(f"Conjur refused to write {name}: HTTP {result.status_code} {result.body[:300]}")
@step("Read the secret {name} from CyberArk Conjur")
def read(self, name: str) -> str | None:
result: Final = send_text_external("GET", self._secret_url(name), headers=self._headers())
self._fail_unless_reached(result, f"read {name}")
if result.status_code == 404:
return None
if not result.ok:
pytest.fail(f"Conjur refused to read {name}: HTTP {result.status_code} {result.body[:300]}")
return result.body
@step("Delete the secret {name} from CyberArk Conjur")
def destroy(self, name: str) -> None:
self._update_root_policy("PATCH", f"- !delete\n record: !variable {_policy_scalar(name)}\n", f"destroy {name}")
def conjur_from_env() -> Conjur:
api_key: Final = os.environ.get(CYBERARK_API_KEY_ENV, "").strip()
if not api_key:
pytest.fail(f"The {SYSTEM} lane needs {CYBERARK_API_KEY_ENV} to reach its Conjur. {_START_HINT}")
return Conjur(
base_url=os.environ.get(CYBERARK_API_BASE_ENV, "").strip().rstrip("/") or DEFAULT_API_BASE,
account=os.environ.get(CYBERARK_ACCOUNT_ENV, "").strip() or DEFAULT_ACCOUNT,
username=os.environ.get(CYBERARK_USERNAME_ENV, "").strip() or DEFAULT_USERNAME,
api_key=api_key,
)
CYBERARK: Final = SecretBackend(system=SYSTEM, from_env=conjur_from_env, capabilities=frozenset())

View file

@ -1,117 +0,0 @@
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Final
import pytest
from e2e_http import (
Headers,
NetworkError,
Success,
UnknownApiError,
delete_external,
get_external,
post_json_external,
)
from e2e_metadata import step
from pydantic import BaseModel, Field
from secret_store import SecretBackend
VAULT_ADDR_ENV: Final = "E2E_VAULT_ADDR"
VAULT_TOKEN_ENV: Final = "E2E_VAULT_TOKEN"
VAULT_MOUNT_ENV: Final = "E2E_VAULT_MOUNT_NAME"
DEFAULT_VAULT_ADDR: Final = "http://127.0.0.1:8200"
DEFAULT_MOUNT: Final = "secret"
SYSTEM: Final = "hashicorp_vault"
_START_HINT: Final = (
f"Start one with `bash tests/e2e/secret_manager/backend.sh up {SYSTEM}`, which writes the env for "
f"the proxy (booted from gateway/secret_manager_{SYSTEM}_ci_config.yml) and for the tests"
)
class VaultHeaders(Headers):
x_vault_token: str = Field(serialization_alias="X-Vault-Token", repr=False)
class KvData(BaseModel):
key: str = Field(repr=False)
class KvWriteBody(BaseModel):
data: KvData
class KvReadData(BaseModel):
data: KvData
class KvReadResponse(BaseModel):
data: KvReadData
@dataclass(frozen=True, slots=True)
class Vault:
base_url: str
token: str = field(repr=False)
mount: str = DEFAULT_MOUNT
def _headers(self) -> VaultHeaders:
return VaultHeaders(x_vault_token=self.token)
def _data_url(self, name: str) -> str:
return f"{self.base_url}/v1/{self.mount}/data/{name}"
def _metadata_url(self, name: str) -> str:
return f"{self.base_url}/v1/{self.mount}/metadata/{name}"
@step("Write the secret {name} to HashiCorp Vault")
def write(self, name: str, value: str) -> None:
write: Final = post_json_external(
self._data_url(name), headers=self._headers(), json=KvWriteBody(data=KvData(key=value))
)
if write.status_code == -1:
pytest.fail(f"No live Vault at {self.base_url}: {write.body}. {_START_HINT}")
if not write.ok:
pytest.fail(f"Vault refused to write {name}: HTTP {write.status_code} {write.body[:300]}")
@step("Read the secret {name} from HashiCorp Vault")
def read(self, name: str) -> str | None:
result: Final = get_external(self._data_url(name), headers=self._headers(), response_type=KvReadResponse)
match result:
case Success(data=body):
return body.data.data.key
case UnknownApiError(status_code=404):
return None
case NetworkError(message=message):
return pytest.fail(f"No live Vault at {self.base_url}: {message}. {_START_HINT}")
case _:
return pytest.fail(f"Vault refused to read {name}: {result}")
@step("Delete the secret {name} from HashiCorp Vault")
def destroy(self, name: str) -> None:
write: Final = delete_external(self._metadata_url(name), headers=self._headers())
if not write.ok and write.status_code != 404:
pytest.fail(f"Vault refused to destroy {name}: HTTP {write.status_code} {write.body[:300]}")
def vault_from_env() -> Vault:
token: Final = os.environ.get(VAULT_TOKEN_ENV, "").strip()
if not token:
pytest.fail(f"The hashicorp_vault lane needs {VAULT_TOKEN_ENV} to reach its Vault. {_START_HINT}")
return Vault(
base_url=os.environ.get(VAULT_ADDR_ENV, DEFAULT_VAULT_ADDR).rstrip("/"),
token=token,
mount=os.environ.get(VAULT_MOUNT_ENV, "").strip() or DEFAULT_MOUNT,
)
HASHICORP_VAULT: Final = SecretBackend(
system=SYSTEM,
from_env=vault_from_env,
capabilities=frozenset({"deletes_stored_keys"}),
)

View file

@ -1,34 +0,0 @@
import hashlib
import os
from typing import Final
import pytest
from e2e_config import SECRET_MANAGER_OPT_IN_ENV
from e2e_metadata import Domain, Subject, meta
from pydantic import TypeAdapter
from litellm.secret_managers.aws_secret_manager_v2 import AWSSecretsManagerV2
from litellm.secret_managers.main import get_secret
pytestmark = [
pytest.mark.e2e,
pytest.mark.secret_manager,
pytest.mark.skipif(
os.environ.get(SECRET_MANAGER_OPT_IN_ENV, "").strip() != "aws",
reason=f"runs only on the AWS Secrets Manager lane ({SECRET_MANAGER_OPT_IN_ENV}=aws)",
),
]
_AWS_FIXTURE_MASTER_KEY_SHA256: Final = "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b"
_STRING_MAP: Final = TypeAdapter(dict[str, str])
_SECRET_STRING: Final = TypeAdapter(str)
class TestAwsSecretManager:
@meta(Subject(domain=Domain.DEPLOY_OPS))
def test_aws_secret_manager(self) -> None:
AWSSecretsManagerV2.load_aws_secret_manager(use_aws_secret_manager=True)
secret_value: Final = _STRING_MAP.validate_json(
_SECRET_STRING.validate_python(get_secret("litellm_master_key"))
)
assert hashlib.sha256(secret_value["litellm_master_key"].encode()).hexdigest() == _AWS_FIXTURE_MASTER_KEY_SHA256

View file

@ -1,155 +0,0 @@
from __future__ import annotations
import os
import time
from collections.abc import Callable
from typing import Final
import pytest
from e2e_config import unique_marker
from e2e_http import Result, Success, UnauthorizedError, unwrap
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, ChatResponse, KeyGenerateBody, LiteLLMParamsBody
from proxy_client import ProxyClient
from secret_store import SecretStore
from e2e_metadata import Domain, Mode, Provider, Subject, meta
pytestmark = [pytest.mark.e2e, pytest.mark.secret_manager]
BACKEND_MODEL: Final = "openai/gpt-4o-mini"
VIRTUAL_KEY_PREFIX: Final = "litellm-e2e/virtual-keys/"
PROVIDER_KEY_ENV: Final = "OPENAI_API_KEY"
# The proxy's env never holds OPENAI_API_KEY and each test seeds it under a fresh name, so a passing
# call proves the key came from the manager and not get_secret's os.environ fallback.
def _provider_key() -> str:
key: Final = os.environ.get(PROVIDER_KEY_ENV, "").strip()
if not key:
pytest.fail(f"The secret manager suite seeds the manager with the runner's {PROVIDER_KEY_ENV}, which is unset")
return key
def _seed(store: SecretStore, resources: ResourceManager, value: str) -> str:
name: Final = f"litellm-e2e-openai-{unique_marker()}"
store.write(name, value)
resources.defer(lambda: store.destroy(name))
return name
def _deploy(proxy: ProxyClient, resources: ResourceManager, secret_name: str) -> str:
model_name: Final = f"secret-manager-backed-{unique_marker()}"
model_id: Final = proxy.create_model(
model_name,
LiteLLMParamsBody(model=BACKEND_MODEL, api_key=f"os.environ/{secret_name}"),
provider_live=True,
)
resources.defer(lambda: proxy.delete_model(model_id))
return model_name
def _chat(proxy: ProxyClient, key: str, model: str) -> Result[ChatResponse]:
return proxy.chat(
key,
ChatBody(
model=model,
messages=[ChatMessage(role="user", content=f"reply with one word {unique_marker()}")],
max_tokens=16,
),
)
def _eventually(proxy: ProxyClient, read: Callable[[], str | None], expected: str | None, context: str) -> None:
deadline: Final = time.monotonic() + proxy.poll_timeout
last: str | None = read()
while last != expected and time.monotonic() < deadline:
time.sleep(proxy.poll_interval)
last = read()
if last != expected:
pytest.fail(
f"{context}: the secret manager still holds {'a value' if last is not None else 'nothing'} after the deadline"
)
class TestSecretManager:
@pytest.mark.covers("other.config.secret_resolution.kms_integration")
@meta(
Subject(
domain=Domain.DEPLOY_OPS,
providers=(Provider.OPENAI,),
models=(BACKEND_MODEL,),
mode=Mode.NONSTREAM,
)
)
def test_deployment_key_resolves_from_the_manager(
self, proxy: ProxyClient, resources: ResourceManager, store: SecretStore, scoped_key: str
) -> None:
model: Final = _deploy(proxy, resources, _seed(store, resources, _provider_key()))
response: Final = unwrap(_chat(proxy, scoped_key, model))
assert response.choices, f"the manager-backed deployment answered with no choices: {response}"
@pytest.mark.covers("other.config.secret_resolution.manager_value_used")
@meta(
Subject(
domain=Domain.DEPLOY_OPS,
providers=(Provider.OPENAI,),
models=(BACKEND_MODEL,),
mode=Mode.NONSTREAM,
)
)
def test_deployment_uses_the_value_the_manager_holds(
self, proxy: ProxyClient, resources: ResourceManager, store: SecretStore, scoped_key: str
) -> None:
bogus: Final = f"sk-litellm-e2e-not-a-key-{unique_marker()}"
model: Final = _deploy(proxy, resources, _seed(store, resources, bogus))
result: Final = _chat(proxy, scoped_key, model)
match result:
case UnauthorizedError(body=body):
assert "AuthenticationError" in body, f"the 401 did not come from the provider: {body[:300]}"
case Success():
pytest.fail("a deployment whose managed secret is not a real key still reached the provider")
case _:
pytest.fail(f"expected the provider to reject the manager-held key with 401, got {result}")
@pytest.mark.covers("other.config.secret_manager.virtual_key_stored")
@meta(
Subject(
domain=Domain.DEPLOY_OPS,
)
)
def test_generated_key_is_written_to_the_manager(
self, proxy: ProxyClient, resources: ResourceManager, store: SecretStore
) -> None:
alias: Final = f"litellm-e2e-vk-{unique_marker()}"
secret_name: Final = f"{VIRTUAL_KEY_PREFIX}{alias}"
resources.defer(lambda: store.destroy(secret_name))
key: Final = proxy.generate_key(KeyGenerateBody(key_alias=alias))
resources.defer(lambda: proxy.delete_key(key))
_eventually(proxy, lambda: store.read(secret_name), key, f"the generated key {alias}")
@pytest.mark.requires_capability("deletes_stored_keys")
@pytest.mark.covers("other.config.secret_manager.virtual_key_deleted")
@meta(
Subject(
domain=Domain.DEPLOY_OPS,
)
)
def test_deleted_key_is_removed_from_the_manager(
self, proxy: ProxyClient, resources: ResourceManager, store: SecretStore
) -> None:
alias: Final = f"litellm-e2e-vk-{unique_marker()}"
secret_name: Final = f"{VIRTUAL_KEY_PREFIX}{alias}"
resources.defer(lambda: store.destroy(secret_name))
key: Final = proxy.generate_key(KeyGenerateBody(key_alias=alias))
resources.defer(lambda: proxy.delete_key(key))
_eventually(proxy, lambda: store.read(secret_name), key, f"the generated key {alias}")
proxy.delete_key(key)
_eventually(proxy, lambda: store.read(secret_name), None, f"the deleted key {alias}")