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
167 lines
4.7 KiB
Go
167 lines
4.7 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
|
)
|
|
|
|
func TestRoleData_FullRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
models, _ := types.SetValueFrom(ctx, types.StringType, []string{"gpt-4", "gpt-3.5-turbo"})
|
|
meta, _ := types.MapValueFrom(ctx, types.StringType, map[string]string{"team": "a"})
|
|
|
|
m := secretBackendRoleModel{
|
|
Backend: types.StringValue("litellm"),
|
|
Name: types.StringValue("team-a"),
|
|
Models: models,
|
|
MaxBudget: types.Float64Value(50),
|
|
KeyAliasPrefix: types.StringValue("vault"),
|
|
TTL: types.Int64Value(3600),
|
|
MaxTTL: types.Int64Value(86400),
|
|
Metadata: meta,
|
|
}
|
|
|
|
data, diags := roleData(ctx, m)
|
|
if diags.HasError() {
|
|
t.Fatalf("roleData diags: %v", diags)
|
|
}
|
|
if got := data["max_budget"].(float64); got != 50 {
|
|
t.Fatalf("max_budget: %v", got)
|
|
}
|
|
if got := data["ttl"].(int64); got != 3600 {
|
|
t.Fatalf("ttl: %v", got)
|
|
}
|
|
if got := data["models"].([]string); len(got) != 2 {
|
|
t.Fatalf("models: %v", got)
|
|
}
|
|
if got := data["metadata"].(map[string]string); got["team"] != "a" {
|
|
t.Fatalf("metadata: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestRoleData_OmitsUnsetOptionals(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := secretBackendRoleModel{
|
|
Backend: types.StringValue("litellm"),
|
|
Name: types.StringValue("minimal"),
|
|
Models: types.SetNull(types.StringType),
|
|
MaxBudget: types.Float64Null(),
|
|
KeyAliasPrefix: types.StringValue("vault"),
|
|
TTL: types.Int64Null(),
|
|
MaxTTL: types.Int64Null(),
|
|
Metadata: types.MapNull(types.StringType),
|
|
}
|
|
data, diags := roleData(ctx, m)
|
|
if diags.HasError() {
|
|
t.Fatalf("diags: %v", diags)
|
|
}
|
|
if _, ok := data["max_budget"]; ok {
|
|
t.Fatal("max_budget should be omitted when null")
|
|
}
|
|
if _, ok := data["ttl"]; ok {
|
|
t.Fatal("ttl should be omitted when null")
|
|
}
|
|
// models is always sent (empty list = unrestricted).
|
|
if got, ok := data["models"].([]string); !ok || len(got) != 0 {
|
|
t.Fatalf("expected empty models slice, got %v", data["models"])
|
|
}
|
|
}
|
|
|
|
func TestApplyRoleData(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := &secretBackendRoleModel{
|
|
Models: types.SetNull(types.StringType),
|
|
MaxBudget: types.Float64Null(),
|
|
TTL: types.Int64Null(),
|
|
MaxTTL: types.Int64Null(),
|
|
Metadata: types.MapNull(types.StringType),
|
|
}
|
|
|
|
// Simulate what Vault returns (numbers arrive as json.Number).
|
|
role := map[string]interface{}{
|
|
"models": []interface{}{"gpt-4"},
|
|
"max_budget": json.Number("50"),
|
|
"key_alias_prefix": "vault",
|
|
"ttl": json.Number("3600"),
|
|
"max_ttl": json.Number("86400"),
|
|
"metadata": map[string]interface{}{"team": "a"},
|
|
}
|
|
|
|
diags := applyRoleData(ctx, m, role)
|
|
if diags.HasError() {
|
|
t.Fatalf("applyRoleData diags: %v", diags)
|
|
}
|
|
if m.TTL.ValueInt64() != 3600 {
|
|
t.Fatalf("ttl: %v", m.TTL)
|
|
}
|
|
if m.MaxTTL.ValueInt64() != 86400 {
|
|
t.Fatalf("max_ttl: %v", m.MaxTTL)
|
|
}
|
|
if m.MaxBudget.ValueFloat64() != 50 {
|
|
t.Fatalf("max_budget: %v", m.MaxBudget)
|
|
}
|
|
if m.KeyAliasPrefix.ValueString() != "vault" {
|
|
t.Fatalf("prefix: %v", m.KeyAliasPrefix)
|
|
}
|
|
var models []string
|
|
m.Models.ElementsAs(ctx, &models, false)
|
|
if len(models) != 1 || models[0] != "gpt-4" {
|
|
t.Fatalf("models: %v", models)
|
|
}
|
|
}
|
|
|
|
func TestApplyRoleData_EmptyModelsBecomesNull(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := &secretBackendRoleModel{
|
|
Models: types.SetValueMust(types.StringType, nil),
|
|
Metadata: types.MapNull(types.StringType),
|
|
}
|
|
role := map[string]interface{}{"models": []interface{}{}}
|
|
if diags := applyRoleData(ctx, m, role); diags.HasError() {
|
|
t.Fatalf("diags: %v", diags)
|
|
}
|
|
if !m.Models.IsNull() {
|
|
t.Fatalf("expected null models, got %v", m.Models)
|
|
}
|
|
}
|
|
|
|
func TestToInt64(t *testing.T) {
|
|
cases := []struct {
|
|
in interface{}
|
|
want int64
|
|
ok bool
|
|
}{
|
|
{json.Number("3600"), 3600, true},
|
|
{float64(42), 42, true},
|
|
{int64(7), 7, true},
|
|
{int(9), 9, true},
|
|
{"nope", 0, false},
|
|
{nil, 0, false},
|
|
}
|
|
for _, tc := range cases {
|
|
got, ok := toInt64(tc.in)
|
|
if ok != tc.ok || got != tc.want {
|
|
t.Fatalf("toInt64(%v) = (%d,%v), want (%d,%v)", tc.in, got, ok, tc.want, tc.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitRoleID(t *testing.T) {
|
|
backend, name, ok := splitRoleID("litellm/roles/team-a")
|
|
if !ok || backend != "litellm" || name != "team-a" {
|
|
t.Fatalf("got backend=%q name=%q ok=%v", backend, name, ok)
|
|
}
|
|
if _, _, ok := splitRoleID("nonsense"); ok {
|
|
t.Fatal("expected failure on malformed id")
|
|
}
|
|
// Nested mount paths must still split on the final /roles/.
|
|
b, n, ok := splitRoleID("team/litellm/roles/x")
|
|
if !ok || b != "team/litellm" || n != "x" {
|
|
t.Fatalf("nested split: backend=%q name=%q ok=%v", b, n, ok)
|
|
}
|
|
}
|