diff --git a/docs/resources/ai_group_budget.md b/docs/resources/ai_group_budget.md index 8f41217..26ce58c 100644 --- a/docs/resources/ai_group_budget.md +++ b/docs/resources/ai_group_budget.md @@ -6,7 +6,7 @@ description: |- ~> Warning This resource is only compatible with Coder version 2.36.0 and later. Manages the AI spend limit that AI Gateway applies to each member of a group. Requires a Premium license with the AI Gateway feature, and the Owner or User Admin role, or the Organization Admin or Organization User Admin role in the group's organization. - Each member of the group can spend up to the limit in each budget period https://coder.com/docs/ai-coder/ai-gateway/cost-controls#deployment-settings. When a user belongs to several groups with a budget, the deployment's budget policy https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution selects the group that applies. + Each member of the group can spend up to the limit in each budget period https://coder.com/docs/ai-coder/ai-gateway/cost-controls#deployment-settings. When a user belongs to several groups with a budget, the deployment's budget policy https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution selects the group that applies. Use coderd_ai_user_budget_override to set a different limit for a single user. -> If Coder already has a different budget for the group, the plan fails. Import that budget to manage it with Terraform. Destroying this resource removes the budget. Future requests from members of the group use their next effective group https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution, and spend already recorded stays attributed to this group. Coder also removes the budget when the group is deleted. --- @@ -18,7 +18,7 @@ This resource is only compatible with Coder version 2.36.0 and later. Manages the AI spend limit that AI Gateway applies to each member of a group. Requires a Premium license with the AI Gateway feature, and the Owner or User Admin role, or the Organization Admin or Organization User Admin role in the group's organization. -Each member of the group can spend up to the limit in each [budget period](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#deployment-settings). When a user belongs to several groups with a budget, the deployment's [budget policy](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution) selects the group that applies. +Each member of the group can spend up to the limit in each [budget period](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#deployment-settings). When a user belongs to several groups with a budget, the deployment's [budget policy](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution) selects the group that applies. Use `coderd_ai_user_budget_override` to set a different limit for a single user. -> If Coder already has a different budget for the group, the plan fails. Import that budget to manage it with Terraform. diff --git a/docs/resources/ai_user_budget_override.md b/docs/resources/ai_user_budget_override.md new file mode 100644 index 0000000..94b5413 --- /dev/null +++ b/docs/resources/ai_user_budget_override.md @@ -0,0 +1,79 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_ai_user_budget_override Resource - terraform-provider-coderd" +subcategory: "AI Governance" +description: |- + ~> Warning + This resource is only compatible with Coder version 2.36.0 and later. + Manages an AI spend limit for a single user, which takes precedence https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution over the user's coderd_ai_group_budget limits. Requires a Premium license with the AI Gateway feature, and the Owner or User Admin role. + The override attributes the user's spend to a group, which the user must be a member of. Coder removes the override when the user leaves that group or its organization, or when the user or the group is deleted. + -> If Coder already has a different override for the user, the plan fails. Import that override to manage it with Terraform. + Destroying this resource removes the override. Future requests from the user use their effective group https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution, and spend already recorded stays attributed to the override's group. +--- + +# coderd_ai_user_budget_override (Resource) + +~> **Warning** +This resource is only compatible with Coder version 2.36.0 and later. + +Manages an AI spend limit for a single user, which [takes precedence](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution) over the user's `coderd_ai_group_budget` limits. Requires a Premium license with the AI Gateway feature, and the Owner or User Admin role. + +The override attributes the user's spend to a group, which the user must be a member of. Coder removes the override when the user leaves that group or its organization, or when the user or the group is deleted. + +-> If Coder already has a different override for the user, the plan fails. Import that override to manage it with Terraform. + +Destroying this resource removes the override. Future requests from the user use their [effective group](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution), and spend already recorded stays attributed to the override's group. + +## Example Usage + +```terraform +resource "coderd_user" "alice" { + username = "alice" + email = "alice@example.com" +} + +resource "coderd_group" "engineering" { + name = "engineering" + members = [coderd_user.alice.id] +} + +resource "coderd_ai_group_budget" "engineering" { + group_id = coderd_group.engineering.id + spend_limit = 50000000 // $50.00 for each member of the group +} + +resource "coderd_ai_user_budget_override" "alice" { + user_id = coderd_user.alice.id + group_id = coderd_group.engineering.id + spend_limit = 200000000 // $200.00 for alice, attributed to engineering +} +``` + + +## Schema + +### Required + +- `group_id` (String) ID of the group that the user's spend is attributed to. The user must be a member of the group. +- `spend_limit` (Number) Spend limit for the user in each budget period, in integer micro-units: `1000000` is $1.00. A limit of `0` allows no spend. The maximum is `1000000000000` ($1,000,000.00). +- `user_id` (String) ID of the user the override applies to. Changing this forces a new resource. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# The ID supplied can be either a user UUID retrieved via the API +# or a username. +$ terraform import coderd_ai_user_budget_override.example alice +``` +Alternatively, in Terraform v1.5.0 and later, an [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used: + +```terraform +import { + to = coderd_ai_user_budget_override.example + id = "alice" +} +``` diff --git a/examples/resources/coderd_ai_user_budget_override/import.sh b/examples/resources/coderd_ai_user_budget_override/import.sh new file mode 100644 index 0000000..0f47033 --- /dev/null +++ b/examples/resources/coderd_ai_user_budget_override/import.sh @@ -0,0 +1,11 @@ +# The ID supplied can be either a user UUID retrieved via the API +# or a username. +$ terraform import coderd_ai_user_budget_override.example alice +``` +Alternatively, in Terraform v1.5.0 and later, an [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used: + +```terraform +import { + to = coderd_ai_user_budget_override.example + id = "alice" +} diff --git a/examples/resources/coderd_ai_user_budget_override/resource.tf b/examples/resources/coderd_ai_user_budget_override/resource.tf new file mode 100644 index 0000000..fb34657 --- /dev/null +++ b/examples/resources/coderd_ai_user_budget_override/resource.tf @@ -0,0 +1,20 @@ +resource "coderd_user" "alice" { + username = "alice" + email = "alice@example.com" +} + +resource "coderd_group" "engineering" { + name = "engineering" + members = [coderd_user.alice.id] +} + +resource "coderd_ai_group_budget" "engineering" { + group_id = coderd_group.engineering.id + spend_limit = 50000000 // $50.00 for each member of the group +} + +resource "coderd_ai_user_budget_override" "alice" { + user_id = coderd_user.alice.id + group_id = coderd_group.engineering.id + spend_limit = 200000000 // $200.00 for alice, attributed to engineering +} diff --git a/internal/provider/ai_group_budget_resource.go b/internal/provider/ai_group_budget_resource.go index 72f0816..6c205a4 100644 --- a/internal/provider/ai_group_budget_resource.go +++ b/internal/provider/ai_group_budget_resource.go @@ -130,7 +130,8 @@ func (r *AIGroupBudgetResource) Schema(ctx context.Context, req resource.SchemaR "Requires a Premium license with the AI Gateway feature, and the Owner or User Admin role, " + "or the Organization Admin or Organization User Admin role in the group's organization.\n\n" + "Each member of the group can spend up to the limit in each [budget period](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#deployment-settings). " + - "When a user belongs to several groups with a budget, the deployment's [budget policy](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution) selects the group that applies.\n\n" + + "When a user belongs to several groups with a budget, the deployment's [budget policy](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution) selects the group that applies. " + + "Use `coderd_ai_user_budget_override` to set a different limit for a single user.\n\n" + "-> If Coder already has a different budget for the group, the plan fails. Import that budget to manage it with Terraform.\n\n" + "Destroying this resource removes the budget. Future requests from members of the group use their next " + "[effective group](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution), " + diff --git a/internal/provider/ai_user_budget_override_resource.go b/internal/provider/ai_user_budget_override_resource.go new file mode 100644 index 0000000..8afcdbd --- /dev/null +++ b/internal/provider/ai_user_budget_override_resource.go @@ -0,0 +1,275 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// aiUserBudgetOverrideMinVersion is the first Coder release that this resource +// supports. +const aiUserBudgetOverrideMinVersion = "2.36.0" + +var ( + _ resource.Resource = &AIUserBudgetOverrideResource{} + _ resource.ResourceWithConfigure = &AIUserBudgetOverrideResource{} + _ resource.ResourceWithImportState = &AIUserBudgetOverrideResource{} + _ resource.ResourceWithModifyPlan = &AIUserBudgetOverrideResource{} +) + +func NewAIUserBudgetOverrideResource() resource.Resource { + return &AIUserBudgetOverrideResource{} +} + +type AIUserBudgetOverrideResource struct { + data *CoderdProviderData +} + +type AIUserBudgetOverrideResourceModel struct { + UserID UUID `tfsdk:"user_id"` + GroupID UUID `tfsdk:"group_id"` + SpendLimit types.Int64 `tfsdk:"spend_limit"` +} + +// aiUserBudgetOverrideDiag converts an error from the user AI budget override +// API into a diagnostic. The upsert call returns 404 only when the group does +// not exist, or when the deployment has no user AI budget override API. +func aiUserBudgetOverrideDiag(action string, userID uuid.UUID, err error) diag.Diagnostics { + var diags diag.Diagnostics + if isHTTPNotFound(err) { + diags.AddError("Group Not Found or Unsupported Coder Version", fmt.Sprintf( + "Unable to %s AI budget override for user %s: the deployment returned 404 for /api/v2/users/%s/ai/budget/override. "+ + "Either the group does not exist, or the deployment is older than Coder version %s, which this resource requires. "+ + "Original error: %s", + action, userID, userID, aiUserBudgetOverrideMinVersion, err)) + return diags + } + diags.AddError("Client Error", fmt.Sprintf("Unable to %s AI budget override for user %s, got error: %s", action, userID, err)) + return diags +} + +func aiUserBudgetOverrideConflictDiag(userID uuid.UUID) diag.Diagnostics { + var diags diag.Diagnostics + diags.AddError("AI User Budget Override Already Exists", fmt.Sprintf( + "Coder already has an AI budget override for user %s with a different group or spend limit. Terraform does not overwrite an override that it does not manage. "+ + "To manage the existing override, import it with the ID `%s`, then set the values that you want.", + userID, userID)) + return diags +} + +// sameAIUserBudgetOverride reports whether the plan matches an existing +// override. +func sameAIUserBudgetOverride(plan AIUserBudgetOverrideResourceModel, o codersdk.UserAIBudgetOverride) bool { + return plan.GroupID.ValueUUID() == o.GroupID && plan.SpendLimit.ValueInt64() == o.SpendLimitMicros +} + +// userOverride returns nil, nil when the user has no AI budget override. +func (r *AIUserBudgetOverrideResource) userOverride(ctx context.Context, userID uuid.UUID) (*codersdk.UserAIBudgetOverride, error) { + override, err := r.data.Client.UserAIBudgetOverride(ctx, userID) + if isNotFound(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &override, nil +} + +func (r *AIUserBudgetOverrideResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_ai_user_budget_override" +} + +// ModifyPlan fails the plan when a create would overwrite an override that +// Terraform does not manage. A create adopts an equal override, so a retry +// after a partially failed apply succeeds. +func (r *AIUserBudgetOverrideResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if req.Plan.Raw.IsNull() || r.data == nil { + return + } + var plan AIUserBudgetOverrideResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() || plan.UserID.IsUnknown() || plan.GroupID.IsUnknown() || plan.SpendLimit.IsUnknown() { + return + } + // Only a create or a replace writes an override that Terraform does not manage yet. + if !req.State.Raw.IsNull() { + var state AIUserBudgetOverrideResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() || state.UserID.Equal(plan.UserID) { + return + } + } + existing, err := r.userOverride(ctx, plan.UserID.ValueUUID()) + if err != nil { + // Best-effort. Create() repeats the check and reports the error. + tflog.Debug(ctx, "skipping AI user budget override plan-time conflict check", map[string]any{ + "error": err.Error(), + }) + return + } + if existing != nil && !sameAIUserBudgetOverride(plan, *existing) { + resp.Diagnostics.Append(aiUserBudgetOverrideConflictDiag(plan.UserID.ValueUUID())...) + } +} + +func (r *AIUserBudgetOverrideResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "~> **Warning**\nThis resource is only compatible with Coder version " + aiUserBudgetOverrideMinVersion + " and later.\n\n" + + "Manages an AI spend limit for a single user, which [takes precedence](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution) over the user's `coderd_ai_group_budget` limits. " + + "Requires a Premium license with the AI Gateway feature, and the Owner or User Admin role.\n\n" + + "The override attributes the user's spend to a group, which the user must be a member of. " + + "Coder removes the override when the user leaves that group or its organization, or when the user or the group is deleted.\n\n" + + "-> If Coder already has a different override for the user, the plan fails. Import that override to manage it with Terraform.\n\n" + + "Destroying this resource removes the override. Future requests from the user use their " + + "[effective group](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution), " + + "and spend already recorded stays attributed to the override's group.", + Attributes: map[string]schema.Attribute{ + "user_id": schema.StringAttribute{ + MarkdownDescription: "ID of the user the override applies to. Changing this forces a new resource.", + CustomType: UUIDType, + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "group_id": schema.StringAttribute{ + MarkdownDescription: "ID of the group that the user's spend is attributed to. The user must be a member of the group.", + CustomType: UUIDType, + Required: true, + }, + "spend_limit": schema.Int64Attribute{ + MarkdownDescription: "Spend limit for the user in each budget period, in integer micro-units: `1000000` is $1.00. " + + "A limit of `0` allows no spend. The maximum is `1000000000000` ($1,000,000.00).", + Required: true, + Validators: aiSpendLimitValidators, + }, + }, + } +} + +func (r *AIUserBudgetOverrideResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + data, ok := req.ProviderData.(*CoderdProviderData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.data = data +} + +func (r *AIUserBudgetOverrideResource) upsert(ctx context.Context, plan AIUserBudgetOverrideResourceModel) error { + _, err := r.data.Client.UpsertUserAIBudgetOverride(ctx, plan.UserID.ValueUUID(), codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: plan.GroupID.ValueUUID(), + SpendLimitMicros: plan.SpendLimit.ValueInt64(), + }) + return err +} + +func (r *AIUserBudgetOverrideResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan AIUserBudgetOverrideResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(CheckAIBridgeEntitlements(ctx, r.data.Features())...) + if resp.Diagnostics.HasError() { + return + } + + userID := plan.UserID.ValueUUID() + existing, err := r.userOverride(ctx, userID) + if err != nil { + resp.Diagnostics.Append(aiUserBudgetOverrideDiag("read", userID, err)...) + return + } + if existing != nil && !sameAIUserBudgetOverride(plan, *existing) { + resp.Diagnostics.Append(aiUserBudgetOverrideConflictDiag(userID)...) + return + } + if err := r.upsert(ctx, plan); err != nil { + resp.Diagnostics.Append(aiUserBudgetOverrideDiag("set", userID, err)...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *AIUserBudgetOverrideResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state AIUserBudgetOverrideResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + userID := state.UserID.ValueUUID() + override, err := r.userOverride(ctx, userID) + if err != nil { + resp.Diagnostics.Append(aiUserBudgetOverrideDiag("read", userID, err)...) + return + } + if override == nil { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("AI budget override for user %s not found. Marking as deleted.", userID)) + resp.State.RemoveResource(ctx) + return + } + + state.GroupID = UUIDValue(override.GroupID) + state.SpendLimit = types.Int64Value(override.SpendLimitMicros) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *AIUserBudgetOverrideResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan AIUserBudgetOverrideResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(CheckAIBridgeEntitlements(ctx, r.data.Features())...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.upsert(ctx, plan); err != nil { + resp.Diagnostics.Append(aiUserBudgetOverrideDiag("update", plan.UserID.ValueUUID(), err)...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *AIUserBudgetOverrideResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state AIUserBudgetOverrideResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + userID := state.UserID.ValueUUID() + err := r.data.Client.DeleteUserAIBudgetOverride(ctx, userID) + if err != nil && !isNotFound(err) { + resp.Diagnostics.Append(aiUserBudgetOverrideDiag("delete", userID, err)...) + } +} + +// ImportState accepts a user ID or a username, like coderd_user. +func (r *AIUserBudgetOverrideResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + user, err := r.data.Client.User(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get user %q, expected a user ID or a username: %s", req.ID, err)) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("user_id"), user.ID.String())...) +} diff --git a/internal/provider/ai_user_budget_override_resource_test.go b/internal/provider/ai_user_budget_override_resource_test.go new file mode 100644 index 0000000..fb635b4 --- /dev/null +++ b/internal/provider/ai_user_budget_override_resource_test.go @@ -0,0 +1,317 @@ +package provider + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "regexp" + "testing" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stretchr/testify/require" +) + +func testAIUserBudgetOverrideConfig(url, body string) string { + return `provider "coderd" { + url = "` + url + `" + token = "test-token" +} + +` + body +} + +func TestAccAIUserBudgetOverrideResourceValidateConfig(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + for name, tc := range map[string]struct { + body string + wantError string + }{ + "negative spend limit": { + body: `resource "coderd_ai_user_budget_override" "test" { + user_id = "00000000-0000-0000-0000-000000000003" + group_id = "00000000-0000-0000-0000-000000000004" + spend_limit = -1 +} +`, + wantError: `must be between 0 and 1000000000000`, + }, + "invalid user ID": { + body: `resource "coderd_ai_user_budget_override" "test" { + user_id = "not-a-uuid" + group_id = "00000000-0000-0000-0000-000000000004" + spend_limit = 100 +} +`, + wantError: `Invalid UUID`, + }, + "invalid group ID": { + body: `resource "coderd_ai_user_budget_override" "test" { + user_id = "00000000-0000-0000-0000-000000000003" + group_id = "not-a-uuid" + spend_limit = 100 +} +`, + wantError: `Invalid UUID`, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAIUserBudgetOverrideConfig("http://127.0.0.1", tc.body), + ExpectError: regexp.MustCompile(tc.wantError), + }, + }, + }) + }) + } +} + +func TestAccAIUserBudgetOverrideResourceModifyPlan(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + const userID = "00000000-0000-0000-0000-000000000003" + const groupID = "00000000-0000-0000-0000-000000000004" + const knownConfig = `resource "coderd_ai_user_budget_override" "test" { + user_id = "` + userID + `" + group_id = "` + groupID + `" + spend_limit = 50000000 +} +` + for name, tc := range map[string]struct { + status int + body string + config string + wantError string + }{ + "different spend limit conflicts": { + status: http.StatusOK, + body: `{"user_id":"` + userID + `","group_id":"` + groupID + `","spend_limit_micros":1}`, + config: knownConfig, + wantError: `AI User Budget Override Already Exists`, + }, + "different group conflicts": { + status: http.StatusOK, + body: `{"user_id":"` + userID + `","group_id":"00000000-0000-0000-0000-000000000005","spend_limit_micros":50000000}`, + config: knownConfig, + wantError: `AI User Budget Override Already Exists`, + }, + "equal override is adopted": { + status: http.StatusOK, + body: `{"user_id":"` + userID + `","group_id":"` + groupID + `","spend_limit_micros":50000000}`, + config: knownConfig, + }, + "no override": { + status: http.StatusNotFound, + body: `{"message":"Resource not found or you do not have access to this resource"}`, + config: knownConfig, + }, + "unknown group defers the conflict check": { + status: http.StatusOK, + body: `{"user_id":"` + userID + `","group_id":"` + groupID + `","spend_limit_micros":1}`, + config: `resource "terraform_data" "group" { + input = "` + groupID + `" +} + +resource "coderd_ai_user_budget_override" "test" { + user_id = "` + userID + `" + group_id = terraform_data.group.output + spend_limit = 50000000 +} +`, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // The mock server answers the user and entitlements requests from Configure(). + mock := newMockServer(nil) + defer mock.Close() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/users/"+userID+"/ai/budget/override" { + mock.Config.Handler.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + step := resource.TestStep{ + Config: testAIUserBudgetOverrideConfig(srv.URL, tc.config), + PlanOnly: true, + ExpectNonEmptyPlan: true, + } + if tc.wantError != "" { + step.ExpectError = regexp.MustCompile(tc.wantError) + } + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{step}, + }) + }) + } +} + +func TestAccAIUserBudgetOverrideResource(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "ai_user_budget_override_acc", integration.UseLicense) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + orgID := me.OrganizationIDs[0] + + createUser := func(username string) codersdk.User { + user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: username + "@coder.com", + Username: username, + Password: "SomeSecurePassword!", + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{orgID}, + }) + require.NoError(t, err) + return user + } + createGroup := func(name string, members ...codersdk.User) codersdk.Group { + group, err := client.CreateGroup(ctx, orgID, codersdk.CreateGroupRequest{Name: name}) + require.NoError(t, err) + var memberIDs []string + for _, member := range members { + memberIDs = append(memberIDs, member.ID.String()) + } + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{AddUsers: memberIDs}) + require.NoError(t, err) + return group + } + user := createUser("tf-acc-override") + conflictUser := createUser("tf-acc-override-conflict") + group := createGroup("tf-acc-override", user, conflictUser) + otherGroup := createGroup("tf-acc-override-other", user) + nonMemberGroup := createGroup("tf-acc-override-non-member", conflictUser) + + cfg := func(groupID uuid.UUID, spendLimit int64) string { + return fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_ai_user_budget_override" "test" { + user_id = %q + group_id = %q + spend_limit = %d +} +`, client.URL.String(), client.SessionToken(), user.ID, groupID, spendLimit) + } + conflictCfg := func(spendLimit int64) string { + return fmt.Sprintf(` +resource "coderd_ai_user_budget_override" "conflict" { + user_id = %q + group_id = %q + spend_limit = %d +} +`, conflictUser.ID, group.ID, spendLimit) + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: func(*terraform.State) error { + for _, id := range []uuid.UUID{user.ID, conflictUser.ID} { + _, err := client.UserAIBudgetOverride(ctx, id) + if !isNotFound(err) { + return fmt.Errorf("expected the AI budget override for user %s to be deleted, got error: %v", id, err) + } + } + return nil + }, + Steps: []resource.TestStep{ + { + Config: cfg(group.ID, 50000000), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_ai_user_budget_override.test", "group_id", group.ID.String()), + resource.TestCheckResourceAttr("coderd_ai_user_budget_override.test", "spend_limit", "50000000"), + ), + }, + { + Config: cfg(otherGroup.ID, 0), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("coderd_ai_user_budget_override.test", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_ai_user_budget_override.test", "group_id", otherGroup.ID.String()), + resource.TestCheckResourceAttr("coderd_ai_user_budget_override.test", "spend_limit", "0"), + ), + }, + { + ResourceName: "coderd_ai_user_budget_override.test", + ImportState: true, + ImportStateId: user.ID.String(), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + }, + { + ResourceName: "coderd_ai_user_budget_override.test", + ImportState: true, + ImportStateId: user.Username, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + }, + { + Config: cfg(nonMemberGroup.ID, 0), + ExpectError: regexp.MustCompile("User is not a member of the referenced group"), + }, + { + // Terraform recreates an override that was deleted outside of Terraform. + PreConfig: func() { require.NoError(t, client.DeleteUserAIBudgetOverride(ctx, user.ID)) }, + Config: cfg(otherGroup.ID, 0), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("coderd_ai_user_budget_override.test", plancheck.ResourceActionCreate), + }, + }, + }, + { + PreConfig: func() { + _, err := client.UpsertUserAIBudgetOverride(ctx, conflictUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 1000000, + }) + require.NoError(t, err) + }, + Config: cfg(otherGroup.ID, 0) + conflictCfg(2000000), + PlanOnly: true, + ExpectError: regexp.MustCompile("AI User Budget Override Already Exists"), + }, + { + // Terraform adopts an existing override that equals the config. + Config: cfg(otherGroup.ID, 0) + conflictCfg(1000000), + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 024bf04..bb27fe3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -233,6 +233,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewAIProviderResource, NewAIModelPriceResource, NewAIGroupBudgetResource, + NewAIUserBudgetOverrideResource, NewAgentsMCPServerResource, NewAgentsModelResource, NewAgentsDefaultModelResource, diff --git a/templates/resources.md.tmpl b/templates/resources.md.tmpl index 58198df..238fc39 100644 --- a/templates/resources.md.tmpl +++ b/templates/resources.md.tmpl @@ -1,7 +1,7 @@ {{- $subcategory := "" -}} {{- if or (eq .Name "coderd_agents_default_model") (eq .Name "coderd_agents_mcp_server") (eq .Name "coderd_agents_model") (eq .Name "coderd_agents_system_prompt") -}} {{- $subcategory = "Agents" -}} -{{- else if or (eq .Name "coderd_ai_provider") (eq .Name "coderd_ai_model_price") (eq .Name "coderd_ai_group_budget") -}} +{{- else if or (eq .Name "coderd_ai_provider") (eq .Name "coderd_ai_model_price") (eq .Name "coderd_ai_group_budget") (eq .Name "coderd_ai_user_budget_override") -}} {{- $subcategory = "AI Governance" -}} {{- else if or (eq .Name "coderd_license") (eq .Name "coderd_oauth2_provider_settings") (eq .Name "coderd_provisioner_key") (eq .Name "coderd_workspace_proxy") -}} {{- $subcategory = "Deployment" -}}