8ca6c39c66
Populate the repo with the Terraform/OpenTofu provider that manages the LiteLLM dynamic secrets engine on Vault/OpenBao via the Vault API. - Provider (VAULT_ADDR/VAULT_TOKEN) with resources litellmvaultsecret_secret_backend (mount + config) and litellmvaultsecret_secret_backend_role (models, max_budget, ttl/max_ttl in seconds, metadata) - Unit tests against a mock Vault API - End-to-end test: builds the sibling plugin, boots Vault + LiteLLM + Postgres, and runs a real terraform apply/destroy asserting key generation works - Makefile, woodpecker CI (build/test/pre-commit), examples, README
239 lines
6.0 KiB
Go
239 lines
6.0 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// mockVault is a minimal in-memory fake of the Vault/OpenBao HTTP API covering
|
|
// the endpoints this provider uses.
|
|
type mockVault struct {
|
|
server *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
mounts map[string]map[string]interface{} // "litellm/" -> mount output
|
|
configs map[string]map[string]interface{} // backend -> config data
|
|
roles map[string]map[string]interface{} // "backend/roles/name" -> role data
|
|
}
|
|
|
|
func newMockVault(t *testing.T) (*mockVault, *vaultClient) {
|
|
t.Helper()
|
|
m := &mockVault{
|
|
mounts: map[string]map[string]interface{}{},
|
|
configs: map[string]map[string]interface{}{},
|
|
roles: map[string]map[string]interface{}{},
|
|
}
|
|
m.server = httptest.NewServer(http.HandlerFunc(m.handle))
|
|
t.Cleanup(m.server.Close)
|
|
|
|
client, err := newVaultClient(m.server.URL, "test-token")
|
|
if err != nil {
|
|
t.Fatalf("newVaultClient: %v", err)
|
|
}
|
|
return m, client
|
|
}
|
|
|
|
func (m *mockVault) handle(w http.ResponseWriter, r *http.Request) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/")
|
|
|
|
switch {
|
|
case path == "sys/mounts" && r.Method == http.MethodGet:
|
|
writeSecret(w, map[string]interface{}{"data": toIface(m.mounts)})
|
|
|
|
case strings.HasPrefix(path, "sys/mounts/"):
|
|
mp := strings.TrimPrefix(path, "sys/mounts/")
|
|
key := strings.TrimRight(mp, "/") + "/"
|
|
switch r.Method {
|
|
case http.MethodPost, http.MethodPut:
|
|
var body map[string]interface{}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
// Store the mount *output* shape (Vault returns int TTLs, not the
|
|
// string TTLs it accepts on input).
|
|
m.mounts[key] = map[string]interface{}{
|
|
"type": body["type"],
|
|
"description": body["description"],
|
|
"config": map[string]interface{}{},
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case http.MethodDelete:
|
|
delete(m.mounts, key)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
case strings.HasSuffix(path, "/config"):
|
|
backend := strings.TrimSuffix(path, "/config")
|
|
switch r.Method {
|
|
case http.MethodPost, http.MethodPut:
|
|
var body map[string]interface{}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
m.configs[backend] = body
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case http.MethodGet:
|
|
cfg, ok := m.configs[backend]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
// The real backend never returns master_key.
|
|
out := map[string]interface{}{}
|
|
for k, v := range cfg {
|
|
if k == "master_key" {
|
|
continue
|
|
}
|
|
out[k] = v
|
|
}
|
|
writeSecret(w, map[string]interface{}{"data": out})
|
|
}
|
|
|
|
case strings.Contains(path, "/roles/"):
|
|
switch r.Method {
|
|
case http.MethodPost, http.MethodPut:
|
|
var body map[string]interface{}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
m.roles[path] = body
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case http.MethodGet:
|
|
role, ok := m.roles[path]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
writeSecret(w, map[string]interface{}{"data": role})
|
|
case http.MethodDelete:
|
|
delete(m.roles, path)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func toIface(m map[string]map[string]interface{}) map[string]interface{} {
|
|
out := map[string]interface{}{}
|
|
for k, v := range m {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func writeSecret(w http.ResponseWriter, body map[string]interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func TestClient_MountLifecycle(t *testing.T) {
|
|
_, c := newMockVault(t)
|
|
ctx := context.Background()
|
|
|
|
if err := c.enableMount(ctx, "litellm", "vault-plugin-secrets-litellm", "desc", mountConfig{}); err != nil {
|
|
t.Fatalf("enableMount: %v", err)
|
|
}
|
|
info, err := c.mountInfo(ctx, "litellm")
|
|
if err != nil {
|
|
t.Fatalf("mountInfo: %v", err)
|
|
}
|
|
if info == nil {
|
|
t.Fatal("expected mount to exist")
|
|
}
|
|
if info.Type != "vault-plugin-secrets-litellm" {
|
|
t.Fatalf("unexpected mount type: %q", info.Type)
|
|
}
|
|
|
|
if err := c.disableMount(ctx, "litellm"); err != nil {
|
|
t.Fatalf("disableMount: %v", err)
|
|
}
|
|
info, err = c.mountInfo(ctx, "litellm")
|
|
if err != nil {
|
|
t.Fatalf("mountInfo after delete: %v", err)
|
|
}
|
|
if info != nil {
|
|
t.Fatal("expected mount to be gone")
|
|
}
|
|
}
|
|
|
|
func TestClient_ConfigRoundTrip(t *testing.T) {
|
|
m, c := newMockVault(t)
|
|
ctx := context.Background()
|
|
|
|
err := c.writeConfig(ctx, "litellm", map[string]interface{}{
|
|
"base_url": "http://litellm:4000",
|
|
"master_key": "sk-secret",
|
|
"request_timeout_seconds": 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("writeConfig: %v", err)
|
|
}
|
|
|
|
cfg, err := c.readConfig(ctx, "litellm")
|
|
if err != nil {
|
|
t.Fatalf("readConfig: %v", err)
|
|
}
|
|
if cfg["base_url"] != "http://litellm:4000" {
|
|
t.Fatalf("unexpected base_url: %v", cfg["base_url"])
|
|
}
|
|
if _, leaked := cfg["master_key"]; leaked {
|
|
t.Fatal("master_key must not be returned by read")
|
|
}
|
|
_ = m
|
|
}
|
|
|
|
func TestClient_ReadConfigMissing(t *testing.T) {
|
|
_, c := newMockVault(t)
|
|
cfg, err := c.readConfig(context.Background(), "litellm")
|
|
if err != nil {
|
|
t.Fatalf("readConfig: %v", err)
|
|
}
|
|
if cfg != nil {
|
|
t.Fatalf("expected nil config, got %v", cfg)
|
|
}
|
|
}
|
|
|
|
func TestClient_RoleLifecycle(t *testing.T) {
|
|
_, c := newMockVault(t)
|
|
ctx := context.Background()
|
|
|
|
err := c.writeRole(ctx, "litellm", "team-a", map[string]interface{}{
|
|
"models": []string{"gpt-4"},
|
|
"max_budget": 25.0,
|
|
"ttl": 3600,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("writeRole: %v", err)
|
|
}
|
|
|
|
role, err := c.readRole(ctx, "litellm", "team-a")
|
|
if err != nil {
|
|
t.Fatalf("readRole: %v", err)
|
|
}
|
|
if role == nil {
|
|
t.Fatal("expected role to exist")
|
|
}
|
|
|
|
if err := c.deleteRole(ctx, "litellm", "team-a"); err != nil {
|
|
t.Fatalf("deleteRole: %v", err)
|
|
}
|
|
role, err = c.readRole(ctx, "litellm", "team-a")
|
|
if err != nil {
|
|
t.Fatalf("readRole after delete: %v", err)
|
|
}
|
|
if role != nil {
|
|
t.Fatal("expected role to be gone")
|
|
}
|
|
}
|
|
|
|
func TestRolePath(t *testing.T) {
|
|
if got := rolePath("litellm/", "team-a"); got != "litellm/roles/team-a" {
|
|
t.Fatalf("unexpected role path: %q", got)
|
|
}
|
|
}
|