mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
fix(terraform): read litellm_team_member from /team/info (#47003)
Refresh role/email/budget via GET /team/info, drop state when the team or member is gone, and treat delete of an already-removed member as success so Terraform can converge after out-of-band removals.
This commit is contained in:
parent
f81e657903
commit
f32bc4e422
2 changed files with 204 additions and 4 deletions
|
|
@ -1,8 +1,13 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
|
||||
|
|
@ -82,11 +87,101 @@ func resourceLiteLLMTeamMemberCreate(d *schema.ResourceData, m interface{}) erro
|
|||
return resourceLiteLLMTeamMemberRead(d, m)
|
||||
}
|
||||
|
||||
type teamMemberRoleRow struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type teamMemberBudgetRow struct {
|
||||
MaxBudget *float64 `json:"max_budget"`
|
||||
}
|
||||
|
||||
type teamMemberMembershipRow struct {
|
||||
UserID string `json:"user_id"`
|
||||
LiteLLMBudgetTable *teamMemberBudgetRow `json:"litellm_budget_table"`
|
||||
}
|
||||
|
||||
// teamMemberInfoAPI is the subset of GET /team/info used to refresh a single member.
|
||||
type teamMemberInfoAPI struct {
|
||||
TeamInfo struct {
|
||||
MembersWithRoles []teamMemberRoleRow `json:"members_with_roles"`
|
||||
} `json:"team_info"`
|
||||
TeamMemberships []teamMemberMembershipRow `json:"team_memberships"`
|
||||
}
|
||||
|
||||
func resourceLiteLLMTeamMemberRead(d *schema.ResourceData, m interface{}) error {
|
||||
// There's no specific endpoint to read a single team member
|
||||
// We might need to read the entire team and find the member
|
||||
// For now, we'll just return the data we have in the state
|
||||
log.Printf("[INFO] Reading team member with ID: %s", d.Id())
|
||||
client := m.(*Client)
|
||||
teamID := d.Get("team_id").(string)
|
||||
userID := d.Get("user_id").(string)
|
||||
if teamID == "" || userID == "" {
|
||||
// Fall back to the composite id "team_id:user_id" used on create/import.
|
||||
if parts := strings.SplitN(d.Id(), ":", 2); len(parts) == 2 {
|
||||
if teamID == "" {
|
||||
teamID = parts[0]
|
||||
}
|
||||
if userID == "" {
|
||||
userID = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[INFO] Reading team member team_id=%s user_id=%s", teamID, userID)
|
||||
|
||||
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/team/info?team_id=%s", url.QueryEscape(teamID)), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading team info for member: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
log.Printf("[WARN] Team %s not found, removing team member from state", teamID)
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
if err := handleResponse(resp, "reading team info for member"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var info teamMemberInfoAPI
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return fmt.Errorf("error decoding team info response: %w", err)
|
||||
}
|
||||
|
||||
var found *teamMemberRoleRow
|
||||
for i := range info.TeamInfo.MembersWithRoles {
|
||||
row := &info.TeamInfo.MembersWithRoles[i]
|
||||
if row.UserID == userID {
|
||||
found = row
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
log.Printf("[WARN] User %s not in team %s, removing team member from state", userID, teamID)
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
|
||||
d.Set("team_id", teamID)
|
||||
d.Set("user_id", found.UserID)
|
||||
if found.UserEmail != "" {
|
||||
d.Set("user_email", found.UserEmail)
|
||||
}
|
||||
if found.Role != "" {
|
||||
d.Set("role", found.Role)
|
||||
}
|
||||
|
||||
for _, membership := range info.TeamMemberships {
|
||||
if membership.UserID != userID {
|
||||
continue
|
||||
}
|
||||
if membership.LiteLLMBudgetTable != nil && membership.LiteLLMBudgetTable.MaxBudget != nil {
|
||||
d.Set("max_budget_in_team", *membership.LiteLLMBudgetTable.MaxBudget)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
d.SetId(fmt.Sprintf("%s:%s", teamID, userID))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +230,21 @@ func resourceLiteLLMTeamMemberDelete(d *schema.ResourceData, m interface{}) erro
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Member or team already gone outside Terraform: treat as destroyed so state can converge.
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(body)
|
||||
if resp.StatusCode == http.StatusNotFound ||
|
||||
strings.Contains(bodyStr, "User not found in team") ||
|
||||
strings.Contains(bodyStr, "does not exist") ||
|
||||
strings.Contains(bodyStr, "Team not found") {
|
||||
log.Printf("[WARN] Team member already gone (%s): %s", resp.Status, bodyStr)
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("error deleting team member: %s - %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
if err := handleResponse(resp, "deleting team member"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,3 +42,93 @@ func TestTeamMemberUpdateSendsRole(t *testing.T) {
|
|||
t.Fatalf("update payload sent role %v, want user", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamMemberReadRefreshesRoleAndBudget(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/team/info" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"team_id":"team-1",
|
||||
"team_info":{"members_with_roles":[{"user_id":"user-1","user_email":"u@example.com","role":"admin"}]},
|
||||
"team_memberships":[{"user_id":"user-1","litellm_budget_table":{"max_budget":12.5}}]
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, map[string]interface{}{
|
||||
"team_id": "team-1",
|
||||
"user_id": "user-1",
|
||||
"user_email": "old@example.com",
|
||||
"role": "user",
|
||||
"max_budget_in_team": 1.0,
|
||||
})
|
||||
d.SetId("team-1:user-1")
|
||||
|
||||
if err := resourceLiteLLMTeamMemberRead(d, client); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if d.Id() == "" {
|
||||
t.Fatalf("expected member to remain in state")
|
||||
}
|
||||
if got := d.Get("role").(string); got != "admin" {
|
||||
t.Fatalf("role=%q want admin", got)
|
||||
}
|
||||
if got := d.Get("user_email").(string); got != "u@example.com" {
|
||||
t.Fatalf("user_email=%q want u@example.com", got)
|
||||
}
|
||||
if got := d.Get("max_budget_in_team").(float64); got != 12.5 {
|
||||
t.Fatalf("max_budget_in_team=%v want 12.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamMemberReadRemovesMissingMember(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"team_id":"team-1","team_info":{"members_with_roles":[]},"team_memberships":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, map[string]interface{}{
|
||||
"team_id": "team-1",
|
||||
"user_id": "user-gone",
|
||||
"user_email": "gone@example.com",
|
||||
"role": "user",
|
||||
})
|
||||
d.SetId("team-1:user-gone")
|
||||
|
||||
if err := resourceLiteLLMTeamMemberRead(d, client); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if d.Id() != "" {
|
||||
t.Fatalf("expected member removed from state, id=%q", d.Id())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamMemberDeleteTreatsAlreadyGoneAsSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"detail":{"error":"User not found in team"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, map[string]interface{}{
|
||||
"team_id": "team-1",
|
||||
"user_id": "user-1",
|
||||
"user_email": "u@example.com",
|
||||
"role": "user",
|
||||
})
|
||||
d.SetId("team-1:user-1")
|
||||
|
||||
if err := resourceLiteLLMTeamMemberDelete(d, client); err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if d.Id() != "" {
|
||||
t.Fatalf("expected empty id after gone delete, id=%q", d.Id())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue