diff --git a/terraform/provider/litellm/resource_team_member.go b/terraform/provider/litellm/resource_team_member.go index 84db07239fd..fa7fad597cb 100644 --- a/terraform/provider/litellm/resource_team_member.go +++ b/terraform/provider/litellm/resource_team_member.go @@ -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,110 @@ 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) + } + + // Always reconcile the per-team budget: if the membership row, its budget + // table, or max_budget is gone, clear the attribute so out-of-band removals + // show up as drift instead of keeping the stale value in state. + var maxBudget *float64 + for _, membership := range info.TeamMemberships { + if membership.UserID != userID { + continue + } + if membership.LiteLLMBudgetTable != nil { + maxBudget = membership.LiteLLMBudgetTable.MaxBudget + } + break + } + if maxBudget != nil { + d.Set("max_budget_in_team", *maxBudget) + } else { + d.Set("max_budget_in_team", nil) + } + + d.SetId(fmt.Sprintf("%s:%s", teamID, userID)) return nil } @@ -135,6 +239,18 @@ 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 isTeamMemberAlreadyGone(resp.StatusCode, bodyStr) { + 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 } @@ -144,3 +260,21 @@ func resourceLiteLLMTeamMemberDelete(d *schema.ResourceData, m interface{}) erro d.SetId("") return nil } + +// isTeamMemberAlreadyGone reports whether a /team/member_delete error means the +// member or team was already removed outside Terraform. Only 404s and the +// proxy's concrete "not found" messages qualify, so unrelated 400 validation +// errors still fail the destroy. +func isTeamMemberAlreadyGone(statusCode int, body string) bool { + if statusCode == http.StatusNotFound { + return true + } + if statusCode != http.StatusBadRequest { + return false + } + if strings.Contains(body, "User not found in team") { + return true + } + // "Team id= does not exist in db" + return strings.Contains(body, "Team id=") && strings.Contains(body, "does not exist in db") +} diff --git a/terraform/provider/litellm/resource_team_member_test.go b/terraform/provider/litellm/resource_team_member_test.go index 156c4b3abfa..30991d8524f 100644 --- a/terraform/provider/litellm/resource_team_member_test.go +++ b/terraform/provider/litellm/resource_team_member_test.go @@ -42,3 +42,180 @@ 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()) + } +} + +func TestTeamMemberReadClearsRemovedBudget(t *testing.T) { + cases := map[string]string{ + "no membership row": `[]`, + "null budget table": `[{"user_id":"user-1","litellm_budget_table":null}]`, + "null max_budget": `[{"user_id":"user-1","litellm_budget_table":{"max_budget":null}}]`, + "other member budget": `[{"user_id":"user-2","litellm_budget_table":{"max_budget":9}}]`, + } + for name, memberships := range cases { + t.Run(name, func(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":[{"user_id":"user-1","user_email":"u@example.com","role":"user"}]}, + "team_memberships":` + 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-1", + "user_email": "u@example.com", + "role": "user", + "max_budget_in_team": 12.5, + }) + 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("max_budget_in_team").(float64); got != 0 { + t.Fatalf("max_budget_in_team=%v want cleared (0)", got) + } + }) + } +} + +func TestIsTeamMemberAlreadyGone(t *testing.T) { + cases := []struct { + status int + body string + want bool + }{ + {http.StatusNotFound, ``, true}, + {http.StatusBadRequest, `{"detail":{"error":"User not found in team"}}`, true}, + {http.StatusBadRequest, `{"detail":{"error":"Team id=team-1 does not exist in db"}}`, true}, + {http.StatusBadRequest, `{"detail":{"error":"Either user_id or user_email needs to be passed in"}}`, false}, + {http.StatusBadRequest, `{"detail":{"error":"budget does not exist"}}`, false}, + {http.StatusForbidden, `{"detail":{"error":"User not found in team"}}`, false}, + } + for _, c := range cases { + if got := isTeamMemberAlreadyGone(c.status, c.body); got != c.want { + t.Errorf("isTeamMemberAlreadyGone(%d, %q)=%v want %v", c.status, c.body, got, c.want) + } + } +} + +func TestTeamMemberDeleteFailsOnUnrelatedBadRequest(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":"Either user_id or user_email needs to be passed in"}}`)) + })) + 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("expected delete to fail on unrelated 400") + } + if d.Id() == "" { + t.Fatalf("expected id to be kept after failed delete") + } +}