diff --git a/docs/resources/ai_group_budget.md b/docs/resources/ai_group_budget.md new file mode 100644 index 0000000..8f41217 --- /dev/null +++ b/docs/resources/ai_group_budget.md @@ -0,0 +1,66 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_ai_group_budget Resource - terraform-provider-coderd" +subcategory: "AI Governance" +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. + -> 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. +--- + +# coderd_ai_group_budget (Resource) + +~> **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. + +-> 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. + +## Example Usage + +```terraform +resource "coderd_group" "engineering" { + name = "engineering" +} + +resource "coderd_ai_group_budget" "engineering" { + group_id = coderd_group.engineering.id + spend_limit = 50000000 // $50.00 for each member of the group +} +``` + + +## Schema + +### Required + +- `group_id` (String) ID of the group the budget applies to. Changing this forces a new resource. +- `spend_limit` (Number) Spend limit for each member of the group in each budget period, in integer micro-units: `1000000` is $1.00. A limit of `0` blocks AI Gateway requests from members whose effective group is this group. The maximum is `1000000000000` ($1,000,000.00). + +## 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 group UUID retrieved via the API +# or a fully qualified name: `/`. +$ terraform import coderd_ai_group_budget.example coder/engineering +``` +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_group_budget.example + id = "coder/engineering" +} +``` diff --git a/examples/resources/coderd_ai_group_budget/import.sh b/examples/resources/coderd_ai_group_budget/import.sh new file mode 100644 index 0000000..3872778 --- /dev/null +++ b/examples/resources/coderd_ai_group_budget/import.sh @@ -0,0 +1,11 @@ +# The ID supplied can be either a group UUID retrieved via the API +# or a fully qualified name: `/`. +$ terraform import coderd_ai_group_budget.example coder/engineering +``` +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_group_budget.example + id = "coder/engineering" +} diff --git a/examples/resources/coderd_ai_group_budget/resource.tf b/examples/resources/coderd_ai_group_budget/resource.tf new file mode 100644 index 0000000..422a37c --- /dev/null +++ b/examples/resources/coderd_ai_group_budget/resource.tf @@ -0,0 +1,8 @@ +resource "coderd_group" "engineering" { + name = "engineering" +} + +resource "coderd_ai_group_budget" "engineering" { + group_id = coderd_group.engineering.id + spend_limit = 50000000 // $50.00 for each member of the group +} diff --git a/internal/provider/ai_group_budget_resource.go b/internal/provider/ai_group_budget_resource.go new file mode 100644 index 0000000..72f0816 --- /dev/null +++ b/internal/provider/ai_group_budget_resource.go @@ -0,0 +1,288 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// aiGroupBudgetMinVersion is the first Coder release that this resource +// supports. +const aiGroupBudgetMinVersion = "2.36.0" + +var ( + _ resource.Resource = &AIGroupBudgetResource{} + _ resource.ResourceWithConfigure = &AIGroupBudgetResource{} + _ resource.ResourceWithImportState = &AIGroupBudgetResource{} + _ resource.ResourceWithModifyPlan = &AIGroupBudgetResource{} +) + +func NewAIGroupBudgetResource() resource.Resource { + return &AIGroupBudgetResource{} +} + +type AIGroupBudgetResource struct { + data *CoderdProviderData +} + +type AIGroupBudgetResourceModel struct { + GroupID UUID `tfsdk:"group_id"` + SpendLimit types.Int64 `tfsdk:"spend_limit"` +} + +// aiSpendLimitValidators bound a spend limit to the range that Coder accepts. +var aiSpendLimitValidators = []validator.Int64{ + int64validator.Between(0, codersdk.MaxAISpendLimitMicros), +} + +// aiGroupBudgetDiag converts an error from the group AI budget API into a +// diagnostic. The upsert call returns 404 only when the group does not exist, +// or when the deployment has no group AI budget API. +func aiGroupBudgetDiag(action string, groupID 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 for group %s: the deployment returned 404 for /api/v2/groups/%s/ai/budget. "+ + "Either the group does not exist, or the deployment is older than Coder version %s, which this resource requires. "+ + "Original error: %s", + action, groupID, groupID, aiGroupBudgetMinVersion, err)) + return diags + } + diags.AddError("Client Error", fmt.Sprintf("Unable to %s AI budget for group %s, got error: %s", action, groupID, err)) + return diags +} + +func aiGroupBudgetConflictDiag(groupID uuid.UUID) diag.Diagnostics { + var diags diag.Diagnostics + diags.AddError("AI Group Budget Already Exists", fmt.Sprintf( + "Coder already has an AI budget for group %s with a different spend limit. Terraform does not overwrite a budget that it does not manage. "+ + "To manage the existing budget, import it with the ID `%s`, then set the spend limit that you want.", + groupID, groupID)) + return diags +} + +// groupBudget returns nil, nil when the group has no AI budget. +func (r *AIGroupBudgetResource) groupBudget(ctx context.Context, groupID uuid.UUID) (*codersdk.GroupAIBudget, error) { + budget, err := r.data.Client.GroupAIBudget(ctx, groupID) + if isNotFound(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &budget, nil +} + +func (r *AIGroupBudgetResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_ai_group_budget" +} + +// ModifyPlan fails the plan when a create would overwrite a budget that +// Terraform does not manage. A create adopts an equal budget, so a retry after +// a partially failed apply succeeds. +func (r *AIGroupBudgetResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if req.Plan.Raw.IsNull() || r.data == nil { + return + } + var plan AIGroupBudgetResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() || plan.GroupID.IsUnknown() || plan.SpendLimit.IsUnknown() { + return + } + // Only a create or a replace writes a budget that Terraform does not manage yet. + if !req.State.Raw.IsNull() { + var state AIGroupBudgetResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() || state.GroupID.Equal(plan.GroupID) { + return + } + } + existing, err := r.groupBudget(ctx, plan.GroupID.ValueUUID()) + if err != nil { + // Best-effort. Create() repeats the check and reports the error. + tflog.Debug(ctx, "skipping AI group budget plan-time conflict check", map[string]any{ + "error": err.Error(), + }) + return + } + if existing != nil && existing.SpendLimitMicros != plan.SpendLimit.ValueInt64() { + resp.Diagnostics.Append(aiGroupBudgetConflictDiag(plan.GroupID.ValueUUID())...) + } +} + +func (r *AIGroupBudgetResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "~> **Warning**\nThis resource is only compatible with Coder version " + aiGroupBudgetMinVersion + " and later.\n\n" + + "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.\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" + + "-> 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), " + + "and spend already recorded stays attributed to this group. " + + "Coder also removes the budget when the group is deleted.", + Attributes: map[string]schema.Attribute{ + "group_id": schema.StringAttribute{ + MarkdownDescription: "ID of the group the budget applies to. Changing this forces a new resource.", + CustomType: UUIDType, + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "spend_limit": schema.Int64Attribute{ + MarkdownDescription: "Spend limit for each member of the group in each budget period, in integer micro-units: `1000000` is $1.00. " + + "A limit of `0` blocks AI Gateway requests from members whose effective group is this group. The maximum is `1000000000000` ($1,000,000.00).", + Required: true, + Validators: aiSpendLimitValidators, + }, + }, + } +} + +func (r *AIGroupBudgetResource) 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 *AIGroupBudgetResource) upsert(ctx context.Context, plan AIGroupBudgetResourceModel) error { + _, err := r.data.Client.UpsertGroupAIBudget(ctx, plan.GroupID.ValueUUID(), codersdk.UpsertGroupAIBudgetRequest{ + SpendLimitMicros: plan.SpendLimit.ValueInt64(), + }) + return err +} + +func (r *AIGroupBudgetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan AIGroupBudgetResourceModel + 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 + } + + groupID := plan.GroupID.ValueUUID() + existing, err := r.groupBudget(ctx, groupID) + if err != nil { + resp.Diagnostics.Append(aiGroupBudgetDiag("read", groupID, err)...) + return + } + if existing != nil && existing.SpendLimitMicros != plan.SpendLimit.ValueInt64() { + resp.Diagnostics.Append(aiGroupBudgetConflictDiag(groupID)...) + return + } + if err := r.upsert(ctx, plan); err != nil { + resp.Diagnostics.Append(aiGroupBudgetDiag("set", groupID, err)...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *AIGroupBudgetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state AIGroupBudgetResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + groupID := state.GroupID.ValueUUID() + budget, err := r.groupBudget(ctx, groupID) + if err != nil { + resp.Diagnostics.Append(aiGroupBudgetDiag("read", groupID, err)...) + return + } + if budget == nil { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("AI budget for group %s not found. Marking as deleted.", groupID)) + resp.State.RemoveResource(ctx) + return + } + + state.SpendLimit = types.Int64Value(budget.SpendLimitMicros) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *AIGroupBudgetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan AIGroupBudgetResourceModel + 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(aiGroupBudgetDiag("update", plan.GroupID.ValueUUID(), err)...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *AIGroupBudgetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state AIGroupBudgetResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + groupID := state.GroupID.ValueUUID() + err := r.data.Client.DeleteGroupAIBudget(ctx, groupID) + if err != nil && !isNotFound(err) { + resp.Diagnostics.Append(aiGroupBudgetDiag("delete", groupID, err)...) + } +} + +// ImportState accepts a group ID, or / like +// coderd_group. +func (r *AIGroupBudgetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + orgName, groupName, ok := strings.Cut(req.ID, "/") + if !ok { + groupID, err := uuid.Parse(req.ID) + if err != nil { + resp.Diagnostics.AddError("Invalid Import ID", fmt.Sprintf("Expected a group ID or /, got %q: %s", req.ID, err)) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("group_id"), groupID.String())...) + return + } + + org, err := r.data.Client.OrganizationByName(ctx, orgName) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get organization with name %s: %s", orgName, err)) + return + } + group, err := r.data.Client.GroupByOrgAndName(ctx, org.ID, groupName) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get group with name %s: %s", groupName, err)) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("group_id"), group.ID.String())...) +} diff --git a/internal/provider/ai_group_budget_resource_test.go b/internal/provider/ai_group_budget_resource_test.go new file mode 100644 index 0000000..7193171 --- /dev/null +++ b/internal/provider/ai_group_budget_resource_test.go @@ -0,0 +1,267 @@ +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 testAIGroupBudgetConfig(url, body string) string { + return `provider "coderd" { + url = "` + url + `" + token = "test-token" +} + +` + body +} + +func TestAccAIGroupBudgetResourceValidateConfig(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_group_budget" "test" { + group_id = "00000000-0000-0000-0000-000000000003" + spend_limit = -1 +} +`, + wantError: `must be between 0 and 1000000000000`, + }, + "spend limit above the maximum": { + body: `resource "coderd_ai_group_budget" "test" { + group_id = "00000000-0000-0000-0000-000000000003" + spend_limit = 1000000000001 +} +`, + wantError: `must be between 0 and 1000000000000`, + }, + "invalid group ID": { + body: `resource "coderd_ai_group_budget" "test" { + 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: testAIGroupBudgetConfig("http://127.0.0.1", tc.body), + ExpectError: regexp.MustCompile(tc.wantError), + }, + }, + }) + }) + } +} + +func TestAccAIGroupBudgetResourceModifyPlan(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + const groupID = "00000000-0000-0000-0000-000000000003" + const knownConfig = `resource "coderd_ai_group_budget" "test" { + group_id = "` + groupID + `" + spend_limit = 50000000 +} +` + for name, tc := range map[string]struct { + status int + body string + config string + wantError string + }{ + "different budget conflicts": { + status: http.StatusOK, + body: `{"group_id":"` + groupID + `","spend_limit_micros":1}`, + config: knownConfig, + wantError: `AI Group Budget Already Exists`, + }, + "equal budget is adopted": { + status: http.StatusOK, + body: `{"group_id":"` + groupID + `","spend_limit_micros":50000000}`, + config: knownConfig, + }, + "no budget": { + status: http.StatusNotFound, + body: `{"message":"Resource not found or you do not have access to this resource"}`, + config: knownConfig, + }, + "unknown spend limit defers the conflict check": { + status: http.StatusOK, + body: `{"group_id":"` + groupID + `","spend_limit_micros":1}`, + config: `resource "terraform_data" "limit" { + input = 50000000 +} + +resource "coderd_ai_group_budget" "test" { + group_id = "` + groupID + `" + spend_limit = terraform_data.limit.output +} +`, + }, + } { + 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/groups/"+groupID+"/ai/budget" { + 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: testAIGroupBudgetConfig(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 TestAccAIGroupBudgetResource(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_group_budget_acc", integration.UseLicense) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + org, err := client.Organization(ctx, me.OrganizationIDs[0]) + require.NoError(t, err) + + createGroup := func(name string) codersdk.Group { + group, err := client.CreateGroup(ctx, org.ID, codersdk.CreateGroupRequest{Name: name}) + require.NoError(t, err) + return group + } + group := createGroup("tf-acc-budget") + conflictGroup := createGroup("tf-acc-budget-conflict") + + cfg := func(spendLimit int64) string { + return fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_ai_group_budget" "test" { + group_id = %q + spend_limit = %d +} +`, client.URL.String(), client.SessionToken(), group.ID, spendLimit) + } + conflictCfg := func(spendLimit int64) string { + return fmt.Sprintf(` +resource "coderd_ai_group_budget" "conflict" { + group_id = %q + spend_limit = %d +} +`, conflictGroup.ID, spendLimit) + } + upsert := func(groupID uuid.UUID, spendLimit int64) { + _, err := client.UpsertGroupAIBudget(ctx, groupID, codersdk.UpsertGroupAIBudgetRequest{SpendLimitMicros: spendLimit}) + require.NoError(t, err) + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: func(*terraform.State) error { + for _, id := range []uuid.UUID{group.ID, conflictGroup.ID} { + _, err := client.GroupAIBudget(ctx, id) + if !isNotFound(err) { + return fmt.Errorf("expected the AI budget for group %s to be deleted, got error: %v", id, err) + } + } + return nil + }, + Steps: []resource.TestStep{ + { + Config: cfg(50000000), + Check: resource.TestCheckResourceAttr("coderd_ai_group_budget.test", "spend_limit", "50000000"), + }, + { + Config: cfg(0), + Check: resource.TestCheckResourceAttr("coderd_ai_group_budget.test", "spend_limit", "0"), + }, + { + ResourceName: "coderd_ai_group_budget.test", + ImportState: true, + ImportStateId: group.ID.String(), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "group_id", + }, + { + ResourceName: "coderd_ai_group_budget.test", + ImportState: true, + ImportStateId: org.Name + "/" + group.Name, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "group_id", + }, + { + // Terraform recreates a budget that was deleted outside of Terraform. + PreConfig: func() { require.NoError(t, client.DeleteGroupAIBudget(ctx, group.ID)) }, + Config: cfg(0), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("coderd_ai_group_budget.test", plancheck.ResourceActionCreate), + }, + }, + }, + { + PreConfig: func() { upsert(conflictGroup.ID, 1000000) }, + Config: cfg(0) + conflictCfg(2000000), + PlanOnly: true, + ExpectError: regexp.MustCompile("AI Group Budget Already Exists"), + }, + { + // Terraform adopts an existing budget that equals the config. + Config: cfg(0) + conflictCfg(1000000), + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 6dc8495..024bf04 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -232,6 +232,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewOrganizationGroupSyncResource, NewAIProviderResource, NewAIModelPriceResource, + NewAIGroupBudgetResource, NewAgentsMCPServerResource, NewAgentsModelResource, NewAgentsDefaultModelResource, diff --git a/templates/resources.md.tmpl b/templates/resources.md.tmpl index 7be527b..58198df 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") -}} +{{- else if or (eq .Name "coderd_ai_provider") (eq .Name "coderd_ai_model_price") (eq .Name "coderd_ai_group_budget") -}} {{- $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" -}}