diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 597e0c6d4d8..e89ff345ab3 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -53,6 +53,7 @@ longer signal it. - **model**: `additional_drop_params` in `litellm_model.additional_litellm_params` is now sent to the proxy as the deployment's `additional_drop_params` list, the same list a `model_list` entry takes. Before, the provider never sent the list and instead deleted every key it named from `litellm_params`, so a `store` or `vertex_location` listed there was lost and requests still carried the parameters (#38349). The value must be a JSON list of parameter names such as `jsonencode(["store"])`, which `terraform plan` now checks, and `null` entries are skipped as before. When the configuration sets the list, refresh reads the stored list back from the proxy, so a list changed outside Terraform shows up as drift. On a model an older provider created, the proxy has no stored list, so the first plan after upgrading shows the list and every listed key the configuration sets as changes, since that apply writes them. A write-only secret such as `model_api_key_wo` is never shown in a plan - **user**: `litellm_user` updates no longer send `blocked`, which `/user/update` writes into the user table where no such column exists, so every update failed with a 400 (`Could not find field at upsertOneLiteLLM_UserTable.create.blocked`). Create still sends `blocked`, including `false`, so a `default_internal_user_params.blocked` on the proxy cannot block the auto-created key. Changing `blocked` after creation has no effect, since the proxy has no user-level block; use `litellm_key_block` instead +- **key**: `litellm_key` and `litellm_key_block` drop a key deleted outside Terraform from state again now that `/key/info` serves deleted keys with `status = "deleted"` instead of a 404, so plan recreates it, and destroying a `litellm_key` the proxy already deleted succeeds instead of failing on the 404 `No keys found` from `/key/delete` - **model**: `litellm_model` refresh now reads the `{"data": [...]}` envelope `/model/info` returns, so `model_info` fields changed outside Terraform show up as drift instead of silently keeping the previous state - **key**: An update that changes `team_id` and fails because the key was already cascade-deleted along with its previous team now recovers by recreating the key under the new team, instead of aborting the apply. The key's absence is confirmed against the proxy first, so an unrelated failure still errors out, and a `team_id` change between two teams that both still exist stays a plain in-place update - **credential**: create now reports a `credential_name` collision as a clear error naming the `terraform import` command that adopts the existing credential, instead of surfacing the proxy's raw 500 with a Prisma `Unique constraint failed` message. New `adopt_existing` argument (default `false`) opts into taking the existing credential over during create, which makes `apply` idempotent again once state loses track of a credential that still exists on the proxy. Requires a proxy that answers 409 on the collision; older proxies are still detected by their 500 message diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go index 28a89f1eafd..ce8358670ea 100644 --- a/terraform/provider/litellm/client.go +++ b/terraform/provider/litellm/client.go @@ -13,6 +13,8 @@ import ( "strings" ) +const keyStatusDeleted = "deleted" + type Client struct { APIBase string APIKey string @@ -82,6 +84,9 @@ func (c *Client) GetKey(keyID string) (*Key, error) { // /key/info nests the key's fields under "info"; only "key" itself is // top-level. Without unwrapping, reads map nothing back into state. if info, ok := resp["info"].(map[string]interface{}); ok { + if status, _ := info["status"].(string); status == keyStatusDeleted { + return nil, nil + } if _, present := info["key"]; !present { if k, ok := resp["key"].(string); ok { info["key"] = k diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go index 2c751ae6a34..a36aa5a966a 100644 --- a/terraform/provider/litellm/resource_key.go +++ b/terraform/provider/litellm/resource_key.go @@ -530,9 +530,11 @@ func mergeKeyMetadata(server, oldDeclared, newDeclared map[string]interface{}) m func resourceKeyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { c := m.(*Client) - err := c.DeleteKey(d.Id()) - if err != nil { - return diag.FromErr(fmt.Errorf("error deleting key: %s", err)) + if err := c.DeleteKey(d.Id()); err != nil { + if !keyIsGone(c, d.Id(), err) { + return diag.FromErr(fmt.Errorf("error deleting key: %s", err)) + } + log.Printf("[WARN] Key %q was already deleted outside Terraform, removing from state", d.Id()) } d.SetId("") diff --git a/terraform/provider/litellm/resource_key_block.go b/terraform/provider/litellm/resource_key_block.go index 7aa41f832bb..59c0151d103 100644 --- a/terraform/provider/litellm/resource_key_block.go +++ b/terraform/provider/litellm/resource_key_block.go @@ -17,7 +17,8 @@ const ( type KeyBlockInfoResponse struct { Info struct { - Blocked *bool `json:"blocked"` + Status string `json:"status"` + Blocked *bool `json:"blocked"` } `json:"info"` } @@ -98,6 +99,12 @@ func resourceLiteLLMKeyBlockRead(d *schema.ResourceData, m interface{}) error { return fmt.Errorf("error decoding key info response: %w", err) } + if infoResp.Info.Status == keyStatusDeleted { + log.Printf("[WARN] Key was deleted, removing key block from state") + d.SetId("") + return nil + } + if infoResp.Info.Blocked == nil || !*infoResp.Info.Blocked { log.Printf("[WARN] Key is no longer blocked, removing key block from state") d.SetId("") diff --git a/terraform/provider/litellm/resource_key_block_test.go b/terraform/provider/litellm/resource_key_block_test.go index 3de7d3494a5..373cf3b669b 100644 --- a/terraform/provider/litellm/resource_key_block_test.go +++ b/terraform/provider/litellm/resource_key_block_test.go @@ -98,6 +98,25 @@ func TestResourceLiteLLMKeyBlockRead_404ClearsID(t *testing.T) { } } +func TestResourceLiteLLMKeyBlockRead_DeletedKeyClearsID(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(`{"key":"sk-test-123","info":{"blocked":true,"status":"deleted"}}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newKeyBlockTestResourceData(t, "sk-test-123") + d.SetId(keyBlockTestHash) + + if err := resourceLiteLLMKeyBlockRead(d, client); err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if d.Id() != "" { + t.Fatalf("expected ID cleared for deleted key, got %q", d.Id()) + } +} + func TestResourceLiteLLMKeyBlockDelete(t *testing.T) { var gotPath string var unblockPayload map[string]interface{} diff --git a/terraform/provider/litellm/resource_key_test.go b/terraform/provider/litellm/resource_key_test.go index 3ad13b6c83b..6162f0452af 100644 --- a/terraform/provider/litellm/resource_key_test.go +++ b/terraform/provider/litellm/resource_key_test.go @@ -789,6 +789,22 @@ func TestGetKeyUnwrapsInfoEnvelope(t *testing.T) { } } +func TestGetKeyReturnsNilForDeletedStatus(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(`{"key":"hash-1","info":{"token":"hash-1","key_alias":"gone","status":"deleted","deleted_at":"2026-10-05T00:00:00Z","deleted_by":"admin"}}`)) + })) + defer srv.Close() + + key, err := NewClient(srv.URL, "test-key", true).GetKey("hash-1") + if err != nil { + t.Fatalf("GetKey returned error: %v", err) + } + if key != nil { + t.Errorf("GetKey returned %+v, want nil for deleted status", key) + } +} + func TestGetKeyReadsFieldsStoredInMetadata(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -897,6 +913,48 @@ func TestResourceKeyReadDropsMissingKeyFromState(t *testing.T) { } } +func TestResourceKeyReadDropsDeletedKeyFromState(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(`{"key":"hash-1","info":{"token":"hash-1","key_alias":"gone","status":"deleted","deleted_at":"2026-10-05T00:00:00Z","deleted_by":"admin"}}`)) + })) + defer srv.Close() + + d := newKeyResourceData(t, map[string]interface{}{"key_alias": "gone"}) + d.SetId("hash-1") + + diags := resourceKeyRead(context.Background(), d, NewClient(srv.URL, "test-key", true)) + if diags.HasError() { + t.Fatalf("read of a deleted key must not error, got: %v", diags) + } + if d.Id() != "" { + t.Errorf("Id = %q, want empty so Terraform plans a recreate", d.Id()) + } +} + +func TestResourceKeyReadKeepsKeyWithLiveStatus(t *testing.T) { + for _, status := range []string{"active", "revoked", "expired"} { + t.Run(status, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"key":"hash-1","info":{"token":"hash-1","status":%q}}`, status) + })) + defer srv.Close() + + d := newKeyResourceData(t, map[string]interface{}{}) + d.SetId("hash-1") + + diags := resourceKeyRead(context.Background(), d, NewClient(srv.URL, "test-key", true)) + if diags.HasError() { + t.Fatalf("read of a %s key returned error: %v", status, diags) + } + if d.Id() != "hash-1" { + t.Errorf("Id = %q, want unchanged for %s status", d.Id(), status) + } + }) + } +} + func TestResourceKeyReadStillFailsOnNon404Errors(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -916,6 +974,109 @@ func TestResourceKeyReadStillFailsOnNon404Errors(t *testing.T) { } } +func TestResourceKeyDeleteTreatsAlreadyDeletedKeyAsGone(t *testing.T) { + for _, infoStatus := range []int{http.StatusOK, http.StatusNotFound} { + t.Run(http.StatusText(infoStatus), func(t *testing.T) { + infoCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/key/delete": + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":{"message":"{'error': 'No keys found'}","type":"internal_server_error","param":null,"code":"404"}}`)) + case "/key/info": + infoCalls++ + if infoStatus == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(keyNotFoundBody)) + return + } + w.Write([]byte(`{"key":"hash-1","info":{"token":"hash-1","status":"deleted"}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + d := newKeyResourceData(t, map[string]interface{}{}) + d.SetId("hash-1") + + diags := resourceKeyDelete(context.Background(), d, NewClient(srv.URL, "test-key", true)) + if diags.HasError() { + t.Fatalf("deleting an already deleted key must not error, got: %v", diags) + } + if d.Id() != "" { + t.Errorf("Id = %q, want empty after confirming the key is gone", d.Id()) + } + if infoCalls != 1 { + t.Errorf("expected one /key/info confirmation, got %d calls", infoCalls) + } + }) + } +} + +func TestResourceKeyDelete404WithLiveKeyFails(t *testing.T) { + infoCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/key/delete": + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":{"message":"{'error': 'No keys found'}","type":"internal_server_error","param":null,"code":"404"}}`)) + case "/key/info": + infoCalls++ + w.Write([]byte(`{"key":"hash-1","info":{"token":"hash-1","status":"active"}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + d := newKeyResourceData(t, map[string]interface{}{}) + d.SetId("hash-1") + + diags := resourceKeyDelete(context.Background(), d, NewClient(srv.URL, "test-key", true)) + if !diags.HasError() { + t.Fatal("a /key/delete 404 with a live key must remain an error") + } + if d.Id() != "hash-1" { + t.Errorf("Id = %q, want unchanged when /key/info confirms a live key", d.Id()) + } + if infoCalls != 1 { + t.Errorf("expected one /key/info confirmation, got %d calls", infoCalls) + } +} + +func TestResourceKeyDeleteServerErrorFailsWithoutInfoLookup(t *testing.T) { + infoCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/key/delete" { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"server error"}}`)) + return + } + if r.URL.Path == "/key/info" { + infoCalls++ + } + })) + defer srv.Close() + + d := newKeyResourceData(t, map[string]interface{}{}) + d.SetId("hash-1") + + diags := resourceKeyDelete(context.Background(), d, NewClient(srv.URL, "test-key", true)) + if !diags.HasError() { + t.Fatal("a /key/delete 500 must remain an error") + } + if d.Id() != "hash-1" { + t.Errorf("Id = %q, want unchanged after a server error", d.Id()) + } + if infoCalls != 0 { + t.Errorf("expected no /key/info lookup after a /key/delete 500, got %d calls", infoCalls) + } +} + // fakeKeyProxy serves /key/info from stored metadata and applies /key/update // the way the proxy does: an absent "metadata" keeps the stored map, a // present one replaces it wholesale. @@ -1135,11 +1296,12 @@ func newKeyUpdateResourceData(t *testing.T, id, oldTeamID, newTeamID string) *sc // turns on: what POST /key/update returns, and whether GET /key/info still // finds the key afterwards. type keyRecoveryProxy struct { - updateStatus int - updateBody string - staleKeyGone bool - updateCalls int32 - generateCalls int32 + updateStatus int + updateBody string + staleKeyGone bool + staleKeyDeleted bool + updateCalls int32 + generateCalls int32 } const keyNotFoundBody = `{"error":{"message":"Key not found.","type":"not_found_error","param":"key","code":"404"}}` @@ -1158,6 +1320,10 @@ func (p *keyRecoveryProxy) handler() http.HandlerFunc { case "/key/info": requested := r.URL.Query().Get("key") if p.staleKeyGone && requested != "new-token" { + if p.staleKeyDeleted { + io.WriteString(w, `{"key":"stale-token","info":{"token":"stale-token","status":"deleted","deleted_at":"2026-10-05T00:00:00Z","deleted_by":"admin"}}`) + return + } w.WriteHeader(http.StatusNotFound) io.WriteString(w, keyNotFoundBody) return @@ -1216,6 +1382,26 @@ func TestResourceKeyUpdateRecreatesCascadeDeletedKey(t *testing.T) { } } +func TestResourceKeyUpdateRecreatesCascadeDeletedKeyWithDeletedStatus(t *testing.T) { + proxy := &keyRecoveryProxy{ + updateStatus: http.StatusNotFound, + updateBody: keyNotFoundBody, + staleKeyGone: true, + staleKeyDeleted: true, + } + d := newKeyUpdateResourceData(t, "stale-token", "team-a", "team-b") + + if diags := runKeyUpdate(t, proxy, d); diags.HasError() { + t.Fatalf("a cascade-deleted key must be recreated, not error: %v", diags) + } + if got := atomic.LoadInt32(&proxy.generateCalls); got != 1 { + t.Errorf("expected exactly 1 /key/generate recreate, got %d", got) + } + if d.Id() != "new-token" { + t.Errorf("Id = %q, want the recreated key's new-token", d.Id()) + } +} + // /key/update 404s for reasons other than a missing key, a rejected // project_id among them. Recovering on the status code alone would orphan a // key that is still live on the proxy, so the key's absence must be confirmed.