mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
fix(terraform): clear max_budget_in_team when membership budget is removed
Address review: always reconcile max_budget_in_team after a successful member hit, and only treat 404 or the proxy's concrete 'User not found in team' / 'Team id=... does not exist in db' 400s as already-deleted.
This commit is contained in:
parent
08a00097b4
commit
8606261cff
2 changed files with 117 additions and 6 deletions
|
|
@ -171,15 +171,24 @@ func resourceLiteLLMTeamMemberRead(d *schema.ResourceData, m interface{}) error
|
|||
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 && membership.LiteLLMBudgetTable.MaxBudget != nil {
|
||||
d.Set("max_budget_in_team", *membership.LiteLLMBudgetTable.MaxBudget)
|
||||
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
|
||||
|
|
@ -234,10 +243,7 @@ func resourceLiteLLMTeamMemberDelete(d *schema.ResourceData, m interface{}) erro
|
|||
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") {
|
||||
if isTeamMemberAlreadyGone(resp.StatusCode, bodyStr) {
|
||||
log.Printf("[WARN] Team member already gone (%s): %s", resp.Status, bodyStr)
|
||||
d.SetId("")
|
||||
return nil
|
||||
|
|
@ -254,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=<id> does not exist in db"
|
||||
return strings.Contains(body, "Team id=") && strings.Contains(body, "does not exist in db")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,3 +132,90 @@ func TestTeamMemberDeleteTreatsAlreadyGoneAsSuccess(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue