From b2fd2853740b0bd500e0dca9777d14555707a877 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 08:16:35 +0000 Subject: [PATCH 01/13] feat!: scope coderd_agents_model to organizations --- docs/resources/agents_model.md | 16 ++- .../resources/coderd_agents_model/import.sh | 6 +- go.mod | 2 +- go.sum | 4 +- integration/integration_test.go | 10 +- internal/provider/agents_model_resource.go | 130 +++++++++++++----- .../provider/agents_model_resource_test.go | 109 ++++++++++++++- .../provider/default_agents_model_resource.go | 10 +- .../default_agents_model_resource_test.go | 25 ++-- 9 files changed, 240 insertions(+), 72 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index f083bd7..3ce3060 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -4,14 +4,19 @@ page_title: "coderd_agents_model Resource - terraform-provider-coderd" subcategory: "" description: |- ~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. - Configures an admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see coderd_ai_provider) along with context, compression, and optional JSON tuning settings. + ~> Warning + This resource is only compatible with Coder version 2.37.0 https://github.com/coder/coder/releases/tag/v2.37.0 and later. + Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see coderd_ai_provider) along with context, compression, and optional JSON tuning settings. Import IDs use /. --- # coderd_agents_model (Resource) ~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. -Configures an admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. +~> **Warning** +This resource is only compatible with Coder version [2.37.0](https://github.com/coder/coder/releases/tag/v2.37.0) and later. + +Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. Import IDs use `/`. ## Example Usage @@ -68,6 +73,7 @@ resource "coderd_agents_model" "sonnet" { - `display_name` (String) Display name shown in Coder. - `enabled` (Boolean) Whether this model configuration is enabled. Defaults to true. - `model_config` (String) Optional JSON blob of per-call tuning for the model, such as `max_output_tokens`, `temperature`, `top_p`, and `provider_options`. See the field reference (including per-provider `provider_options`) at https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig. +- `organization_id` (String) Organization ID that owns the Agents model configuration. Defaults to the provider default organization ID. ### Read-Only @@ -83,14 +89,14 @@ 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 must be the Agents model configuration UUID returned by Coder. -$ terraform import coderd_agents_model.sonnet +# The ID supplied must be the organization UUID and Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet / ``` 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_agents_model.sonnet - id = "" + id = "/" } ``` diff --git a/examples/resources/coderd_agents_model/import.sh b/examples/resources/coderd_agents_model/import.sh index 77d8614..b1b9f7a 100644 --- a/examples/resources/coderd_agents_model/import.sh +++ b/examples/resources/coderd_agents_model/import.sh @@ -1,10 +1,10 @@ -# The ID supplied must be the Agents model configuration UUID returned by Coder. -$ terraform import coderd_agents_model.sonnet +# The ID supplied must be the organization UUID and Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet / ``` 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_agents_model.sonnet - id = "" + id = "/" } diff --git a/go.mod b/go.mod index 496f669..5a67678 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.5 require ( cdr.dev/slog/v3 v3.1.0 - github.com/coder/coder/v2 v2.34.0-rc.0.0.20260819194623-24b83dc727e8 + github.com/coder/coder/v2 v2.34.0-rc.0.0.20260824124824-3c89b09734d3 github.com/coder/retry v1.5.1 github.com/coder/serpent v0.15.0 github.com/coder/websocket v1.8.15 diff --git a/go.sum b/go.sum index 27f62e6..b11e232 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/coder/coder/v2 v2.34.0-rc.0.0.20260819194623-24b83dc727e8 h1:mLAxnd8iaIvQm43EfILFLvIyCRnHILQskGgnkj6V/P8= -github.com/coder/coder/v2 v2.34.0-rc.0.0.20260819194623-24b83dc727e8/go.mod h1:pZvSpxDWkuVFXq6X/JvnW+BHPuEr1hc5VkwrJ8aJJng= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260824124824-3c89b09734d3 h1:z4LePNblIWBpRlspn7TI2CaJixFrIJcmWnmrRQLvJgs= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260824124824-3c89b09734d3/go.mod h1:pZvSpxDWkuVFXq6X/JvnW+BHPuEr1hc5VkwrJ8aJJng= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg= diff --git a/integration/integration_test.go b/integration/integration_test.go index bccb755..d18ac9e 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -207,8 +207,12 @@ func TestIntegration(t *testing.T) { require.NoError(t, err) require.Len(t, providers, 2) + organizations, err := c.Organizations(ctx) + require.NoError(t, err) + require.NotEmpty(t, organizations) + exp := codersdk.NewExperimentalClient(c) - configs, err := exp.ListChatModelConfigs(ctx) + configs, err := exp.ChatModels(ctx, organizations[0].ID) require.NoError(t, err) providerTypeByID := make(map[uuid.UUID]string, len(providers)) @@ -267,10 +271,10 @@ func TestIntegration(t *testing.T) { } }`}, } - require.Len(t, configs, len(want)) + require.Len(t, configs.Models, len(want)) var defaults []string - for _, m := range configs { + for _, m := range configs.Models { if m.IsDefault { defaults = append(defaults, m.Model) } diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index b5ed271..a47cd5c 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -6,11 +6,13 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -27,6 +29,10 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" ) +// agentsModelMinVersion is the first Coder release that includes the +// organization-scoped chat model API. +const agentsModelMinVersion = "2.37.0" + var ( _ resource.Resource = &AgentsModelResource{} _ resource.ResourceWithConfigure = &AgentsModelResource{} @@ -48,6 +54,7 @@ func (r *AgentsModelResource) experimentalClient() *codersdk.ExperimentalClient type AgentsModelResourceModel struct { ID UUID `tfsdk:"id"` + OrganizationID UUID `tfsdk:"organization_id"` AIProviderID UUID `tfsdk:"ai_provider_id"` ProviderType types.String `tfsdk:"provider_type"` Model types.String `tfsdk:"model"` @@ -74,7 +81,8 @@ func (r *AgentsModelResource) ModifyPlan(ctx context.Context, req resource.Modif func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments.\n\n" + - "Configures an admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings.", + "~> **Warning**\nThis resource is only compatible with Coder version [" + agentsModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + agentsModelMinVersion + ") and later.\n\n" + + "Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. Import IDs use `/`.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Agents model configuration ID.", @@ -84,6 +92,15 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq stringplanmodifier.UseStateForUnknown(), }, }, + "organization_id": schema.StringAttribute{ + MarkdownDescription: "Organization ID that owns the Agents model configuration. Defaults to the provider default organization ID.", + CustomType: UUIDType, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + }, "ai_provider_id": schema.StringAttribute{ MarkdownDescription: "AI provider ID that backs this model. Usually this is `coderd_ai_provider..id`. Updating it re-derives the read-only `provider_type` from the referenced provider.", CustomType: UUIDType, @@ -194,15 +211,19 @@ func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateReq if resp.Diagnostics.HasError() { return } + if plan.OrganizationID.IsNull() || plan.OrganizationID.IsUnknown() { + plan.OrganizationID = UUIDValue(r.data.DefaultOrganizationID) + } createReq := plan.createRequest(&resp.Diagnostics) if resp.Diagnostics.HasError() { return } tflog.Info(ctx, "creating Agents model") - modelConfig, err := r.createChatModelConfigWithRetry(ctx, createReq) + organizationID := plan.OrganizationID.ValueUUID() + modelConfig, err := r.createChatModelWithRetry(ctx, organizationID, createReq) if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create Agents model, got error: %s", err)) + resp.Diagnostics.Append(agentsModelCreateDiag(organizationID, err)...) return } @@ -217,7 +238,7 @@ func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateReq resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } -func (r *AgentsModelResource) lookupProviderType(ctx context.Context, config codersdk.ChatModelConfig, diags *diag.Diagnostics) string { +func (r *AgentsModelResource) lookupProviderType(ctx context.Context, config codersdk.ChatModel, diags *diag.Diagnostics) string { provider, err := r.data.Client.AIProvider(ctx, config.AIProviderID.String()) if err != nil { diags.AddError("Client Error", fmt.Sprintf("Unable to read AI provider %s to derive provider_type, got error: %s", config.AIProviderID, err)) @@ -226,30 +247,29 @@ func (r *AgentsModelResource) lookupProviderType(ctx context.Context, config cod return string(provider.Type) } -// createChatModelConfigWithRetry retries CreateChatModelConfig on the 409 -// default-election race (the only unique constraint is the single-default one, -// so a 409 can only be that race). Required until Linear CODAGT-736 is fixed -// server-side; remove this and call CreateChatModelConfig directly once it is. -func (r *AgentsModelResource) createChatModelConfigWithRetry(ctx context.Context, req codersdk.CreateChatModelConfigRequest) (codersdk.ChatModelConfig, error) { +// createChatModelWithRetry retries CreateChatModel on the 409 +// default-election race. coder/coder#27968 now serializes default election +// server-side, so the retry is likely vestigial but harmless. +func (r *AgentsModelResource) createChatModelWithRetry(ctx context.Context, organizationID uuid.UUID, req codersdk.CreateChatModelRequest) (codersdk.ChatModel, error) { const maxAttempts = 10 var lastErr error for attempt := 0; attempt < maxAttempts; attempt++ { - config, err := r.experimentalClient().CreateChatModelConfig(ctx, req) + config, err := r.experimentalClient().CreateChatModel(ctx, organizationID, req) if err == nil { return config, nil } var sdkErr *codersdk.Error if !errors.As(err, &sdkErr) || sdkErr.StatusCode() != http.StatusConflict { - return codersdk.ChatModelConfig{}, err + return codersdk.ChatModel{}, err } lastErr = err select { case <-ctx.Done(): - return codersdk.ChatModelConfig{}, ctx.Err() + return codersdk.ChatModel{}, ctx.Err() case <-time.After(time.Duration(attempt+1) * 100 * time.Millisecond): } } - return codersdk.ChatModelConfig{}, lastErr + return codersdk.ChatModel{}, lastErr } func (r *AgentsModelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { @@ -260,29 +280,26 @@ func (r *AgentsModelResource) Read(ctx context.Context, req resource.ReadRequest } modelConfigID := state.ID.ValueUUID() - configs, err := r.experimentalClient().ListChatModelConfigs(ctx) + config, err := r.experimentalClient().ChatModel(ctx, state.OrganizationID.ValueUUID(), modelConfigID) if err != nil { + if isNotFound(err) { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Agents model with ID %s not found. Marking as deleted.", modelConfigID.String())) + resp.State.RemoveResource(ctx) + return + } resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read Agents model, got error: %s", err)) return } - for _, config := range configs { - if config.ID == modelConfigID { - providerType := r.lookupProviderType(ctx, config, &resp.Diagnostics) - if resp.Diagnostics.HasError() { - return - } - refreshed := stateFromModelConfig(config, providerType, &resp.Diagnostics) - if resp.Diagnostics.HasError() { - return - } - resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...) - return - } + providerType := r.lookupProviderType(ctx, config, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return } - - resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Agents model with ID %s not found. Marking as deleted.", modelConfigID.String())) - resp.State.RemoveResource(ctx) + refreshed := stateFromModelConfig(config, providerType, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...) } func (r *AgentsModelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { @@ -298,7 +315,7 @@ func (r *AgentsModelResource) Update(ctx context.Context, req resource.UpdateReq } tflog.Info(ctx, "updating Agents model", map[string]any{"id": state.ID.ValueString()}) - modelConfig, err := r.experimentalClient().UpdateChatModelConfig(ctx, state.ID.ValueUUID(), updateReq) + modelConfig, err := r.experimentalClient().UpdateChatModel(ctx, state.OrganizationID.ValueUUID(), state.ID.ValueUUID(), updateReq) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update Agents model, got error: %s", err)) return @@ -323,19 +340,55 @@ func (r *AgentsModelResource) Delete(ctx context.Context, req resource.DeleteReq } tflog.Info(ctx, "deleting Agents model", map[string]any{"id": state.ID.ValueString()}) - if err := r.experimentalClient().DeleteChatModelConfig(ctx, state.ID.ValueUUID()); err != nil && !isNotFound(err) { + if err := r.experimentalClient().DeleteChatModel(ctx, state.OrganizationID.ValueUUID(), state.ID.ValueUUID()); err != nil && !isNotFound(err) { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete Agents model, got error: %s", err)) return } } func (r *AgentsModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + parts := strings.Split(req.ID, "/") + if len(parts) != 2 { + resp.Diagnostics.AddError("Invalid Import ID", "Expected `/`.") + return + } + organizationID, err := uuid.Parse(parts[0]) + if err != nil { + resp.Diagnostics.AddError("Invalid Import ID", fmt.Sprintf("Unable to parse organization ID as UUID: %s", err)) + return + } + id, err := uuid.Parse(parts[1]) + if err != nil { + resp.Diagnostics.AddError("Invalid Import ID", fmt.Sprintf("Unable to parse Agents model ID as UUID: %s", err)) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), organizationID.String())...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id.String())...) +} + +func agentsModelCreateDiag(organizationID uuid.UUID, err error) diag.Diagnostics { + var diags diag.Diagnostics + + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { + endpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID) + diags.AddError( + "Agents Model Endpoint Unavailable", + fmt.Sprintf("Unable to create the Agents model: the deployment returned 404 for %s. "+ + "This resource requires Coder version %s or later; upgrade the deployment, or remove "+ + "`coderd_agents_model` from your configuration. Original error: %s", + endpoint, agentsModelMinVersion, err), + ) + return diags + } + + diags.AddError("Client Error", fmt.Sprintf("Unable to create Agents model, got error: %s", err)) + return diags } -func (m AgentsModelResourceModel) createRequest(diags *diag.Diagnostics) codersdk.CreateChatModelConfigRequest { +func (m AgentsModelResourceModel) createRequest(diags *diag.Diagnostics) codersdk.CreateChatModelRequest { aiProviderID := m.AIProviderID.ValueUUID() - req := codersdk.CreateChatModelConfigRequest{ + req := codersdk.CreateChatModelRequest{ AIProviderID: &aiProviderID, Model: m.Model.ValueString(), DisplayName: m.DisplayName.ValueString(), @@ -347,8 +400,8 @@ func (m AgentsModelResourceModel) createRequest(diags *diag.Diagnostics) codersd return req } -func (m AgentsModelResourceModel) updateRequest(state AgentsModelResourceModel, diags *diag.Diagnostics) codersdk.UpdateChatModelConfigRequest { - var req codersdk.UpdateChatModelConfigRequest +func (m AgentsModelResourceModel) updateRequest(state AgentsModelResourceModel, diags *diag.Diagnostics) codersdk.UpdateChatModelRequest { + var req codersdk.UpdateChatModelRequest if !m.AIProviderID.Equal(state.AIProviderID) { aiProviderID := m.AIProviderID.ValueUUID() req.AIProviderID = &aiProviderID @@ -379,9 +432,10 @@ func (m AgentsModelResourceModel) updateRequest(state AgentsModelResourceModel, return req } -func stateFromModelConfig(config codersdk.ChatModelConfig, providerType string, diags *diag.Diagnostics) AgentsModelResourceModel { +func stateFromModelConfig(config codersdk.ChatModel, providerType string, diags *diag.Diagnostics) AgentsModelResourceModel { return AgentsModelResourceModel{ ID: UUIDValue(config.ID), + OrganizationID: UUIDValue(config.OrganizationID), AIProviderID: UUIDValue(config.AIProviderID), ProviderType: types.StringValue(providerType), Model: types.StringValue(config.Model), diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index a7ced45..59292cf 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "net/http" "os" "regexp" "testing" @@ -32,8 +34,10 @@ import ( func TestAgentsModelCreateRequest(t *testing.T) { t.Parallel() + organizationID := uuid.New() aiProviderID := uuid.New() plan := AgentsModelResourceModel{ + OrganizationID: UUIDValue(organizationID), AIProviderID: UUIDValue(aiProviderID), Model: types.StringValue("claude-3-5-sonnet-20241022"), DisplayName: types.StringValue("Claude 3.5 Sonnet"), @@ -65,6 +69,7 @@ func TestAgentsModelUpdateRequestClearsModelConfig(t *testing.T) { t.Parallel() state := AgentsModelResourceModel{ + OrganizationID: UUIDValue(uuid.New()), AIProviderID: UUIDValue(uuid.New()), Model: types.StringValue("claude-3-5-sonnet-20241022"), DisplayName: types.StringValue("Claude 3.5 Sonnet"), @@ -95,6 +100,7 @@ func TestAgentsModelUpdateRequestChangedFields(t *testing.T) { t.Parallel() state := AgentsModelResourceModel{ + OrganizationID: UUIDValue(uuid.New()), AIProviderID: UUIDValue(uuid.New()), Model: types.StringValue("claude-3-5-sonnet-20241022"), DisplayName: types.StringValue("Claude 3.5 Sonnet"), @@ -117,6 +123,7 @@ func TestAgentsModelStateFromModelConfig(t *testing.T) { t.Parallel() modelConfigID := uuid.New() + organizationID := uuid.New() aiProviderID := uuid.New() createdAt := time.Unix(1700000000, 0) updatedAt := time.Unix(1700000600, 0) @@ -127,8 +134,9 @@ func TestAgentsModelStateFromModelConfig(t *testing.T) { remote := decodeAgentsModelConfigForTest(t, `{"top_p":0.9,"max_output_tokens":9223372036854775807,"top_k":40}`) var diags diag.Diagnostics - state := stateFromModelConfig(codersdk.ChatModelConfig{ + state := stateFromModelConfig(codersdk.ChatModel{ ID: modelConfigID, + OrganizationID: organizationID, AIProviderID: aiProviderID, Model: "claude-3-5-sonnet-20241022", DisplayName: "Claude 3.5 Sonnet", @@ -141,6 +149,7 @@ func TestAgentsModelStateFromModelConfig(t *testing.T) { }, "anthropic", &diags) require.False(t, diags.HasError(), diags.Errors()) require.Equal(t, modelConfigID, state.ID.ValueUUID()) + require.Equal(t, organizationID, state.OrganizationID.ValueUUID()) require.Equal(t, aiProviderID, state.AIProviderID.ValueUUID()) require.Equal(t, "anthropic", state.ProviderType.ValueString()) require.Equal(t, "claude-3-5-sonnet-20241022", state.Model.ValueString()) @@ -456,6 +465,52 @@ resource "coderd_agents_model" "sonnet" { }) } +func TestAgentsModelResourcePlanDefersUnknownOrganizationID(t *testing.T) { + t.Parallel() + + // PlanOnly reaches provider Configure(), which fetches the current user + // and entitlements, so use a mock server instead of an unreachable URL. + srv := newMockServer(nil) + defer srv.Close() + + cfg := `provider "coderd" { + url = "` + srv.URL + `" + token = "test-token" +} + +variable "organization_id" { + type = string +} + +resource "terraform_data" "organization" { + input = var.organization_id +} + +resource "coderd_agents_model" "sonnet" { + organization_id = terraform_data.organization.output + ai_provider_id = "` + uuid.NewString() + `" + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 +} +` + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // terraform_data.output remains unknown in the initial plan even + // though ConfigVariables supplies a concrete input value. + Config: cfg, + ConfigVariables: config.Variables{ + "organization_id": config.StringVariable(uuid.NewString()), + }, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + }) +} + func TestAccAgentsModelResource(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { @@ -463,11 +518,19 @@ func TestAccAgentsModelResource(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_model_acc", integration.UseLicense) + skipUnlessAgentsModelEndpoint(ctx, t, client) + organization, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "agents-model-acc", + DisplayName: "Agents Model Acceptance", + }) + require.NoError(t, err, "create non-default organization") + t.Cleanup(func() { _ = client.DeleteOrganization(context.WithoutCancel(t.Context()), organization.ID.String()) }) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) cfg1 := testAccAgentsModelResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), + OrganizationID: organization.ID.String(), AIProviderID: aiProvider.ID.String(), Model: "claude-3-5-sonnet-20241022", DisplayName: "Claude 3.5 Sonnet", @@ -495,6 +558,7 @@ func TestAccAgentsModelResource(t *testing.T) { Config: cfg1.String(t), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("coderd_agents_model.sonnet", "id"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "organization_id", organization.ID.String()), resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "ai_provider_id", aiProvider.ID.String()), resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "provider_type", "anthropic"), resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "model", cfg1.Model), @@ -513,6 +577,13 @@ func TestAccAgentsModelResource(t *testing.T) { ResourceName: "coderd_agents_model.sonnet", ImportState: true, ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["coderd_agents_model.sonnet"] + if !ok { + return "", fmt.Errorf("coderd_agents_model.sonnet not found in state") + } + return rs.Primary.Attributes["organization_id"] + "/" + rs.Primary.ID, nil + }, // Coder serializes model_config fields in struct order while jsonencode sorts them // alphabetically, so ImportStateVerify's byte comparison can't match it. Compare it // semantically via ImportStateCheck instead. @@ -545,6 +616,7 @@ func TestAccAgentsModelResourceModelConfigNoDrift(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_model_drift_acc", integration.UseLicense) + skipUnlessAgentsModelEndpoint(ctx, t, client) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) cfg := fmt.Sprintf(` @@ -604,11 +676,13 @@ func TestAccAgentsModelResourceImportNoDrift(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_model_import_acc", integration.UseLicense) + skipUnlessAgentsModelEndpoint(ctx, t, client) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) // Create the model out-of-band so state is first populated by import (Read). + organizationID := accDefaultOrganizationID(ctx, t, client) exp := codersdk.NewExperimentalClient(client) - created, err := exp.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "claude-3-5-sonnet-20241022", ContextLimit: ptr.Ref(int64(200000)), @@ -619,7 +693,7 @@ func TestAccAgentsModelResourceImportNoDrift(t *testing.T) { }) require.NoError(t, err, "create chat model config out-of-band") // WithoutCancel: t.Context() is already cancelled by the time cleanup runs. - t.Cleanup(func() { _ = exp.DeleteChatModelConfig(context.WithoutCancel(t.Context()), created.ID) }) + t.Cleanup(func() { _ = exp.DeleteChatModel(context.WithoutCancel(t.Context()), organizationID, created.ID) }) cfg := fmt.Sprintf(` provider "coderd" { @@ -649,7 +723,7 @@ resource "coderd_agents_model" "sonnet" { Config: cfg, ResourceName: "coderd_agents_model.sonnet", ImportState: true, - ImportStateId: created.ID.String(), + ImportStateId: organizationID.String() + "/" + created.ID.String(), ImportStatePersist: true, }, { @@ -673,6 +747,7 @@ func TestAccAgentsModelResourceEmptyModelConfig(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_model_empty_acc", integration.UseLicense) + skipUnlessAgentsModelEndpoint(ctx, t, client) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) cfg := fmt.Sprintf(` @@ -706,6 +781,7 @@ resource "coderd_agents_model" "sonnet" { type testAccAgentsModelResourceConfig struct { URL string Token string + OrganizationID string AIProviderID string Model string DisplayName string @@ -724,6 +800,9 @@ provider "coderd" { } resource "coderd_agents_model" "sonnet" { +{{- if .OrganizationID }} + organization_id = "{{.OrganizationID}}" +{{- end }} ai_provider_id = "{{.AIProviderID}}" model = "{{.Model}}" display_name = "{{.DisplayName}}" @@ -753,6 +832,7 @@ func TestAccAgentsModelResourceProviderTypeRederive(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_model_provider_type_acc", integration.UseLicense) + skipUnlessAgentsModelEndpoint(ctx, t, client) anthropic := createAccAgentsModelAIProvider(ctx, t, client) openai := createAccAgentsModelAIProviderOfType(ctx, t, client, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -818,6 +898,27 @@ resource "coderd_agents_model" "sonnet" { }) } +func skipUnlessAgentsModelEndpoint(ctx context.Context, t *testing.T, client *codersdk.Client) { + t.Helper() + organizationID := accDefaultOrganizationID(ctx, t, client) + _, err := codersdk.NewExperimentalClient(client).ChatModels(ctx, organizationID) + if err != nil { + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { + t.Skipf("deployment does not support org-scoped chat models: %s", err) + } + require.NoError(t, err, "probe org-scoped chat models") + } +} + +func accDefaultOrganizationID(ctx context.Context, t *testing.T, client *codersdk.Client) uuid.UUID { + t.Helper() + organizations, err := client.Organizations(ctx) + require.NoError(t, err, "list organizations") + require.NotEmpty(t, organizations, "first user must belong to an organization") + return organizations[0].ID +} + func createAccAgentsModelAIProviderOfType(ctx context.Context, t *testing.T, client *codersdk.Client, req codersdk.CreateAIProviderRequest) codersdk.AIProvider { t.Helper() provider, err := client.CreateAIProvider(ctx, req) diff --git a/internal/provider/default_agents_model_resource.go b/internal/provider/default_agents_model_resource.go index 6ff7f0c..7f85eb4 100644 --- a/internal/provider/default_agents_model_resource.go +++ b/internal/provider/default_agents_model_resource.go @@ -118,13 +118,13 @@ func (r *DefaultAgentsModelResource) Read(ctx context.Context, req resource.Read return } - configs, err := r.experimentalClient().ListChatModelConfigs(ctx) + configs, err := r.experimentalClient().ChatModels(ctx, r.data.DefaultOrganizationID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read default Agents model, got error: %s", err)) return } - for _, config := range configs { + for _, config := range configs.Models { if config.IsDefault { state = stateFromDefaultModelConfig(config) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) @@ -135,7 +135,7 @@ func (r *DefaultAgentsModelResource) Read(ctx context.Context, req resource.Read // Coder keeps a default model whenever any model exists, so reaching here // means there are no models at all. Treat the pointer as deleted. resp.Diagnostics.AddWarning("Client Warning", - fmt.Sprintf("No default Agents model found among %d model config(s). Marking as deleted.", len(configs))) + fmt.Sprintf("No default Agents model found among %d model config(s). Marking as deleted.", len(configs.Models))) resp.State.RemoveResource(ctx) } @@ -171,7 +171,7 @@ func (r *DefaultAgentsModelResource) ImportState(ctx context.Context, req resour // Coder merges it into the existing model config and atomically demotes the // previous default. func (r *DefaultAgentsModelResource) setDefault(ctx context.Context, modelID uuid.UUID) (DefaultAgentsModelResourceModel, error) { - updated, err := r.experimentalClient().UpdateChatModelConfig(ctx, modelID, codersdk.UpdateChatModelConfigRequest{ + updated, err := r.experimentalClient().UpdateChatModel(ctx, r.data.DefaultOrganizationID, modelID, codersdk.UpdateChatModelRequest{ IsDefault: ptr.Ref(true), }) if err != nil { @@ -183,7 +183,7 @@ func (r *DefaultAgentsModelResource) setDefault(ctx context.Context, modelID uui // stateFromDefaultModelConfig maps the model config that Coder reports as the // default into resource state. The resource ID is a constant because the default // is a global singleton. -func stateFromDefaultModelConfig(config codersdk.ChatModelConfig) DefaultAgentsModelResourceModel { +func stateFromDefaultModelConfig(config codersdk.ChatModel) DefaultAgentsModelResourceModel { return DefaultAgentsModelResourceModel{ ID: types.StringValue(defaultAgentsModelID), ModelID: UUIDValue(config.ID), diff --git a/internal/provider/default_agents_model_resource_test.go b/internal/provider/default_agents_model_resource_test.go index 061da91..64cf315 100644 --- a/internal/provider/default_agents_model_resource_test.go +++ b/internal/provider/default_agents_model_resource_test.go @@ -20,7 +20,7 @@ func TestDefaultAgentsModelStateFromModelConfig(t *testing.T) { t.Parallel() id := uuid.New() - state := stateFromDefaultModelConfig(codersdk.ChatModelConfig{ID: id, IsDefault: true}) + state := stateFromDefaultModelConfig(codersdk.ChatModel{ID: id, IsDefault: true}) require.Equal(t, defaultAgentsModelID, state.ID.ValueString()) require.Equal(t, id, state.ModelID.ValueUUID()) require.Equal(t, id.String(), state.ModelID.ValueString()) @@ -159,10 +159,11 @@ func TestAccDefaultAgentsModelResourceDriftAndDelete(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "default_agents_model_drift_acc", integration.UseLicense) + organizationID := accDefaultOrganizationID(ctx, t, client) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) - sonnet := createAccChatModelConfig(ctx, t, client, aiProvider.ID, "claude-3-5-sonnet-20241022") - opus := createAccChatModelConfig(ctx, t, client, aiProvider.ID, "claude-3-opus-20240229") + sonnet := createAccChatModel(ctx, t, client, aiProvider.ID, "claude-3-5-sonnet-20241022") + opus := createAccChatModel(ctx, t, client, aiProvider.ID, "claude-3-opus-20240229") exp := codersdk.NewExperimentalClient(client) cfg := fmt.Sprintf(` @@ -204,7 +205,7 @@ resource "coderd_default_agents_model" "default" { // Externally re-point the default to opus, then expect Terraform to // detect the drift on refresh and plan to restore sonnet. PreConfig: func() { - _, err := exp.UpdateChatModelConfig(ctx, opus.ID, codersdk.UpdateChatModelConfigRequest{ + _, err := exp.UpdateChatModel(ctx, organizationID, opus.ID, codersdk.UpdateChatModelRequest{ IsDefault: ptr.Ref(true), }) require.NoError(t, err, "externally set opus as default") @@ -225,19 +226,20 @@ resource "coderd_default_agents_model" "default" { }) } -// createAccChatModelConfig creates a chat model config directly via the SDK so it +// createAccChatModel creates a chat model config directly via the SDK so it // exists independently of any Terraform-managed resource. -func createAccChatModelConfig(ctx context.Context, t *testing.T, client *codersdk.Client, aiProviderID uuid.UUID, model string) codersdk.ChatModelConfig { +func createAccChatModel(ctx context.Context, t *testing.T, client *codersdk.Client, aiProviderID uuid.UUID, model string) codersdk.ChatModel { t.Helper() + organizationID := accDefaultOrganizationID(ctx, t, client) exp := codersdk.NewExperimentalClient(client) - created, err := exp.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProviderID, Model: model, ContextLimit: ptr.Ref(int64(200000)), }) require.NoError(t, err, "create chat model config out-of-band") // WithoutCancel: t.Context() is already cancelled by the time cleanup runs. - t.Cleanup(func() { _ = exp.DeleteChatModelConfig(context.WithoutCancel(t.Context()), created.ID) }) + t.Cleanup(func() { _ = exp.DeleteChatModel(context.WithoutCancel(t.Context()), organizationID, created.ID) }) return created } @@ -245,11 +247,12 @@ func createAccChatModelConfig(ctx context.Context, t *testing.T, client *codersd // Coder enforces a single default, so a healthy deployment returns one ID. func serverDefaultModelIDs(ctx context.Context, t *testing.T, client *codersdk.Client) []uuid.UUID { t.Helper() + organizationID := accDefaultOrganizationID(ctx, t, client) exp := codersdk.NewExperimentalClient(client) - configs, err := exp.ListChatModelConfigs(ctx) - require.NoError(t, err, "list chat model configs") + configs, err := exp.ChatModels(ctx, organizationID) + require.NoError(t, err, "list chat models") var defaults []uuid.UUID - for _, c := range configs { + for _, c := range configs.Models { if c.IsDefault { defaults = append(defaults, c.ID) } From aaff7cf6f1652f2794e84a27b77e0cb0b1b9f780 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 12:22:49 +0000 Subject: [PATCH 02/13] fix(coderd_agents_model): recover legacy organization state --- internal/provider/agents_model_resource.go | 74 ++++++++++++++++++- .../provider/agents_model_resource_test.go | 30 ++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index a47cd5c..df98be6 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -52,6 +52,47 @@ func (r *AgentsModelResource) experimentalClient() *codersdk.ExperimentalClient return codersdk.NewExperimentalClient(r.data.Client) } +// legacyDefaultOrganizationChatModels uses the compatibility collection route +// retained for models created before chat model configurations became +// organization-scoped. It is only used to recover organization_id from legacy +// Terraform state; normal CRUD uses the organization-scoped SDK methods. +func legacyDefaultOrganizationChatModels(ctx context.Context, client *codersdk.Client) (codersdk.OrganizationChatModelsResponse, error) { + res, err := client.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil) + if err != nil { + return codersdk.OrganizationChatModelsResponse{}, err + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { + return codersdk.OrganizationChatModelsResponse{}, codersdk.ReadBodyAsError(res) + } + var configs codersdk.OrganizationChatModelsResponse + return configs, codersdk.ReadBodyAsJSON(res, &configs) +} + +func legacyDefaultOrganizationChatModel(ctx context.Context, client *codersdk.Client, modelID uuid.UUID) (codersdk.ChatModel, bool, error) { + configs, err := legacyDefaultOrganizationChatModels(ctx, client) + if err != nil { + return codersdk.ChatModel{}, false, err + } + for _, config := range configs.Models { + if config.ID == modelID { + return config, true, nil + } + } + return codersdk.ChatModel{}, false, nil +} + +func (r *AgentsModelResource) stateOrganizationID(ctx context.Context, value UUID, modelID uuid.UUID) (uuid.UUID, bool, error) { + if !value.IsNull() && !value.IsUnknown() { + return value.ValueUUID(), true, nil + } + config, found, err := legacyDefaultOrganizationChatModel(ctx, r.data.Client, modelID) + if err != nil || !found { + return uuid.Nil, found, err + } + return config.OrganizationID, true, nil +} + type AgentsModelResourceModel struct { ID UUID `tfsdk:"id"` OrganizationID UUID `tfsdk:"organization_id"` @@ -280,7 +321,17 @@ func (r *AgentsModelResource) Read(ctx context.Context, req resource.ReadRequest } modelConfigID := state.ID.ValueUUID() - config, err := r.experimentalClient().ChatModel(ctx, state.OrganizationID.ValueUUID(), modelConfigID) + organizationID, found, err := r.stateOrganizationID(ctx, state.OrganizationID, modelConfigID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy Agents model state, got error: %s", err)) + return + } + if !found { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Agents model with ID %s not found. Marking as deleted.", modelConfigID.String())) + resp.State.RemoveResource(ctx) + return + } + config, err := r.experimentalClient().ChatModel(ctx, organizationID, modelConfigID) if err != nil { if isNotFound(err) { resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Agents model with ID %s not found. Marking as deleted.", modelConfigID.String())) @@ -315,7 +366,16 @@ func (r *AgentsModelResource) Update(ctx context.Context, req resource.UpdateReq } tflog.Info(ctx, "updating Agents model", map[string]any{"id": state.ID.ValueString()}) - modelConfig, err := r.experimentalClient().UpdateChatModel(ctx, state.OrganizationID.ValueUUID(), state.ID.ValueUUID(), updateReq) + organizationID, found, err := r.stateOrganizationID(ctx, state.OrganizationID, state.ID.ValueUUID()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy Agents model state, got error: %s", err)) + return + } + if !found { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update Agents model %s because it no longer exists.", state.ID.ValueString())) + return + } + modelConfig, err := r.experimentalClient().UpdateChatModel(ctx, organizationID, state.ID.ValueUUID(), updateReq) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update Agents model, got error: %s", err)) return @@ -340,7 +400,15 @@ func (r *AgentsModelResource) Delete(ctx context.Context, req resource.DeleteReq } tflog.Info(ctx, "deleting Agents model", map[string]any{"id": state.ID.ValueString()}) - if err := r.experimentalClient().DeleteChatModel(ctx, state.OrganizationID.ValueUUID(), state.ID.ValueUUID()); err != nil && !isNotFound(err) { + organizationID, found, err := r.stateOrganizationID(ctx, state.OrganizationID, state.ID.ValueUUID()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy Agents model state, got error: %s", err)) + return + } + if !found { + return + } + if err := r.experimentalClient().DeleteChatModel(ctx, organizationID, state.ID.ValueUUID()); err != nil && !isNotFound(err) { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete Agents model, got error: %s", err)) return } diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 59292cf..f7c1b46 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "net/http" + "net/http/httptest" + "net/url" "os" "regexp" "testing" @@ -31,6 +33,34 @@ import ( "github.com/stretchr/testify/require" ) +func TestAgentsModelStateOrganizationIDLegacy(t *testing.T) { + t.Parallel() + + organizationID := uuid.New() + modelID := uuid.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/experimental/chats/models", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(codersdk.OrganizationChatModelsResponse{ + Models: []codersdk.ChatModel{{ + ID: modelID, + OrganizationID: organizationID, + }}, + })) + })) + t.Cleanup(srv.Close) + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + r := &AgentsModelResource{data: &CoderdProviderData{Client: codersdk.New(srvURL)}} + + got, found, err := r.stateOrganizationID(t.Context(), NewUUIDNull(), modelID) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, organizationID, got) +} + func TestAgentsModelCreateRequest(t *testing.T) { t.Parallel() From beaa567a91133578ba46a06c856a019a3a478533 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 12:34:41 +0000 Subject: [PATCH 03/13] test(coderd_default_agents_model): skip unsupported servers --- internal/provider/default_agents_model_resource_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/provider/default_agents_model_resource_test.go b/internal/provider/default_agents_model_resource_test.go index 64cf315..b4bffe4 100644 --- a/internal/provider/default_agents_model_resource_test.go +++ b/internal/provider/default_agents_model_resource_test.go @@ -74,6 +74,7 @@ func TestAccDefaultAgentsModelResource(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "default_agents_model_acc", integration.UseLicense) + skipUnlessAgentsModelEndpoint(ctx, t, client) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) cfg := func(defaultModel string) string { @@ -159,6 +160,7 @@ func TestAccDefaultAgentsModelResourceDriftAndDelete(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "default_agents_model_drift_acc", integration.UseLicense) + skipUnlessAgentsModelEndpoint(ctx, t, client) organizationID := accDefaultOrganizationID(ctx, t, client) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) From 0212f3dd8288f2f2b1838864173f9376d92db8f6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 14:43:48 +0000 Subject: [PATCH 04/13] fix(coderd_agents_model): disambiguate create 404s --- internal/provider/agents_model_resource.go | 38 ++++++-- .../provider/agents_model_resource_test.go | 96 +++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index df98be6..2473c5e 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -264,7 +264,7 @@ func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateReq organizationID := plan.OrganizationID.ValueUUID() modelConfig, err := r.createChatModelWithRetry(ctx, organizationID, createReq) if err != nil { - resp.Diagnostics.Append(agentsModelCreateDiag(organizationID, err)...) + resp.Diagnostics.Append(r.agentsModelCreateDiag(ctx, organizationID, err)...) return } @@ -434,23 +434,45 @@ func (r *AgentsModelResource) ImportState(ctx context.Context, req resource.Impo resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id.String())...) } -func agentsModelCreateDiag(organizationID uuid.UUID, err error) diag.Diagnostics { +func (r *AgentsModelResource) agentsModelCreateDiag(ctx context.Context, organizationID uuid.UUID, createErr error) diag.Diagnostics { var diags diag.Diagnostics var sdkErr *codersdk.Error - if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - endpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID) + if !errors.As(createErr, &sdkErr) || sdkErr.StatusCode() != http.StatusNotFound { + diags.AddError("Client Error", fmt.Sprintf("Unable to create Agents model, got error: %s", createErr)) + return diags + } + + createEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID) + probeEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", r.data.DefaultOrganizationID) + _, probeErr := r.experimentalClient().ChatModels(ctx, r.data.DefaultOrganizationID) + if probeErr == nil { + diags.AddError( + "Invalid Agents Model Organization", + fmt.Sprintf("Unable to create the Agents model: the deployment returned 404 for %s, but the same endpoint is available for the provider's default organization. "+ + "Organization %s may not exist or the token may not have access to it. Original error: %s", + createEndpoint, organizationID, createErr), + ) + return diags + } + + var probeSDKErr *codersdk.Error + if errors.As(probeErr, &probeSDKErr) && probeSDKErr.StatusCode() == http.StatusNotFound { diags.AddError( "Agents Model Endpoint Unavailable", - fmt.Sprintf("Unable to create the Agents model: the deployment returned 404 for %s. "+ + fmt.Sprintf("Unable to create the Agents model: the deployment returned 404 for %s, and the capability probe returned 404 for %s. "+ "This resource requires Coder version %s or later; upgrade the deployment, or remove "+ - "`coderd_agents_model` from your configuration. Original error: %s", - endpoint, agentsModelMinVersion, err), + "`coderd_agents_model` from your configuration. Original error: %s. Probe error: %s", + createEndpoint, probeEndpoint, agentsModelMinVersion, createErr, probeErr), ) return diags } - diags.AddError("Client Error", fmt.Sprintf("Unable to create Agents model, got error: %s", err)) + diags.AddError( + "Client Error", + fmt.Sprintf("Unable to create the Agents model, and unable to determine whether the endpoint is supported because the capability probe for %s also failed. Original error: %s. Probe error: %s", + probeEndpoint, createErr, probeErr), + ) return diags } diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index f7c1b46..616f59a 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -61,6 +61,102 @@ func TestAgentsModelStateOrganizationIDLegacy(t *testing.T) { require.Equal(t, organizationID, got) } +func TestAgentsModelCreateDiag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + probeStatus int + probeMessage string + wantSummary string + wantContains []string + wantNotContains []string + }{ + { + name: "supported endpoint identifies invalid organization", + probeStatus: http.StatusOK, + wantSummary: "Invalid Agents Model Organization", + wantContains: []string{ + "target organization not found", + "may not exist or the token may not have access", + }, + wantNotContains: []string{agentsModelMinVersion}, + }, + { + name: "probe 404 identifies unsupported endpoint", + probeStatus: http.StatusNotFound, + probeMessage: "endpoint not found", + wantSummary: "Agents Model Endpoint Unavailable", + wantContains: []string{ + "target organization not found", + "endpoint not found", + agentsModelMinVersion, + }, + }, + { + name: "other probe failure remains generic", + probeStatus: http.StatusInternalServerError, + probeMessage: "probe failed", + wantSummary: "Client Error", + wantContains: []string{ + "target organization not found", + "probe failed", + "unable to determine whether the endpoint is supported", + }, + wantNotContains: []string{agentsModelMinVersion}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + targetOrganizationID := uuid.New() + defaultOrganizationID := uuid.New() + targetEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) + probeEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodPost && req.URL.Path == targetEndpoint: + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(codersdk.Response{Message: "target organization not found"})) + case req.Method == http.MethodGet && req.URL.Path == probeEndpoint: + w.WriteHeader(tt.probeStatus) + if tt.probeStatus == http.StatusOK { + require.NoError(t, json.NewEncoder(w).Encode(codersdk.OrganizationChatModelsResponse{})) + return + } + require.NoError(t, json.NewEncoder(w).Encode(codersdk.Response{Message: tt.probeMessage})) + default: + http.NotFound(w, req) + } + })) + t.Cleanup(srv.Close) + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + r := &AgentsModelResource{data: &CoderdProviderData{ + Client: codersdk.New(srvURL), + DefaultOrganizationID: defaultOrganizationID, + }} + + _, createErr := r.experimentalClient().CreateChatModel(t.Context(), targetOrganizationID, codersdk.CreateChatModelRequest{}) + require.Error(t, createErr) + + diags := r.agentsModelCreateDiag(t.Context(), targetOrganizationID, createErr) + require.Len(t, diags, 1) + require.Equal(t, tt.wantSummary, diags[0].Summary()) + for _, want := range append(tt.wantContains, targetOrganizationID.String(), "Original error") { + require.Contains(t, diags[0].Detail(), want) + } + for _, notWant := range tt.wantNotContains { + require.NotContains(t, diags[0].Detail(), notWant) + } + }) + } +} + func TestAgentsModelCreateRequest(t *testing.T) { t.Parallel() From 123b020c88a2ed35b6c8033d9365daa3b81e18ba Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 15:39:18 +0000 Subject: [PATCH 05/13] test(integration): gate Agents models on Coder 2.37 --- integration/integration_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index d18ac9e..bceeed4 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -200,8 +200,9 @@ func TestIntegration(t *testing.T) { }, }, { - name: "agents-model-test", - preF: func(t testing.TB, c *codersdk.Client) {}, + name: "agents-model-test", + minVersion: "v2.37.0", + preF: func(t testing.TB, c *codersdk.Client) {}, assertF: func(t testing.TB, c *codersdk.Client) { providers, err := c.AIProviders(ctx) require.NoError(t, err) From 045b1d059a845be91b80a0e293e7467fcd8a59c4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 17:12:19 +0000 Subject: [PATCH 06/13] test: use testing contexts --- integration/headers_test.go | 4 ++-- integration/integration.go | 2 +- internal/provider/ai_provider_resource_test.go | 5 ++--- internal/provider/provider_headers_test.go | 5 ++--- internal/provider/template_resource_test.go | 4 ++-- internal/provider/wait_for_job_test.go | 10 +++++----- internal/provider/workspace_proxy_resource_test.go | 3 +-- 7 files changed, 15 insertions(+), 18 deletions(-) diff --git a/integration/headers_test.go b/integration/headers_test.go index 58b9940..73a7c80 100644 --- a/integration/headers_test.go +++ b/integration/headers_test.go @@ -71,7 +71,7 @@ func TestHeadersBypassRateLimit(t *testing.T) { t.Run("WithoutBypass", func(t *testing.T) { t.Parallel() - subCtx, subCancel := context.WithTimeout(context.Background(), 2*time.Minute) + subCtx, subCancel := context.WithTimeout(t.Context(), 2*time.Minute) defer subCancel() got429 := false @@ -90,7 +90,7 @@ func TestHeadersBypassRateLimit(t *testing.T) { t.Run("WithBypass", func(t *testing.T) { t.Parallel() - subCtx, subCancel := context.WithTimeout(context.Background(), 2*time.Minute) + subCtx, subCancel := context.WithTimeout(t.Context(), 2*time.Minute) defer subCancel() // Create a new client with the bypass header set. diff --git a/integration/integration.go b/integration/integration.go index ffd383f..a736aa0 100644 --- a/integration/integration.go +++ b/integration/integration.go @@ -186,7 +186,7 @@ func dumpContainerLogs(t *testing.T, ctr testcontainers.Container) { if ctr == nil { return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) defer cancel() logs, err := ctr.Logs(ctx) if err != nil { diff --git a/internal/provider/ai_provider_resource_test.go b/internal/provider/ai_provider_resource_test.go index ed57f0c..f70e04f 100644 --- a/internal/provider/ai_provider_resource_test.go +++ b/internal/provider/ai_provider_resource_test.go @@ -2,7 +2,6 @@ package provider import ( "bytes" - "context" "os" "regexp" "testing" @@ -820,7 +819,7 @@ func TestAIProviderUpdatePreservesBedrockCredentialsWhenVersionRemoved(t *testin // aiProviderPlanWithRoleARN builds a plan with only settings.bedrock.role_arn set. func aiProviderPlanWithRoleARN(t *testing.T, roleARN tftypes.Value) tfsdk.Plan { t.Helper() - ctx := context.Background() + ctx := t.Context() schemaResp := &fwresource.SchemaResponse{} (&AIProviderResource{}).Schema(ctx, fwresource.SchemaRequest{}, schemaResp) require.Empty(t, schemaResp.Diagnostics) @@ -874,7 +873,7 @@ func TestBedrockExternalIDPlanModifier(t *testing.T) { PlanValue: tc.planSeed, } resp := &planmodifier.StringResponse{PlanValue: req.PlanValue} - bedrockExternalIDPlanModifier{}.PlanModifyString(context.Background(), req, resp) + bedrockExternalIDPlanModifier{}.PlanModifyString(t.Context(), req, resp) require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics.Errors()) require.Equal(t, tc.want, resp.PlanValue) }) diff --git a/internal/provider/provider_headers_test.go b/internal/provider/provider_headers_test.go index 9ae7905..e78c2ef 100644 --- a/internal/provider/provider_headers_test.go +++ b/internal/provider/provider_headers_test.go @@ -1,7 +1,6 @@ package provider import ( - "context" "net/http" "net/http/httptest" "sync/atomic" @@ -43,11 +42,11 @@ func configureProvider(t *testing.T, cfgVal tftypes.Value) *provider.ConfigureRe t.Helper() p := &CoderdProvider{version: "test"} schemaResp := &provider.SchemaResponse{} - p.Schema(context.Background(), provider.SchemaRequest{}, schemaResp) + p.Schema(t.Context(), provider.SchemaRequest{}, schemaResp) require.Empty(t, schemaResp.Diagnostics) configResp := &provider.ConfigureResponse{} - p.Configure(context.Background(), provider.ConfigureRequest{ + p.Configure(t.Context(), provider.ConfigureRequest{ Config: tfsdkConfig(t, schemaResp.Schema, cfgVal), }, configResp) return configResp diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 63efe40..d396646 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -2521,7 +2521,7 @@ func versionObjectType() types.ObjectType { // fields the validator must succeed without diagnostics. func TestValidateListUnknownTFVars(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() objType := versionObjectType() versionVal, diags := types.ObjectValue(objType.AttrTypes, map[string]attr.Value{ @@ -2554,7 +2554,7 @@ func TestValidateListUnknownTFVars(t *testing.T) { // sensitive or unknown values. func TestUnknownTFVarsDeserialization(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() objType := versionObjectType() // buildAndDeserialize wraps a single version object (with the given tf_vars diff --git a/internal/provider/wait_for_job_test.go b/internal/provider/wait_for_job_test.go index 2dd2dba..cd3dc33 100644 --- a/internal/provider/wait_for_job_test.go +++ b/internal/provider/wait_for_job_test.go @@ -53,7 +53,7 @@ func TestWaitForJobOnce_Success(t *testing.T) { client := codersdk.New(srvURL) version := &codersdk.TemplateVersion{ID: versionID} - logs, done, err := waitForJobOnce(context.Background(), client, version, 0) + logs, done, err := waitForJobOnce(t.Context(), client, version, 0) require.NoError(t, err) require.True(t, done) require.Len(t, logs, 1) @@ -92,7 +92,7 @@ func TestWaitForJobOnce_JobFailed(t *testing.T) { client := codersdk.New(srvURL) version := &codersdk.TemplateVersion{ID: versionID} - _, done, err := waitForJobOnce(context.Background(), client, version, 0) + _, done, err := waitForJobOnce(t.Context(), client, version, 0) require.Error(t, err) require.False(t, done) require.Contains(t, err.Error(), "provisioner job did not succeed") @@ -130,7 +130,7 @@ func TestWaitForJobOnce_StillActive(t *testing.T) { client := codersdk.New(srvURL) version := &codersdk.TemplateVersion{ID: versionID} - _, done, err := waitForJobOnce(context.Background(), client, version, 0) + _, done, err := waitForJobOnce(t.Context(), client, version, 0) require.NoError(t, err) require.False(t, done) } @@ -187,7 +187,7 @@ func TestWaitForJob_UsesAfterCursorAcrossRetries(t *testing.T) { client := codersdk.New(srvURL) version := &codersdk.TemplateVersion{ID: versionID} - logs, err := waitForJob(context.Background(), client, version) + logs, err := waitForJob(t.Context(), client, version) require.NoError(t, err) require.Len(t, logs, 5) for i, log := range logs { @@ -205,7 +205,7 @@ func TestWaitForJob_UsesAfterCursorAcrossRetries(t *testing.T) { func TestWaitForJob_ContextCanceledDuringBackoff(t *testing.T) { t.Parallel() versionID := uuid.New() - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(t.Context()) t.Cleanup(cancel) var statusCallCount atomic.Int32 firstStatusSeen := make(chan struct{}, 1) diff --git a/internal/provider/workspace_proxy_resource_test.go b/internal/provider/workspace_proxy_resource_test.go index 2e5ed44..a755ca2 100644 --- a/internal/provider/workspace_proxy_resource_test.go +++ b/internal/provider/workspace_proxy_resource_test.go @@ -1,7 +1,6 @@ package provider import ( - "context" "os" "regexp" "strings" @@ -19,7 +18,7 @@ func TestWorkspaceProxyResourceSessionTokenSensitive(t *testing.T) { t.Parallel() resp := &fwresource.SchemaResponse{} - (&WorkspaceProxyResource{}).Schema(context.Background(), fwresource.SchemaRequest{}, resp) + (&WorkspaceProxyResource{}).Schema(t.Context(), fwresource.SchemaRequest{}, resp) require.Empty(t, resp.Diagnostics) attr := resp.Schema.Attributes["session_token"] From b11a6a2cb2454e68a4299c37c1460298cdd02743 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 09:59:50 +0000 Subject: [PATCH 07/13] chore(deps): bump Coder SDK to promoted chat routes --- go.mod | 4 +- go.sum | 8 +- internal/provider/agents_model_resource.go | 17 +- .../provider/agents_model_resource_test.go | 11 +- .../provider/agents_system_prompt_resource.go | 2 +- .../agents_system_prompt_resource_test.go | 2 +- .../provider/default_agents_model_resource.go | 3 +- .../default_agents_model_resource_test.go | 5 +- internal/provider/group_data_source_test.go | 11 +- internal/provider/group_resource_test.go | 27 +- .../oauth2_provider_settings_fake_test.go | 2 +- .../provider/organization_data_source_test.go | 11 +- .../organization_group_sync_resource_test.go | 7 +- .../provider/organization_resource_test.go | 27 +- .../provider/template_data_source_test.go | 62 ++-- internal/provider/template_resource.go | 5 +- internal/provider/template_resource_test.go | 291 +++++++++--------- internal/provider/user_data_source_test.go | 13 +- internal/provider/user_resource_test.go | 75 +++-- .../provider/workspace_proxy_resource_test.go | 17 +- 20 files changed, 292 insertions(+), 308 deletions(-) diff --git a/go.mod b/go.mod index 5a67678..145991b 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.5 require ( cdr.dev/slog/v3 v3.1.0 - github.com/coder/coder/v2 v2.34.0-rc.0.0.20260824124824-3c89b09734d3 + github.com/coder/coder/v2 v2.34.0-rc.0.0.20260827091847-26b9c8764f91 github.com/coder/retry v1.5.1 github.com/coder/serpent v0.15.0 github.com/coder/websocket v1.8.15 @@ -67,7 +67,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/speakeasy v0.2.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect - github.com/brianvoe/gofakeit/v7 v7.15.0 // indirect + github.com/brianvoe/gofakeit/v7 v7.16.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/go.sum b/go.sum index b11e232..49a2ff1 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/brianvoe/gofakeit/v7 v7.15.0 h1:kGLYAWN8tnmxq2PelKVK6zwpM7kMxdz9SGPH31mFkNs= -github.com/brianvoe/gofakeit/v7 v7.15.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= +github.com/brianvoe/gofakeit/v7 v7.16.0 h1:LXNcvT4Klw72/hqpLNNdEWFIcP7G0VFPNsqvEIGONBE= +github.com/brianvoe/gofakeit/v7 v7.16.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= @@ -136,8 +136,8 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/coder/coder/v2 v2.34.0-rc.0.0.20260824124824-3c89b09734d3 h1:z4LePNblIWBpRlspn7TI2CaJixFrIJcmWnmrRQLvJgs= -github.com/coder/coder/v2 v2.34.0-rc.0.0.20260824124824-3c89b09734d3/go.mod h1:pZvSpxDWkuVFXq6X/JvnW+BHPuEr1hc5VkwrJ8aJJng= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260827091847-26b9c8764f91 h1:J8Niy9QDHfWBcaH4gSAKulKhmOmKYbMJw+tVNx9CAm0= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260827091847-26b9c8764f91/go.mod h1:1meIub5i3H2wb2Vz1g61w0bvVHqDCFVAAOl6xGhfaWg= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg= diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index 2473c5e..c7f9c25 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" @@ -443,8 +442,8 @@ func (r *AgentsModelResource) agentsModelCreateDiag(ctx context.Context, organiz return diags } - createEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID) - probeEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", r.data.DefaultOrganizationID) + createEndpoint := fmt.Sprintf("/api/v2/organizations/%s/chats/models", organizationID) + probeEndpoint := fmt.Sprintf("/api/v2/organizations/%s/chats/models", r.data.DefaultOrganizationID) _, probeErr := r.experimentalClient().ChatModels(ctx, r.data.DefaultOrganizationID) if probeErr == nil { diags.AddError( @@ -482,9 +481,9 @@ func (m AgentsModelResourceModel) createRequest(diags *diag.Diagnostics) codersd AIProviderID: &aiProviderID, Model: m.Model.ValueString(), DisplayName: m.DisplayName.ValueString(), - Enabled: ptr.Ref(m.Enabled.ValueBool()), - ContextLimit: ptr.Ref(m.ContextLimit.ValueInt64()), - CompressionThreshold: ptr.Ref(int32(m.CompressionThreshold.ValueInt64())), + Enabled: new(m.Enabled.ValueBool()), + ContextLimit: new(m.ContextLimit.ValueInt64()), + CompressionThreshold: new(int32(m.CompressionThreshold.ValueInt64())), ModelConfig: agentsModelDecodeConfig(m.ModelConfig, diags), } return req @@ -503,13 +502,13 @@ func (m AgentsModelResourceModel) updateRequest(state AgentsModelResourceModel, req.DisplayName = m.DisplayName.ValueString() } if !m.Enabled.Equal(state.Enabled) { - req.Enabled = ptr.Ref(m.Enabled.ValueBool()) + req.Enabled = new(m.Enabled.ValueBool()) } if !m.ContextLimit.Equal(state.ContextLimit) { - req.ContextLimit = ptr.Ref(m.ContextLimit.ValueInt64()) + req.ContextLimit = new(m.ContextLimit.ValueInt64()) } if !m.CompressionThreshold.Equal(state.CompressionThreshold) { - req.CompressionThreshold = ptr.Ref(int32(m.CompressionThreshold.ValueInt64())) + req.CompressionThreshold = new(int32(m.CompressionThreshold.ValueInt64())) } if !m.ModelConfig.Equal(state.ModelConfig) { if m.ModelConfig.IsNull() { diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 616f59a..845d030 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -15,7 +15,6 @@ import ( "text/template" "time" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" @@ -113,8 +112,8 @@ func TestAgentsModelCreateDiag(t *testing.T) { targetOrganizationID := uuid.New() defaultOrganizationID := uuid.New() - targetEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) - probeEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) + targetEndpoint := fmt.Sprintf("/api/v2/organizations/%s/chats/models", targetOrganizationID) + probeEndpoint := fmt.Sprintf("/api/v2/organizations/%s/chats/models", defaultOrganizationID) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") switch { @@ -811,10 +810,10 @@ func TestAccAgentsModelResourceImportNoDrift(t *testing.T) { created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, Model: "claude-3-5-sonnet-20241022", - ContextLimit: ptr.Ref(int64(200000)), + ContextLimit: new(int64(200000)), ModelConfig: &codersdk.ChatModelCallConfig{ - TopP: ptr.Ref(0.9), - TopK: ptr.Ref(int64(40)), + TopP: new(0.9), + TopK: new(int64(40)), }, }) require.NoError(t, err, "create chat model config out-of-band") diff --git a/internal/provider/agents_system_prompt_resource.go b/internal/provider/agents_system_prompt_resource.go index ef551a3..a3f29e6 100644 --- a/internal/provider/agents_system_prompt_resource.go +++ b/internal/provider/agents_system_prompt_resource.go @@ -285,7 +285,7 @@ func agentsSystemPromptDiag(action string, err error) diag.Diagnostics { "This endpoint requires Coder version %s or later and a token with site-wide permissions; "+ "upgrade the deployment or use a token with the required permissions. If neither is possible, "+ "remove `coderd_agents_system_prompt` from your configuration. Original error: %s", - action, "/api/experimental/chats/config/system-prompt", agentsSystemPromptMinVersion, err), + action, "/api/v2/chats/config/system-prompt", agentsSystemPromptMinVersion, err), ) return diags } diff --git a/internal/provider/agents_system_prompt_resource_test.go b/internal/provider/agents_system_prompt_resource_test.go index 1011628..a6bcfcb 100644 --- a/internal/provider/agents_system_prompt_resource_test.go +++ b/internal/provider/agents_system_prompt_resource_test.go @@ -28,7 +28,7 @@ import ( "github.com/stretchr/testify/require" ) -const agentsSystemPromptPath = "/api/experimental/chats/config/system-prompt" +const agentsSystemPromptPath = "/api/v2/chats/config/system-prompt" const agentsSystemPromptResourceAddr = "coderd_agents_system_prompt.test" diff --git a/internal/provider/default_agents_model_resource.go b/internal/provider/default_agents_model_resource.go index 7f85eb4..426a17a 100644 --- a/internal/provider/default_agents_model_resource.go +++ b/internal/provider/default_agents_model_resource.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/path" @@ -172,7 +171,7 @@ func (r *DefaultAgentsModelResource) ImportState(ctx context.Context, req resour // previous default. func (r *DefaultAgentsModelResource) setDefault(ctx context.Context, modelID uuid.UUID) (DefaultAgentsModelResourceModel, error) { updated, err := r.experimentalClient().UpdateChatModel(ctx, r.data.DefaultOrganizationID, modelID, codersdk.UpdateChatModelRequest{ - IsDefault: ptr.Ref(true), + IsDefault: new(true), }) if err != nil { return DefaultAgentsModelResourceModel{}, err diff --git a/internal/provider/default_agents_model_resource_test.go b/internal/provider/default_agents_model_resource_test.go index b4bffe4..60f9719 100644 --- a/internal/provider/default_agents_model_resource_test.go +++ b/internal/provider/default_agents_model_resource_test.go @@ -6,7 +6,6 @@ import ( "os" "testing" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" @@ -208,7 +207,7 @@ resource "coderd_default_agents_model" "default" { // detect the drift on refresh and plan to restore sonnet. PreConfig: func() { _, err := exp.UpdateChatModel(ctx, organizationID, opus.ID, codersdk.UpdateChatModelRequest{ - IsDefault: ptr.Ref(true), + IsDefault: new(true), }) require.NoError(t, err, "externally set opus as default") }, @@ -237,7 +236,7 @@ func createAccChatModel(ctx context.Context, t *testing.T, client *codersdk.Clie created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProviderID, Model: model, - ContextLimit: ptr.Ref(int64(200000)), + ContextLimit: new(int64(200000)), }) require.NoError(t, err, "create chat model config out-of-band") // WithoutCancel: t.Context() is already cancelled by the time cleanup runs. diff --git a/internal/provider/group_data_source_test.go b/internal/provider/group_data_source_test.go index 2476fb1..8edbfd5 100644 --- a/internal/provider/group_data_source_test.go +++ b/internal/provider/group_data_source_test.go @@ -7,7 +7,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -75,7 +74,7 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: ptr.Ref(group.ID.String()), + ID: new(group.ID.String()), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -93,8 +92,8 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - OrganizationID: ptr.Ref(firstUser.OrganizationIDs[0].String()), - Name: ptr.Ref("example-group"), + OrganizationID: new(firstUser.OrganizationIDs[0].String()), + Name: new("example-group"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -112,7 +111,7 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-group"), + Name: new("example-group"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -130,7 +129,7 @@ func TestAccGroupDataSource(t *testing.T) { cfg := testAccGroupDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - OrganizationID: ptr.Ref(firstUser.OrganizationIDs[0].String()), + OrganizationID: new(firstUser.OrganizationIDs[0].String()), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, diff --git a/internal/provider/group_resource_test.go b/internal/provider/group_resource_test.go index 7329cc1..bf3047d 100644 --- a/internal/provider/group_resource_test.go +++ b/internal/provider/group_resource_test.go @@ -7,7 +7,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -45,17 +44,17 @@ func TestAccGroupResource(t *testing.T) { cfg1 := testAccGroupResourceconfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-group"), - DisplayName: ptr.Ref("Example Group"), - AvatarUrl: ptr.Ref("https://google.com"), - QuotaAllowance: ptr.Ref(int32(100)), - Members: ptr.Ref([]string{user1.ID.String()}), + Name: new("example-group"), + DisplayName: new("Example Group"), + AvatarUrl: new("https://google.com"), + QuotaAllowance: new(int32(100)), + Members: new([]string{user1.ID.String()}), } cfg2 := cfg1 - cfg2.Name = ptr.Ref("example-group-new") - cfg2.DisplayName = ptr.Ref("Example Group New") - cfg2.Members = ptr.Ref([]string{user2.ID.String()}) + cfg2.Name = new("example-group-new") + cfg2.DisplayName = new("Example Group New") + cfg2.Members = new([]string{user2.ID.String()}) cfg3 := cfg2 cfg3.Members = nil @@ -145,11 +144,11 @@ func TestAccGroupResourceAGPL(t *testing.T) { cfg1 := testAccGroupResourceconfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-group"), - DisplayName: ptr.Ref("Example Group"), - AvatarUrl: ptr.Ref("https://google.com"), - QuotaAllowance: ptr.Ref(int32(100)), - Members: ptr.Ref([]string{firstUser.ID.String()}), + Name: new("example-group"), + DisplayName: new("Example Group"), + AvatarUrl: new("https://google.com"), + QuotaAllowance: new(int32(100)), + Members: &[]string{firstUser.ID.String()}, } resource.Test(t, resource.TestCase{ diff --git a/internal/provider/oauth2_provider_settings_fake_test.go b/internal/provider/oauth2_provider_settings_fake_test.go index 9d959f3..af7bd4e 100644 --- a/internal/provider/oauth2_provider_settings_fake_test.go +++ b/internal/provider/oauth2_provider_settings_fake_test.go @@ -60,7 +60,7 @@ type fakeCoderd struct { putEcho *bool // putEchoOmit makes a successful PUT answer with the field absent. The real - // endpoint always sets it (`ptr.Ref(resolvedEnabled)`), so this is a + // endpoint always sets it (`new(resolvedEnabled)`), so this is a // contract violation by construction -- it exists to pin down which value // the provider falls back to, which is the difference between a clean apply // and a spurious "inconsistent result after apply". diff --git a/internal/provider/organization_data_source_test.go b/internal/provider/organization_data_source_test.go index b73aa37..8a2e8b5 100644 --- a/internal/provider/organization_data_source_test.go +++ b/internal/provider/organization_data_source_test.go @@ -7,7 +7,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -79,7 +78,7 @@ func runOrganizationDataSourceTests(t *testing.T, client *codersdk.Client, first cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: ptr.Ref(firstUser.OrganizationIDs[0].String()), + ID: new(firstUser.OrganizationIDs[0].String()), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -97,7 +96,7 @@ func runOrganizationDataSourceTests(t *testing.T, client *codersdk.Client, first cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("coder"), + Name: new("coder"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -115,7 +114,7 @@ func runOrganizationDataSourceTests(t *testing.T, client *codersdk.Client, first cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - IsDefault: ptr.Ref(true), + IsDefault: new(true), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -133,8 +132,8 @@ func runOrganizationDataSourceTests(t *testing.T, client *codersdk.Client, first cfg := testAccOrganizationDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - IsDefault: ptr.Ref(true), - Name: ptr.Ref("coder"), + IsDefault: new(true), + Name: new("coder"), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, diff --git a/internal/provider/organization_group_sync_resource_test.go b/internal/provider/organization_group_sync_resource_test.go index 1e5b1c9..3572b55 100644 --- a/internal/provider/organization_group_sync_resource_test.go +++ b/internal/provider/organization_group_sync_resource_test.go @@ -7,7 +7,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -45,8 +44,8 @@ func TestAccOrganizationGroupSyncResource(t *testing.T) { cfg2 := cfg1 cfg2.Field = "updated_groups" - cfg2.RegexFilter = ptr.Ref(".*test.*") - cfg2.AutoCreateMissing = ptr.Ref(true) + cfg2.RegexFilter = new(".*test.*") + cfg2.AutoCreateMissing = new(true) cfg2.Mapping = map[string][]string{ "test_group": {"6e57187f-6543-46ab-a62c-a10065dd4314"}, } @@ -130,7 +129,7 @@ func TestAccOrganizationGroupSyncResource(t *testing.T) { Token: client.SessionToken(), OrganizationID: org.ID.String(), Field: "invalid_regex", - RegexFilter: ptr.Ref("[invalid"), + RegexFilter: new("[invalid"), Mapping: map[string][]string{}, } resource.Test(t, resource.TestCase{ diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index c850beb..2c747af 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -6,7 +6,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" @@ -70,15 +69,15 @@ func runOrganizationResourceTest(t *testing.T, client *codersdk.Client, enableEx cfg1 := testAccOrganizationResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-org"), - DisplayName: ptr.Ref("Example Organization"), - Description: ptr.Ref("This is an example organization"), - Icon: ptr.Ref("/icon/coder.svg"), + Name: new("example-org"), + DisplayName: new("Example Organization"), + Description: new("This is an example organization"), + Icon: new("/icon/coder.svg"), } cfg2 := cfg1 - cfg2.Name = ptr.Ref("example-org-new") - cfg2.DisplayName = ptr.Ref("Example Organization New") + cfg2.Name = new("example-org-new") + cfg2.DisplayName = new("Example Organization New") cfg3 := cfg2 cfg3.OrgSyncIdpGroups = []string{"wibble", "wobble"} @@ -87,13 +86,13 @@ func runOrganizationResourceTest(t *testing.T, client *codersdk.Client, enableEx cfg4.OrgSyncIdpGroups = []string{"wibbley", "wobbley"} cfg5 := cfg4 - cfg5.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ + cfg5.GroupSync = new(codersdk.GroupSyncSettings{ Field: "wibble", Mapping: map[string][]uuid.UUID{ "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, }, }) - cfg5.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ + cfg5.RoleSync = new(codersdk.RoleSyncSettings{ Field: "wobble", Mapping: map[string][]string{ "wobble": {"wobbly"}, @@ -161,10 +160,10 @@ func runOrganizationResourceTest(t *testing.T, client *codersdk.Client, enableEx } if enableExperimentalSteps { cfg6 := cfg5 - cfg6.WorkspaceSharing = ptr.Ref("none") + cfg6.WorkspaceSharing = new("none") cfg7 := cfg6 - cfg7.WorkspaceSharing = ptr.Ref("everyone") + cfg7.WorkspaceSharing = new("everyone") steps = append(steps, // Disable workspace sharing for org @@ -192,9 +191,9 @@ func runOrganizationResourceTest(t *testing.T, client *codersdk.Client, enableEx cfg1 := testAccOrganizationResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-org"), - Description: ptr.Ref("This is an example organization"), - Icon: ptr.Ref("/icon/coder.svg"), + Name: new("example-org"), + Description: new("This is an example organization"), + Icon: new("/icon/coder.svg"), } resource.Test(t, resource.TestCase{ IsUnitTest: true, diff --git a/internal/provider/template_data_source_test.go b/internal/provider/template_data_source_test.go index 09ce4c3..9b32d58 100644 --- a/internal/provider/template_data_source_test.go +++ b/internal/provider/template_data_source_test.go @@ -9,14 +9,12 @@ import ( "text/template" "time" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/stretchr/testify/require" "golang.org/x/mod/semver" - - "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/terraform-provider-coderd/integration" ) func TestAccTemplateDataSource(t *testing.T) { @@ -55,8 +53,8 @@ func TestAccTemplateDataSource(t *testing.T) { Description: "An example template", Icon: "/path/to/icon.png", VersionID: version.ID, - DefaultTTLMillis: ptr.Ref((10 * time.Hour).Milliseconds()), - ActivityBumpMillis: ptr.Ref((4 * time.Hour).Milliseconds()), + DefaultTTLMillis: new((10 * time.Hour).Milliseconds()), + ActivityBumpMillis: new((4 * time.Hour).Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: []string{"sunday"}, Weeks: 1, @@ -64,26 +62,26 @@ func TestAccTemplateDataSource(t *testing.T) { AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, }, - AllowUserCancelWorkspaceJobs: ptr.Ref(true), - AllowUserAutostart: ptr.Ref(true), - AllowUserAutostop: ptr.Ref(true), - FailureTTLMillis: ptr.Ref((1 * time.Hour).Milliseconds()), - TimeTilDormantMillis: ptr.Ref((7 * 24 * time.Hour).Milliseconds()), - TimeTilDormantAutoDeleteMillis: ptr.Ref((30 * 24 * time.Hour).Milliseconds()), + AllowUserCancelWorkspaceJobs: new(true), + AllowUserAutostart: new(true), + AllowUserAutostop: new(true), + FailureTTLMillis: new((1 * time.Hour).Milliseconds()), + TimeTilDormantMillis: new((7 * 24 * time.Hour).Milliseconds()), + TimeTilDormantAutoDeleteMillis: new((30 * 24 * time.Hour).Milliseconds()), DisableEveryoneGroupAccess: true, RequireActiveVersion: true, - AgentsAllowed: ptr.Ref(false), + AgentsAllowed: new(false), }) require.NoError(t, err) // Can't set some fields on create, like deprecated. tpl, err = client.UpdateTemplateMeta(ctx, tpl.ID, codersdk.UpdateTemplateMeta{ - Name: ptr.Ref(tpl.Name), + Name: new(tpl.Name), DisplayName: &tpl.DisplayName, Description: &tpl.Description, Icon: &tpl.Icon, - DefaultTTLMillis: ptr.Ref(tpl.DefaultTTLMillis), - ActivityBumpMillis: ptr.Ref(tpl.ActivityBumpMillis), + DefaultTTLMillis: new(tpl.DefaultTTLMillis), + ActivityBumpMillis: new(tpl.ActivityBumpMillis), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: tpl.AutostopRequirement.DaysOfWeek, Weeks: tpl.AutostopRequirement.Weeks, @@ -91,18 +89,18 @@ func TestAccTemplateDataSource(t *testing.T) { AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: tpl.AutostartRequirement.DaysOfWeek, }, - AllowUserAutostart: ptr.Ref(tpl.AllowUserAutostart), - AllowUserAutostop: ptr.Ref(tpl.AllowUserAutostop), - AllowUserCancelWorkspaceJobs: ptr.Ref(tpl.AllowUserCancelWorkspaceJobs), - FailureTTLMillis: ptr.Ref(tpl.FailureTTLMillis), - TimeTilDormantMillis: ptr.Ref(tpl.TimeTilDormantMillis), - TimeTilDormantAutoDeleteMillis: ptr.Ref(tpl.TimeTilDormantAutoDeleteMillis), - UpdateWorkspaceLastUsedAt: ptr.Ref(false), - UpdateWorkspaceDormantAt: ptr.Ref(false), - RequireActiveVersion: ptr.Ref(tpl.RequireActiveVersion), - DeprecationMessage: ptr.Ref("This template is deprecated"), - DisableEveryoneGroupAccess: ptr.Ref(true), - MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevelOwner), + AllowUserAutostart: new(tpl.AllowUserAutostart), + AllowUserAutostop: new(tpl.AllowUserAutostop), + AllowUserCancelWorkspaceJobs: new(tpl.AllowUserCancelWorkspaceJobs), + FailureTTLMillis: new(tpl.FailureTTLMillis), + TimeTilDormantMillis: new(tpl.TimeTilDormantMillis), + TimeTilDormantAutoDeleteMillis: new(tpl.TimeTilDormantAutoDeleteMillis), + UpdateWorkspaceLastUsedAt: new(false), + UpdateWorkspaceDormantAt: new(false), + RequireActiveVersion: new(tpl.RequireActiveVersion), + DeprecationMessage: new("This template is deprecated"), + DisableEveryoneGroupAccess: new(true), + MaxPortShareLevel: new(codersdk.WorkspaceAgentPortShareLevelOwner), }) require.NoError(t, err) @@ -165,8 +163,8 @@ func TestAccTemplateDataSource(t *testing.T) { cfg := testAccTemplateDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - OrganizationID: ptr.Ref(orgID.String()), - Name: ptr.Ref(tpl.Name), + OrganizationID: new(orgID.String()), + Name: new(tpl.Name), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -185,7 +183,7 @@ func TestAccTemplateDataSource(t *testing.T) { cfg := testAccTemplateDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: ptr.Ref(tpl.ID.String()), + ID: new(tpl.ID.String()), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -222,7 +220,7 @@ func TestAccTemplateDataSource(t *testing.T) { cfg := testAccTemplateDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref(tpl.Name), + Name: new(tpl.Name), } resource.Test(t, resource.TestCase{ IsUnitTest: true, diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index adbae5d..e9b1721 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -11,7 +11,6 @@ import ( "time" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/retry" @@ -1516,12 +1515,12 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(), RequireActiveVersion: r.RequireActiveVersion.ValueBoolPointer(), DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), - MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), + MaxPortShareLevel: new(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), CORSBehavior: corsPtr(r.CORSBehavior), UseClassicParameterFlow: boolPtrOrNil(r.UseClassicParameterFlow), AgentsAllowed: boolPtrOrNil(r.AgentsAllowed), // If we're managing ACL, we want to delete the everyone group. - DisableEveryoneGroupAccess: ptr.Ref(!r.ACL.IsNull()), + DisableEveryoneGroupAccess: new(!r.ACL.IsNull()), } } diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index d396646..503d523 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -10,6 +10,8 @@ import ( "testing" "text/template" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -22,10 +24,6 @@ import ( cp "github.com/otiai10/copy" "github.com/stretchr/testify/require" "golang.org/x/mod/semver" - - "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/terraform-provider-coderd/integration" ) // mustVariablesToSet converts a []Variable to a types.Set for use in tests, @@ -87,25 +85,25 @@ func TestTemplateResourceBoolRequests(t *testing.T) { name: "classic unknown, agents true", useClassicParameterFlow: types.BoolUnknown(), agentsAllowed: types.BoolValue(true), - wantAgents: ptr.Ref(true), + wantAgents: new(true), }, { name: "classic true, agents unknown", useClassicParameterFlow: types.BoolValue(true), agentsAllowed: types.BoolUnknown(), - wantClassic: ptr.Ref(true), + wantClassic: new(true), }, { name: "classic null, agents false", useClassicParameterFlow: types.BoolNull(), agentsAllowed: types.BoolValue(false), - wantAgents: ptr.Ref(false), + wantAgents: new(false), }, { name: "classic false, agents null", useClassicParameterFlow: types.BoolValue(false), agentsAllowed: types.BoolNull(), - wantClassic: ptr.Ref(false), + wantClassic: new(false), }, } for _, tc := range cases { @@ -145,18 +143,18 @@ func TestTemplateResourceACLRoleSchemaValidation(t *testing.T) { cfg := testAccTemplateResourceConfig{ URL: "http://127.0.0.1", Token: "test-token", - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &directory, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ GroupACL: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref("00000000-0000-0000-0000-000000000000"), - Value: ptr.Ref("owner"), + Key: new("00000000-0000-0000-0000-000000000000"), + Value: new("owner"), }, }, }, @@ -181,12 +179,12 @@ func TestTemplateResourceDescriptionSchemaValidation(t *testing.T) { cfg := testAccTemplateResourceConfig{ URL: "http://127.0.0.1", Token: "test-token", - Name: ptr.Ref("example-template"), - Description: ptr.Ref(strings.Repeat("a", 128)), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Description: new(strings.Repeat("a", 128)), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &directory, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ @@ -228,12 +226,12 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ @@ -242,36 +240,36 @@ func TestAccTemplateResource(t *testing.T) { } cfg2 := cfg1 - cfg2.Versions = ptr.Ref(slices.Clone(*cfg2.Versions)) - cfg2.Name = ptr.Ref("example-template-new") + cfg2.Versions = new(slices.Clone(*cfg2.Versions)) + cfg2.Name = new("example-template-new") (*cfg2.Versions)[0].Directory = &exTemplateTwo - (*cfg2.Versions)[0].Name = ptr.Ref("new") + (*cfg2.Versions)[0].Name = new("new") cfg3 := cfg2 - cfg3.Versions = ptr.Ref(slices.Clone(*cfg3.Versions)) - cfg3.Versions = ptr.Ref(append(*cfg3.Versions, testAccTemplateVersionConfig{ - Name: ptr.Ref("legacy-template"), + cfg3.Versions = new(slices.Clone(*cfg3.Versions)) + cfg3.Versions = new(append(*cfg3.Versions, testAccTemplateVersionConfig{ + Name: new("legacy-template"), Directory: &exTemplateOne, - Active: ptr.Ref(false), + Active: new(false), TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref("name"), - Value: ptr.Ref("world"), + Key: new("name"), + Value: new("world"), }, }, })) cfg4 := cfg3 - cfg4.Versions = ptr.Ref(slices.Clone(*cfg4.Versions)) - (*cfg4.Versions)[0].Active = ptr.Ref(false) - (*cfg4.Versions)[1].Active = ptr.Ref(true) + cfg4.Versions = new(slices.Clone(*cfg4.Versions)) + (*cfg4.Versions)[0].Active = new(false) + (*cfg4.Versions)[1].Active = new(true) cfg5 := cfg4 - cfg5.Versions = ptr.Ref(slices.Clone(*cfg5.Versions)) + cfg5.Versions = new(slices.Clone(*cfg5.Versions)) (*cfg5.Versions)[0], (*cfg5.Versions)[1] = (*cfg5.Versions)[1], (*cfg5.Versions)[0] cfg6 := cfg4 - cfg6.Versions = ptr.Ref(slices.Clone((*cfg6.Versions)[1:])) + cfg6.Versions = new(slices.Clone((*cfg6.Versions)[1:])) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -427,29 +425,29 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template2"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template2"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateTwo, TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref("name"), - Value: ptr.Ref("world"), + Key: new("name"), + Value: new("world"), }, }, - Active: ptr.Ref(true), + Active: new(true), }, { // Auto-generated version name Directory: &exTemplateTwo, TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref("name"), - Value: ptr.Ref("world"), + Key: new("name"), + Value: new("world"), }, }, - Active: ptr.Ref(false), + Active: new(false), }, }), ACL: testAccTemplateACLConfig{ @@ -458,29 +456,29 @@ func TestAccTemplateResource(t *testing.T) { } cfg2 := cfg1 - cfg2.Versions = ptr.Ref(slices.Clone(*cfg2.Versions)) - (*cfg2.Versions)[1].Name = ptr.Ref("new-name") + cfg2.Versions = new(slices.Clone(*cfg2.Versions)) + (*cfg2.Versions)[1].Name = new("new-name") cfg3 := cfg2 - cfg3.Versions = ptr.Ref(slices.Clone(*cfg3.Versions)) - (*cfg3.Versions)[0].Name = ptr.Ref("new-name-one") - (*cfg3.Versions)[1].Name = ptr.Ref("new-name-two") + cfg3.Versions = new(slices.Clone(*cfg3.Versions)) + (*cfg3.Versions)[0].Name = new("new-name-one") + (*cfg3.Versions)[1].Name = new("new-name-two") (*cfg3.Versions)[0], (*cfg3.Versions)[1] = (*cfg3.Versions)[1], (*cfg3.Versions)[0] cfg4 := cfg1 - cfg4.Versions = ptr.Ref(slices.Clone(*cfg4.Versions)) + cfg4.Versions = new(slices.Clone(*cfg4.Versions)) (*cfg4.Versions)[0].Directory = &exTemplateOne cfg5 := cfg4 - cfg5.Versions = ptr.Ref(slices.Clone(*cfg5.Versions)) + cfg5.Versions = new(slices.Clone(*cfg5.Versions)) (*cfg5.Versions)[1].Directory = &exTemplateOne cfg6 := cfg5 - cfg6.Versions = ptr.Ref(slices.Clone(*cfg6.Versions)) + cfg6.Versions = new(slices.Clone(*cfg6.Versions)) (*cfg6.Versions)[0].TerraformVariables = []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref("name"), - Value: ptr.Ref("world2"), + Key: new("name"), + Value: new("world2"), }, } @@ -556,18 +554,18 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template3"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template3"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateTwo, TerraformVariables: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref("name"), - Value: ptr.Ref("world"), + Key: new("name"), + Value: new("world"), }, }, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ @@ -576,11 +574,11 @@ func TestAccTemplateResource(t *testing.T) { } cfg2 := cfg1 - cfg2.Versions = ptr.Ref(slices.Clone(*cfg2.Versions)) + cfg2.Versions = new(slices.Clone(*cfg2.Versions)) (*cfg2.Versions)[0].TerraformVariables = []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref("name"), - Value: ptr.Ref("world2"), + Key: new("name"), + Value: new("world2"), }, } @@ -609,12 +607,12 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: ptr.Ref(false), + Active: new(false), }, }), ACL: testAccTemplateACLConfig{ @@ -643,12 +641,12 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ @@ -657,23 +655,23 @@ func TestAccTemplateResource(t *testing.T) { } cfg2 := cfg1 - cfg2.Versions = ptr.Ref(slices.Clone(*cfg2.Versions)) - (*cfg2.Versions)[0].Active = ptr.Ref(false) + cfg2.Versions = new(slices.Clone(*cfg2.Versions)) + (*cfg2.Versions)[0].Active = new(false) cfg3 := cfg2 - cfg3.Versions = ptr.Ref(slices.Clone(*cfg3.Versions)) + cfg3.Versions = new(slices.Clone(*cfg3.Versions)) (*cfg3.Versions)[0].Directory = &exTemplateTwo cfg2b := cfg1 - cfg2b.Versions = ptr.Ref(slices.Clone(*cfg2b.Versions)) - cfg2b.Versions = ptr.Ref(append(*cfg2b.Versions, testAccTemplateVersionConfig{ + cfg2b.Versions = new(slices.Clone(*cfg2b.Versions)) + cfg2b.Versions = new(append(*cfg2b.Versions, testAccTemplateVersionConfig{ Directory: &exTemplateTwo, - Active: ptr.Ref(false), + Active: new(false), })) cfg3b := cfg2b - cfg3b.Versions = ptr.Ref(slices.Clone(*cfg3b.Versions)) - (*cfg3b.Versions)[1].Active = ptr.Ref(true) + cfg3b.Versions = new(slices.Clone(*cfg3b.Versions)) + (*cfg3b.Versions)[1].Active = new(true) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -711,12 +709,12 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ @@ -725,16 +723,16 @@ func TestAccTemplateResource(t *testing.T) { } cfg2 := cfg1 - cfg2.Versions = ptr.Ref(slices.Clone(*cfg2.Versions)) - (*cfg2.Versions)[0].Active = ptr.Ref(false) - cfg2.Versions = ptr.Ref(append(*cfg2.Versions, testAccTemplateVersionConfig{ + cfg2.Versions = new(slices.Clone(*cfg2.Versions)) + (*cfg2.Versions)[0].Active = new(false) + cfg2.Versions = new(append(*cfg2.Versions, testAccTemplateVersionConfig{ Directory: &exTemplateTwo, - Active: ptr.Ref(false), + Active: new(false), })) cfg3 := cfg2 - cfg3.Versions = ptr.Ref(slices.Clone(*cfg3.Versions)) - (*cfg3.Versions)[1].Active = ptr.Ref(true) + cfg3.Versions = new(slices.Clone(*cfg3.Versions)) + (*cfg3.Versions)[1].Active = new(true) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -767,12 +765,12 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ @@ -781,13 +779,13 @@ func TestAccTemplateResource(t *testing.T) { } cfg2 := cfg1 - cfg2.Versions = ptr.Ref(slices.Clone(*cfg2.Versions)) - (*cfg2.Versions)[0].Active = ptr.Ref(false) + cfg2.Versions = new(slices.Clone(*cfg2.Versions)) + (*cfg2.Versions)[0].Active = new(false) (*cfg2.Versions)[0].Directory = &exTemplateTwo cfg3 := cfg2 - cfg3.Versions = ptr.Ref(slices.Clone(*cfg3.Versions)) - (*cfg3.Versions)[0].Active = ptr.Ref(true) + cfg3.Versions = new(slices.Clone(*cfg3.Versions)) + (*cfg3.Versions)[0].Active = new(true) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -829,15 +827,15 @@ func TestAccTemplateResource(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{null: true}, - MaxPortShareLevel: ptr.Ref("invalid"), + MaxPortShareLevel: new("invalid"), } resource.Test(t, resource.TestCase{ @@ -874,19 +872,19 @@ func TestAccTemplateResourceAgentsAllowed(t *testing.T) { cfgOmitted := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("agents-allowed-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("agents-allowed-template"), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &directory, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{null: true}, } cfgFalse := cfgOmitted - cfgFalse.AgentsAllowed = ptr.Ref(false) + cfgFalse.AgentsAllowed = new(false) cfgTrue := cfgFalse - cfgTrue.AgentsAllowed = ptr.Ref(true) + cfgTrue.AgentsAllowed = new(true) cfgOmittedAgain := cfgTrue cfgOmittedAgain.AgentsAllowed = nil @@ -937,7 +935,7 @@ func TestAccTemplateResourceOptionalVersions(t *testing.T) { cfg := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("no-versions-template"), + Name: new("no-versions-template"), ACL: testAccTemplateACLConfig{null: true}, } @@ -984,11 +982,11 @@ func TestAccTemplateResourceOptionalVersions(t *testing.T) { cfgManaged := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref(tpl.Name), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new(tpl.Name), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &exTemplate, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{null: true}, @@ -998,8 +996,8 @@ func TestAccTemplateResourceOptionalVersions(t *testing.T) { // version active. The template already has an active version on // the server; this config is just adopting it, not creating it. cfgReimported := cfgManaged - cfgReimported.Versions = ptr.Ref(slices.Clone(*cfgReimported.Versions)) - (*cfgReimported.Versions)[0].Active = ptr.Ref(false) + cfgReimported.Versions = new(slices.Clone(*cfgReimported.Versions)) + (*cfgReimported.Versions)[0].Active = new(false) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -1041,11 +1039,11 @@ func TestAccTemplateResourceOptionalVersions(t *testing.T) { cfgManaged := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("settings-only-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("settings-only-template"), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &exTemplate, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{null: true}, @@ -1055,7 +1053,7 @@ func TestAccTemplateResourceOptionalVersions(t *testing.T) { cfgUnmanaged.Versions = nil cfgUnmanagedWithNewDescription := cfgUnmanaged - cfgUnmanagedWithNewDescription.Description = ptr.Ref("now managed by an external pipeline") + cfgUnmanagedWithNewDescription.Description = new("now managed by an external pipeline") // Captured from state in the first step's Check, then used both to // simulate the pipeline's out-of-band push (PreConfig runs before a @@ -1180,29 +1178,29 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ GroupACL: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref(firstUser.OrganizationIDs[0].String()), - Value: ptr.Ref("use"), + Key: new(firstUser.OrganizationIDs[0].String()), + Value: new("use"), }, { - Key: ptr.Ref(group.ID.String()), - Value: ptr.Ref("admin"), + Key: new(group.ID.String()), + Value: new("admin"), }, }, UserACL: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref(firstUser.ID.String()), - Value: ptr.Ref("admin"), + Key: new(firstUser.ID.String()), + Value: new("admin"), }, }, }, @@ -1210,19 +1208,19 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { cfg2 := cfg1 cfg2.ACL.GroupACL = slices.Clone(cfg2.ACL.GroupACL[1:]) - cfg2.MaxPortShareLevel = ptr.Ref("owner") - cfg2.CORSBehavior = ptr.Ref("passthru") + cfg2.MaxPortShareLevel = new("owner") + cfg2.CORSBehavior = new("passthru") cfg3 := cfg2 cfg3.ACL.null = true - cfg3.MaxPortShareLevel = ptr.Ref("public") - cfg3.CORSBehavior = ptr.Ref("simple") + cfg3.MaxPortShareLevel = new("public") + cfg3.CORSBehavior = new("simple") cfg4 := cfg3 - cfg4.AllowUserAutostart = ptr.Ref(false) + cfg4.AllowUserAutostart = new(false) cfg4.AutostopRequirement = testAccAutostopRequirementConfig{ - DaysOfWeek: ptr.Ref([]string{"monday", "tuesday"}), - Weeks: ptr.Ref(int64(2)), + DaysOfWeek: new([]string{"monday", "tuesday"}), + Weeks: new(int64(2)), } resource.Test(t, resource.TestCase{ @@ -1314,26 +1312,26 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { baseCfg := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), } cfgOwner := baseCfg - cfgOwner.MaxPortShareLevel = ptr.Ref("owner") + cfgOwner.MaxPortShareLevel = new("owner") cfgAuthenticated := baseCfg - cfgAuthenticated.MaxPortShareLevel = ptr.Ref("authenticated") + cfgAuthenticated.MaxPortShareLevel = new("authenticated") cfgOrganization := baseCfg - cfgOrganization.MaxPortShareLevel = ptr.Ref("organization") + cfgOrganization.MaxPortShareLevel = new("organization") cfgPublic := baseCfg - cfgPublic.MaxPortShareLevel = ptr.Ref("public") + cfgPublic.MaxPortShareLevel = new("public") resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -1377,11 +1375,11 @@ func TestAccTemplateResourceBackCompat(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), ACL: testAccTemplateACLConfig{ @@ -1414,6 +1412,7 @@ func TestAccTemplateResourceAGPL(t *testing.T) { client := integration.StartCoder(ctx, t, "template_resource_agpl_acc") firstUser, err := client.User(ctx, codersdk.Me) require.NoError(t, err) + organizationID := firstUser.OrganizationIDs[0].String() exTemplateOne := t.TempDir() err = cp.Copy("../../integration/template-test/example-template", exTemplateOne) @@ -1422,47 +1421,47 @@ func TestAccTemplateResourceAGPL(t *testing.T) { cfg1 := testAccTemplateResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example-template"), - Versions: ptr.Ref([]testAccTemplateVersionConfig{ + Name: new("example-template"), + Versions: new([]testAccTemplateVersionConfig{ { // Auto-generated version name Directory: &exTemplateOne, - Active: ptr.Ref(true), + Active: new(true), }, }), - AllowUserAutostart: ptr.Ref(false), + AllowUserAutostart: new(false), } cfg2 := cfg1 cfg2.AllowUserAutostart = nil - cfg2.AutostopRequirement.DaysOfWeek = ptr.Ref([]string{"monday", "tuesday"}) + cfg2.AutostopRequirement.DaysOfWeek = new([]string{"monday", "tuesday"}) cfg3 := cfg2 cfg3.AutostopRequirement.null = true - cfg3.AutostartRequirement = ptr.Ref([]string{}) + cfg3.AutostartRequirement = new([]string{}) cfg4 := cfg3 - cfg4.FailureTTL = ptr.Ref(int64(1)) + cfg4.FailureTTL = new(int64(1)) cfg5 := cfg4 cfg5.FailureTTL = nil cfg5.AutostartRequirement = nil - cfg5.RequireActiveVersion = ptr.Ref(true) + cfg5.RequireActiveVersion = new(true) cfg6 := cfg5 cfg6.RequireActiveVersion = nil cfg6.ACL = testAccTemplateACLConfig{ GroupACL: []testAccTemplateKeyValueConfig{ { - Key: ptr.Ref(firstUser.OrganizationIDs[0].String()), - Value: ptr.Ref("use"), + Key: &organizationID, + Value: new("use"), }, }, } cfg7 := cfg6 cfg7.ACL.null = true - cfg7.MaxPortShareLevel = ptr.Ref("owner") + cfg7.MaxPortShareLevel = new("owner") for _, cfg := range []testAccTemplateResourceConfig{cfg1, cfg2, cfg3, cfg4} { resource.Test(t, resource.TestCase{ diff --git a/internal/provider/user_data_source_test.go b/internal/provider/user_data_source_test.go index 16b86db..7f62064 100644 --- a/internal/provider/user_data_source_test.go +++ b/internal/provider/user_data_source_test.go @@ -12,7 +12,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" @@ -123,7 +122,7 @@ func TestAccUserDataSource(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: ptr.Ref(user.Username), + Username: new(user.Username), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -142,7 +141,7 @@ func TestAccUserDataSource(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: ptr.Ref(user.ID.String()), + ID: new(user.ID.String()), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -161,7 +160,7 @@ func TestAccUserDataSource(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Email: ptr.Ref(user.Email), + Email: new(user.Email), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -179,7 +178,7 @@ func TestAccUserDataSource(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Email: ptr.Ref("missing@coder.com"), + Email: new("missing@coder.com"), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -216,7 +215,7 @@ func TestAccUserDataSource(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: ptr.Ref("invalid-uuid"), + ID: new("invalid-uuid"), } resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -256,7 +255,7 @@ func TestAccUserDataSourceServiceAccount(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: ptr.Ref(user.Username), + Username: &user.Username, } resource.Test(t, resource.TestCase{ IsUnitTest: true, diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go index 2609d11..b9bc382 100644 --- a/internal/provider/user_resource_test.go +++ b/internal/provider/user_resource_test.go @@ -7,7 +7,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" @@ -28,25 +27,25 @@ func TestAccUserResource(t *testing.T) { cfg1 := testAccUserResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: ptr.Ref("example"), - Name: ptr.Ref("Example User"), - Email: ptr.Ref("example@coder.com"), - Roles: ptr.Ref([]string{"owner", "auditor"}), - LoginType: ptr.Ref("password"), - Password: ptr.Ref("SomeSecurePassword!"), + Username: new("example"), + Name: new("Example User"), + Email: new("example@coder.com"), + Roles: new([]string{"owner", "auditor"}), + LoginType: new("password"), + Password: new("SomeSecurePassword!"), } cfg2 := cfg1 - cfg2.Username = ptr.Ref("exampleNew") + cfg2.Username = new("exampleNew") cfg3 := cfg2 - cfg3.Name = ptr.Ref("Example New") + cfg3.Name = new("Example New") cfgEmailChanged := cfg3 - cfgEmailChanged.Email = ptr.Ref("example-new@coder.com") + cfgEmailChanged.Email = new("example-new@coder.com") cfg4 := cfgEmailChanged - cfg4.LoginType = ptr.Ref("github") + cfg4.LoginType = new("github") cfg4.Password = nil cfg5 := cfg4 @@ -153,12 +152,12 @@ func TestAccUserResource(t *testing.T) { cfg := testAccUserResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: ptr.Ref("unmanaged"), - Name: ptr.Ref("Unmanaged User"), - Email: ptr.Ref("unmanaged@coder.com"), + Username: new("unmanaged"), + Name: new("Unmanaged User"), + Email: new("unmanaged@coder.com"), Roles: nil, // Start with unmanaged roles - LoginType: ptr.Ref("password"), - Password: ptr.Ref("SomeSecurePassword!"), + LoginType: new("password"), + Password: new("SomeSecurePassword!"), } resource.Test(t, resource.TestCase{ @@ -189,10 +188,10 @@ func TestAccUserResourceServiceAccount(t *testing.T) { cfg := testAccUserResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: ptr.Ref("service-account"), - Name: ptr.Ref("Service Account"), - Roles: ptr.Ref([]string{"template-admin"}), - IsServiceAccount: ptr.Ref(true), + Username: new("service-account"), + Name: new("Service Account"), + Roles: new([]string{"template-admin"}), + IsServiceAccount: new(true), } // Changing an unrelated attribute (name) on a service account exercises the @@ -200,17 +199,17 @@ func TestAccUserResourceServiceAccount(t *testing.T) { // for `email` would be "(known after apply)" on every update, producing // spurious plan churn for SAs (which have no email). cfgRenamed := cfg - cfgRenamed.Name = ptr.Ref("Service Account v2") + cfgRenamed.Name = new("Service Account v2") // Flipping `is_service_account` explicitly from true to false is the // supported way to "convert" an SA into a regular user; it is immutable // server-side, so RequiresReplaceIfConfigured triggers a replacement when // the value is set in config. cfgRegular := cfgRenamed - cfgRegular.IsServiceAccount = ptr.Ref(false) - cfgRegular.Email = ptr.Ref("service-account@coder.com") - cfgRegular.LoginType = ptr.Ref("password") - cfgRegular.Password = ptr.Ref("SomeSecurePassword!") + cfgRegular.IsServiceAccount = new(false) + cfgRegular.Email = new("service-account@coder.com") + cfgRegular.LoginType = new("password") + cfgRegular.Password = new("SomeSecurePassword!") resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -306,28 +305,28 @@ func TestAccUserResourceValidateConfig(t *testing.T) { // Regular user (not a service account) must still provide an email, even // though the attribute is now Optional in the schema. noEmail := base - noEmail.Username = ptr.Ref("no-email") + noEmail.Username = new("no-email") // Service accounts must not carry an email, password, or non-none login_type. saWithEmail := base - saWithEmail.Username = ptr.Ref("sa-email") - saWithEmail.IsServiceAccount = ptr.Ref(true) - saWithEmail.Email = ptr.Ref("sa@coder.com") + saWithEmail.Username = new("sa-email") + saWithEmail.IsServiceAccount = new(true) + saWithEmail.Email = new("sa@coder.com") saWithPassword := base - saWithPassword.Username = ptr.Ref("sa-password") - saWithPassword.IsServiceAccount = ptr.Ref(true) - saWithPassword.Password = ptr.Ref("SomeSecurePassword!") + saWithPassword.Username = new("sa-password") + saWithPassword.IsServiceAccount = new(true) + saWithPassword.Password = new("SomeSecurePassword!") saWithEmptyPassword := base - saWithEmptyPassword.Username = ptr.Ref("sa-empty-password") - saWithEmptyPassword.IsServiceAccount = ptr.Ref(true) - saWithEmptyPassword.Password = ptr.Ref("") + saWithEmptyPassword.Username = new("sa-empty-password") + saWithEmptyPassword.IsServiceAccount = new(true) + saWithEmptyPassword.Password = new("") saWithLoginType := base - saWithLoginType.Username = ptr.Ref("sa-login") - saWithLoginType.IsServiceAccount = ptr.Ref(true) - saWithLoginType.LoginType = ptr.Ref("password") + saWithLoginType.Username = new("sa-login") + saWithLoginType.IsServiceAccount = new(true) + saWithLoginType.LoginType = new("password") resource.Test(t, resource.TestCase{ IsUnitTest: true, diff --git a/internal/provider/workspace_proxy_resource_test.go b/internal/provider/workspace_proxy_resource_test.go index a755ca2..144a11b 100644 --- a/internal/provider/workspace_proxy_resource_test.go +++ b/internal/provider/workspace_proxy_resource_test.go @@ -7,7 +7,6 @@ import ( "testing" "text/template" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/terraform-provider-coderd/integration" fwresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -36,14 +35,14 @@ func TestAccWorkspaceProxyResource(t *testing.T) { cfg1 := testAccWorkspaceProxyResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example"), - DisplayName: ptr.Ref("Example WS Proxy"), - Icon: ptr.Ref("/emojis/1f407.png"), + Name: new("example"), + DisplayName: new("Example WS Proxy"), + Icon: new("/emojis/1f407.png"), } cfg2 := cfg1 - cfg2.Name = ptr.Ref("example-new") - cfg2.DisplayName = ptr.Ref("Example WS Proxy New") + cfg2.Name = new("example-new") + cfg2.DisplayName = new("Example WS Proxy New") resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -78,9 +77,9 @@ func TestAccWorkspaceProxyResourceAGPL(t *testing.T) { cfg1 := testAccWorkspaceProxyResourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Name: ptr.Ref("example"), - DisplayName: ptr.Ref("Example WS Proxy"), - Icon: ptr.Ref("/emojis/1f407.png"), + Name: new("example"), + DisplayName: new("Example WS Proxy"), + Icon: new("/emojis/1f407.png"), } resource.Test(t, resource.TestCase{ From 5204472289d4e7606d9031271e7b3c7df1744389 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 10:53:10 +0000 Subject: [PATCH 08/13] test(integration): dump container logs from canceled cleanup context --- integration/integration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/integration.go b/integration/integration.go index a736aa0..ff1cd38 100644 --- a/integration/integration.go +++ b/integration/integration.go @@ -186,7 +186,7 @@ func dumpContainerLogs(t *testing.T, ctr testcontainers.Container) { if ctr == nil { return } - ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.WithoutCancel(t.Context()), 30*time.Second) defer cancel() logs, err := ctr.Logs(ctx) if err != nil { From 26ff2ade8958c5da0abc8c2148f748f8c7184194 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 10:53:10 +0000 Subject: [PATCH 09/13] fix(coderd_agents_system_prompt)!: require Coder 2.37 for the promoted route The SDK now calls /api/v2/chats/config/system-prompt, first served in Coder 2.37 (coder/coder#28496). Skip the real-Coder acceptance tests when the deployment does not serve the endpoint, matching the chat model tests. --- docs/resources/agents_system_prompt.md | 4 ++-- .../provider/agents_system_prompt_resource.go | 4 ++-- .../agents_system_prompt_resource_test.go | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/resources/agents_system_prompt.md b/docs/resources/agents_system_prompt.md index fde7282..c3f884a 100644 --- a/docs/resources/agents_system_prompt.md +++ b/docs/resources/agents_system_prompt.md @@ -12,7 +12,7 @@ description: |- ~> Warning terraform destroy resets the prompt to empty and include_default_system_prompt to true, the defaults of a never-configured deployment. The API has no delete operation for this setting. ~> Warning - This resource requires Coder version 2.32.0 https://github.com/coder/coder/releases/tag/v2.32.0 or later, and a token with site-wide owner permissions. + This resource requires Coder version 2.37.0 https://github.com/coder/coder/releases/tag/v2.37.0 or later, and a token with site-wide owner permissions. --- # coderd_agents_system_prompt (Resource) @@ -32,7 +32,7 @@ If a system prompt was configured out of band, `terraform import` this resource `terraform destroy` resets the prompt to empty and `include_default_system_prompt` to `true`, the defaults of a never-configured deployment. The API has no delete operation for this setting. ~> **Warning** -This resource requires Coder version [2.32.0](https://github.com/coder/coder/releases/tag/v2.32.0) or later, and a token with site-wide `owner` permissions. +This resource requires Coder version [2.37.0](https://github.com/coder/coder/releases/tag/v2.37.0) or later, and a token with site-wide `owner` permissions. ## Example Usage diff --git a/internal/provider/agents_system_prompt_resource.go b/internal/provider/agents_system_prompt_resource.go index a3f29e6..7bbe047 100644 --- a/internal/provider/agents_system_prompt_resource.go +++ b/internal/provider/agents_system_prompt_resource.go @@ -24,8 +24,8 @@ var _ resource.Resource = &AgentsSystemPromptResource{} var _ resource.ResourceWithImportState = &AgentsSystemPromptResource{} var _ resource.ResourceWithModifyPlan = &AgentsSystemPromptResource{} -// First release with the chat system prompt endpoint (coder/coder#22857). -const agentsSystemPromptMinVersion = "2.32.0" +// First release serving the chat API under /api/v2 (coder/coder#28496). +const agentsSystemPromptMinVersion = "2.37.0" // Mirrors coderd/exp_chats.go. const maxAgentsSystemPromptBytes = 131072 diff --git a/internal/provider/agents_system_prompt_resource_test.go b/internal/provider/agents_system_prompt_resource_test.go index a6bcfcb..2ef495b 100644 --- a/internal/provider/agents_system_prompt_resource_test.go +++ b/internal/provider/agents_system_prompt_resource_test.go @@ -1,7 +1,9 @@ package provider import ( + "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -301,6 +303,7 @@ func TestAccAgentsSystemPromptRealCoderNoDrift(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_system_prompt_acc") + skipUnlessAgentsSystemPromptEndpoint(ctx, t, client) experimental := codersdk.NewExperimentalClient(client) messyPrompt := "You are a helpful agent.\r\nBe concise.\u200b\n\n\n\nAlways cite sources.\n" @@ -344,6 +347,21 @@ resource "coderd_agents_system_prompt" "test" { require.True(t, live.IncludeDefaultSystemPrompt) } +// skipUnlessAgentsSystemPromptEndpoint skips the test when the target +// deployment does not serve the promoted chat system prompt endpoint under +// /api/v2 (Coder < 2.37). +func skipUnlessAgentsSystemPromptEndpoint(ctx context.Context, t *testing.T, client *codersdk.Client) { + t.Helper() + _, err := codersdk.NewExperimentalClient(client).GetChatSystemPrompt(ctx) + if err != nil { + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { + t.Skipf("deployment does not support the chat system prompt endpoint: %s", err) + } + require.NoError(t, err, "probe chat system prompt endpoint") + } +} + func TestAccAgentsSystemPromptRealCoderImportNoDrift(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { @@ -351,6 +369,7 @@ func TestAccAgentsSystemPromptRealCoderImportNoDrift(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_system_prompt_import_acc") + skipUnlessAgentsSystemPromptEndpoint(ctx, t, client) experimental := codersdk.NewExperimentalClient(client) includeDefault := true From ca128eff712417e0a1697b52429f7d866c307ca4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 10:53:10 +0000 Subject: [PATCH 10/13] fix(coderd_agents_model)!: drop the legacy default-organization fallback Coder 2.37 removed GET /api/experimental/chats/models (coder/coder#28632), so organization_id can no longer be recovered from state written by earlier provider versions. Error with re-import instructions instead. --- internal/provider/agents_model_resource.go | 81 +++++-------------- .../provider/agents_model_resource_test.go | 34 +++----- 2 files changed, 35 insertions(+), 80 deletions(-) diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index c7f9c25..dfafe53 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -51,45 +51,23 @@ func (r *AgentsModelResource) experimentalClient() *codersdk.ExperimentalClient return codersdk.NewExperimentalClient(r.data.Client) } -// legacyDefaultOrganizationChatModels uses the compatibility collection route -// retained for models created before chat model configurations became -// organization-scoped. It is only used to recover organization_id from legacy -// Terraform state; normal CRUD uses the organization-scoped SDK methods. -func legacyDefaultOrganizationChatModels(ctx context.Context, client *codersdk.Client) (codersdk.OrganizationChatModelsResponse, error) { - res, err := client.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil) - if err != nil { - return codersdk.OrganizationChatModelsResponse{}, err - } - defer func() { _ = res.Body.Close() }() - if res.StatusCode != http.StatusOK { - return codersdk.OrganizationChatModelsResponse{}, codersdk.ReadBodyAsError(res) - } - var configs codersdk.OrganizationChatModelsResponse - return configs, codersdk.ReadBodyAsJSON(res, &configs) -} - -func legacyDefaultOrganizationChatModel(ctx context.Context, client *codersdk.Client, modelID uuid.UUID) (codersdk.ChatModel, bool, error) { - configs, err := legacyDefaultOrganizationChatModels(ctx, client) - if err != nil { - return codersdk.ChatModel{}, false, err - } - for _, config := range configs.Models { - if config.ID == modelID { - return config, true, nil - } - } - return codersdk.ChatModel{}, false, nil -} - -func (r *AgentsModelResource) stateOrganizationID(ctx context.Context, value UUID, modelID uuid.UUID) (uuid.UUID, bool, error) { - if !value.IsNull() && !value.IsUnknown() { - return value.ValueUUID(), true, nil - } - config, found, err := legacyDefaultOrganizationChatModel(ctx, r.data.Client, modelID) - if err != nil || !found { - return uuid.Nil, found, err +// requireStateOrganizationID resolves organization_id from state. State +// written by provider versions that predate organization-scoped chat models +// lacks it, and Coder 2.37 removed the compatibility route that could recover +// it (coder/coder#28632), so such state must be re-imported. +func requireStateOrganizationID(value UUID, modelID uuid.UUID, diags *diag.Diagnostics) uuid.UUID { + if value.IsNull() || value.IsUnknown() { + diags.AddError( + "Legacy Agents Model State", + fmt.Sprintf( + "State for Agents model %[1]s predates organization-scoped chat models and cannot be upgraded automatically. "+ + "Remove it from state and re-import it with the composite ID `/%[1]s`.", + modelID, + ), + ) + return uuid.Nil } - return config.OrganizationID, true, nil + return value.ValueUUID() } type AgentsModelResourceModel struct { @@ -320,14 +298,8 @@ func (r *AgentsModelResource) Read(ctx context.Context, req resource.ReadRequest } modelConfigID := state.ID.ValueUUID() - organizationID, found, err := r.stateOrganizationID(ctx, state.OrganizationID, modelConfigID) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy Agents model state, got error: %s", err)) - return - } - if !found { - resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Agents model with ID %s not found. Marking as deleted.", modelConfigID.String())) - resp.State.RemoveResource(ctx) + organizationID := requireStateOrganizationID(state.OrganizationID, modelConfigID, &resp.Diagnostics) + if resp.Diagnostics.HasError() { return } config, err := r.experimentalClient().ChatModel(ctx, organizationID, modelConfigID) @@ -365,13 +337,8 @@ func (r *AgentsModelResource) Update(ctx context.Context, req resource.UpdateReq } tflog.Info(ctx, "updating Agents model", map[string]any{"id": state.ID.ValueString()}) - organizationID, found, err := r.stateOrganizationID(ctx, state.OrganizationID, state.ID.ValueUUID()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy Agents model state, got error: %s", err)) - return - } - if !found { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update Agents model %s because it no longer exists.", state.ID.ValueString())) + organizationID := requireStateOrganizationID(state.OrganizationID, state.ID.ValueUUID(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { return } modelConfig, err := r.experimentalClient().UpdateChatModel(ctx, organizationID, state.ID.ValueUUID(), updateReq) @@ -399,12 +366,8 @@ func (r *AgentsModelResource) Delete(ctx context.Context, req resource.DeleteReq } tflog.Info(ctx, "deleting Agents model", map[string]any{"id": state.ID.ValueString()}) - organizationID, found, err := r.stateOrganizationID(ctx, state.OrganizationID, state.ID.ValueUUID()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy Agents model state, got error: %s", err)) - return - } - if !found { + organizationID := requireStateOrganizationID(state.OrganizationID, state.ID.ValueUUID(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { return } if err := r.experimentalClient().DeleteChatModel(ctx, organizationID, state.ID.ValueUUID()); err != nil && !isNotFound(err) { diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 845d030..b71fa7f 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -32,32 +32,24 @@ import ( "github.com/stretchr/testify/require" ) -func TestAgentsModelStateOrganizationIDLegacy(t *testing.T) { +func TestAgentsModelRequireStateOrganizationID(t *testing.T) { t.Parallel() organizationID := uuid.New() modelID := uuid.New() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodGet, r.Method) - require.Equal(t, "/api/experimental/chats/models", r.URL.Path) - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(codersdk.OrganizationChatModelsResponse{ - Models: []codersdk.ChatModel{{ - ID: modelID, - OrganizationID: organizationID, - }}, - })) - })) - t.Cleanup(srv.Close) - - srvURL, err := url.Parse(srv.URL) - require.NoError(t, err) - r := &AgentsModelResource{data: &CoderdProviderData{Client: codersdk.New(srvURL)}} - - got, found, err := r.stateOrganizationID(t.Context(), NewUUIDNull(), modelID) - require.NoError(t, err) - require.True(t, found) + + var diags diag.Diagnostics + got := requireStateOrganizationID(UUIDValue(organizationID), modelID, &diags) + require.False(t, diags.HasError()) require.Equal(t, organizationID, got) + + // Legacy state written before models became organization-scoped lacks + // organization_id and must be re-imported. + diags = nil + requireStateOrganizationID(NewUUIDNull(), modelID, &diags) + require.True(t, diags.HasError()) + require.Contains(t, diags.Errors()[0].Detail(), modelID.String()) + require.Contains(t, diags.Errors()[0].Detail(), "re-import") } func TestAgentsModelCreateDiag(t *testing.T) { From c3a28108ca3a3c6336104c6098a157a2d5cf798a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 11:16:56 +0000 Subject: [PATCH 11/13] feat(coderd_agents_model): adopt legacy state via configured organization_id State written before models were organization-scoped lacks organization_id, and Coder 2.37 removed the compatibility route that could recover it (coder/coder#28632). Instead of forcing a state rm + re-import, let users set organization_id in config: Read skips refresh on legacy state with a warning, the plan modifier treats null-state adoption as an in-place update rather than a replace, and Update falls back to the planned organization_id, which the org-scoped PATCH verifies and the state write-back records permanently. --- internal/provider/agents_model_resource.go | 72 +++-- .../provider/agents_model_resource_test.go | 266 +++++++++++++++++- 2 files changed, 310 insertions(+), 28 deletions(-) diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index dfafe53..9df05a0 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -51,23 +51,42 @@ func (r *AgentsModelResource) experimentalClient() *codersdk.ExperimentalClient return codersdk.NewExperimentalClient(r.data.Client) } -// requireStateOrganizationID resolves organization_id from state. State -// written by provider versions that predate organization-scoped chat models -// lacks it, and Coder 2.37 removed the compatibility route that could recover -// it (coder/coder#28632), so such state must be re-imported. -func requireStateOrganizationID(value UUID, modelID uuid.UUID, diags *diag.Diagnostics) uuid.UUID { - if value.IsNull() || value.IsUnknown() { - diags.AddError( - "Legacy Agents Model State", - fmt.Sprintf( - "State for Agents model %[1]s predates organization-scoped chat models and cannot be upgraded automatically. "+ - "Remove it from state and re-import it with the composite ID `/%[1]s`.", - modelID, - ), - ) - return uuid.Nil +// resolveOrganizationID returns organization_id from state, falling back to +// the planned (configured) value: state written by provider versions that +// predate organization-scoped chat models lacks it, and Coder 2.37 removed the +// compatibility route that could recover it server-side (coder/coder#28632). +// Configuring organization_id adopts such a model in place; the org-scoped API +// call then verifies the value and the state write-back records it. +func resolveOrganizationID(state, plan UUID, modelID uuid.UUID, diags *diag.Diagnostics) uuid.UUID { + if !state.IsNull() && !state.IsUnknown() { + return state.ValueUUID() + } + if !plan.IsNull() && !plan.IsUnknown() { + return plan.ValueUUID() } - return value.ValueUUID() + diags.AddError( + "Missing Organization ID", + legacyAgentsModelStateDetail(modelID), + ) + return uuid.Nil +} + +const agentsModelOrganizationRequiresReplaceDescription = "Requires replace if configured and the prior state already records an organization." + +// agentsModelOrganizationRequiresReplace forces replacement when a configured +// organization_id changes, except when prior state records none: such state +// predates organization-scoped chat models, and adopting the configured value +// is an in-place update, not a move. +func agentsModelOrganizationRequiresReplace(_ context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = !req.ConfigValue.IsNull() && !req.StateValue.IsNull() +} + +func legacyAgentsModelStateDetail(modelID uuid.UUID) string { + return fmt.Sprintf( + "State for Agents model %s predates organization-scoped chat models and does not record its organization. "+ + "Set `organization_id` on the resource and run `terraform apply` to adopt it in place.", + modelID, + ) } type AgentsModelResourceModel struct { @@ -116,7 +135,11 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplaceIfConfigured(), + stringplanmodifier.RequiresReplaceIf( + agentsModelOrganizationRequiresReplace, + agentsModelOrganizationRequiresReplaceDescription, + agentsModelOrganizationRequiresReplaceDescription, + ), }, }, "ai_provider_id": schema.StringAttribute{ @@ -298,10 +321,17 @@ func (r *AgentsModelResource) Read(ctx context.Context, req resource.ReadRequest } modelConfigID := state.ID.ValueUUID() - organizationID := requireStateOrganizationID(state.OrganizationID, modelConfigID, &resp.Diagnostics) - if resp.Diagnostics.HasError() { + if state.OrganizationID.IsNull() || state.OrganizationID.IsUnknown() { + // Legacy state predating organization-scoped chat models. Read can't + // see config, so skip the refresh and let the subsequent apply adopt + // the configured organization_id. + resp.Diagnostics.AddWarning( + "Legacy Agents Model State", + legacyAgentsModelStateDetail(modelConfigID), + ) return } + organizationID := state.OrganizationID.ValueUUID() config, err := r.experimentalClient().ChatModel(ctx, organizationID, modelConfigID) if err != nil { if isNotFound(err) { @@ -337,7 +367,7 @@ func (r *AgentsModelResource) Update(ctx context.Context, req resource.UpdateReq } tflog.Info(ctx, "updating Agents model", map[string]any{"id": state.ID.ValueString()}) - organizationID := requireStateOrganizationID(state.OrganizationID, state.ID.ValueUUID(), &resp.Diagnostics) + organizationID := resolveOrganizationID(state.OrganizationID, plan.OrganizationID, state.ID.ValueUUID(), &resp.Diagnostics) if resp.Diagnostics.HasError() { return } @@ -366,7 +396,7 @@ func (r *AgentsModelResource) Delete(ctx context.Context, req resource.DeleteReq } tflog.Info(ctx, "deleting Agents model", map[string]any{"id": state.ID.ValueString()}) - organizationID := requireStateOrganizationID(state.OrganizationID, state.ID.ValueUUID(), &resp.Diagnostics) + organizationID := resolveOrganizationID(state.OrganizationID, NewUUIDNull(), state.ID.ValueUUID(), &resp.Diagnostics) if resp.Diagnostics.HasError() { return } diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index b71fa7f..4ae3f13 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -15,41 +15,85 @@ import ( "text/template" "time" + "sync/atomic" + "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + providerschema "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + frameworkresource "github.com/hashicorp/terraform-plugin-framework/resource" + resourceschema "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-go/tfprotov6" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/stretchr/testify/require" ) -func TestAgentsModelRequireStateOrganizationID(t *testing.T) { +func TestAgentsModelResolveOrganizationID(t *testing.T) { t.Parallel() - organizationID := uuid.New() + stateOrganizationID := uuid.New() + planOrganizationID := uuid.New() modelID := uuid.New() var diags diag.Diagnostics - got := requireStateOrganizationID(UUIDValue(organizationID), modelID, &diags) + got := resolveOrganizationID(UUIDValue(stateOrganizationID), UUIDValue(planOrganizationID), modelID, &diags) require.False(t, diags.HasError()) - require.Equal(t, organizationID, got) + require.Equal(t, stateOrganizationID, got) // Legacy state written before models became organization-scoped lacks - // organization_id and must be re-imported. + // organization_id; the configured (planned) value adopts the model. diags = nil - requireStateOrganizationID(NewUUIDNull(), modelID, &diags) + got = resolveOrganizationID(NewUUIDNull(), UUIDValue(planOrganizationID), modelID, &diags) + require.False(t, diags.HasError()) + require.Equal(t, planOrganizationID, got) + + diags = nil + resolveOrganizationID(NewUUIDNull(), NewUUIDNull(), modelID, &diags) require.True(t, diags.HasError()) require.Contains(t, diags.Errors()[0].Detail(), modelID.String()) - require.Contains(t, diags.Errors()[0].Detail(), "re-import") + require.Contains(t, diags.Errors()[0].Detail(), "organization_id") +} + +func TestAgentsModelOrganizationRequiresReplace(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config, state types.String + requiresReplace bool + }{ + // Adopting an organization into legacy state is an in-place update. + {"adopt legacy state", types.StringValue(uuid.NewString()), types.StringNull(), false}, + {"change organization", types.StringValue(uuid.NewString()), types.StringValue(uuid.NewString()), true}, + {"unset from config", types.StringNull(), types.StringValue(uuid.NewString()), false}, + {"unknown config", types.StringUnknown(), types.StringValue(uuid.NewString()), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resp := &stringplanmodifier.RequiresReplaceIfFuncResponse{} + agentsModelOrganizationRequiresReplace(t.Context(), planmodifier.StringRequest{ + ConfigValue: tt.config, + StateValue: tt.state, + }, resp) + require.Equal(t, tt.requiresReplace, resp.RequiresReplace) + }) + } } func TestAgentsModelCreateDiag(t *testing.T) { @@ -1108,3 +1152,211 @@ func testCheckAgentsModelConfig(maxOutputTokens int64, temperature float64) reso return nil }) } + +// legacyCoderdProvider mimics the pre-organization-scoped provider (<= 0.0.23) +// closely enough to write state whose coderd_agents_model lacks +// organization_id, without talking to any server. +type legacyCoderdProvider struct { + model codersdk.ChatModel +} + +var _ provider.Provider = (*legacyCoderdProvider)(nil) + +func (*legacyCoderdProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "coderd" +} + +func (*legacyCoderdProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = providerschema.Schema{ + Attributes: map[string]providerschema.Attribute{ + "url": providerschema.StringAttribute{Optional: true}, + "token": providerschema.StringAttribute{Optional: true}, + }, + } +} + +func (*legacyCoderdProvider) Configure(context.Context, provider.ConfigureRequest, *provider.ConfigureResponse) { +} + +func (*legacyCoderdProvider) DataSources(context.Context) []func() datasource.DataSource { + return nil +} + +func (p *legacyCoderdProvider) Resources(context.Context) []func() frameworkresource.Resource { + return []func() frameworkresource.Resource{ + func() frameworkresource.Resource { return &legacyAgentsModelResource{model: p.model} }, + } +} + +// legacyAgentsModelResource reproduces the old coderd_agents_model schema: +// the current one minus organization_id, with server-computed values canned. +type legacyAgentsModelResource struct { + model codersdk.ChatModel +} + +var _ frameworkresource.Resource = (*legacyAgentsModelResource)(nil) + +type legacyAgentsModelModel struct { + ID types.String `tfsdk:"id"` + AIProviderID types.String `tfsdk:"ai_provider_id"` + ProviderType types.String `tfsdk:"provider_type"` + Model types.String `tfsdk:"model"` + DisplayName types.String `tfsdk:"display_name"` + Enabled types.Bool `tfsdk:"enabled"` + ContextLimit types.Int64 `tfsdk:"context_limit"` + CompressionThreshold types.Int64 `tfsdk:"compression_threshold"` + ModelConfig types.String `tfsdk:"model_config"` + CreatedAt types.Int64 `tfsdk:"created_at"` + UpdatedAt types.Int64 `tfsdk:"updated_at"` +} + +func (*legacyAgentsModelResource) Metadata(_ context.Context, req frameworkresource.MetadataRequest, resp *frameworkresource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_agents_model" +} + +func (*legacyAgentsModelResource) Schema(_ context.Context, _ frameworkresource.SchemaRequest, resp *frameworkresource.SchemaResponse) { + resp.Schema = resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "id": resourceschema.StringAttribute{Computed: true}, + "ai_provider_id": resourceschema.StringAttribute{Required: true}, + "provider_type": resourceschema.StringAttribute{Computed: true}, + "model": resourceschema.StringAttribute{Required: true}, + "display_name": resourceschema.StringAttribute{Optional: true, Computed: true}, + "enabled": resourceschema.BoolAttribute{Optional: true, Computed: true}, + "context_limit": resourceschema.Int64Attribute{Required: true}, + "compression_threshold": resourceschema.Int64Attribute{Optional: true, Computed: true}, + "model_config": resourceschema.StringAttribute{Optional: true}, + "created_at": resourceschema.Int64Attribute{Computed: true}, + "updated_at": resourceschema.Int64Attribute{Computed: true}, + }, + } +} + +func (r *legacyAgentsModelResource) Create(ctx context.Context, req frameworkresource.CreateRequest, resp *frameworkresource.CreateResponse) { + var plan legacyAgentsModelModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + plan.ID = types.StringValue(r.model.ID.String()) + plan.ProviderType = types.StringValue("anthropic") + plan.DisplayName = types.StringValue(r.model.DisplayName) + plan.Enabled = types.BoolValue(r.model.Enabled) + plan.CompressionThreshold = types.Int64Value(int64(r.model.CompressionThreshold)) + plan.CreatedAt = types.Int64Value(r.model.CreatedAt.Unix()) + plan.UpdatedAt = types.Int64Value(r.model.UpdatedAt.Unix()) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (*legacyAgentsModelResource) Read(context.Context, frameworkresource.ReadRequest, *frameworkresource.ReadResponse) { +} + +func (*legacyAgentsModelResource) Update(context.Context, frameworkresource.UpdateRequest, *frameworkresource.UpdateResponse) { +} + +func (*legacyAgentsModelResource) Delete(context.Context, frameworkresource.DeleteRequest, *frameworkresource.DeleteResponse) { +} + +// fakeChatModelServer serves the provider Configure endpoints plus the +// organization-scoped chat model routes the real provider uses after upgrade. +func fakeChatModelServer(t *testing.T, orgID uuid.UUID, model codersdk.ChatModel, patched *atomic.Bool) *httptest.Server { + t.Helper() + writeJSON := func(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(v)) + } + modelPath := fmt.Sprintf("/api/v2/organizations/%s/chats/models/%s", orgID, model.ID) + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Configure fetches the current user and entitlements; the user + // payload decodes acceptably for both. + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"id":"%s","username":"admin","organization_ids":["%s"]}`, uuid.NewString(), orgID) + }) + mux.HandleFunc("GET "+modelPath, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, model) + }) + mux.HandleFunc("PATCH "+modelPath, func(w http.ResponseWriter, _ *http.Request) { + patched.Store(true) + writeJSON(w, model) + }) + mux.HandleFunc("DELETE "+modelPath, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("GET /api/v2/ai/providers/"+model.AIProviderID.String(), func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, codersdk.AIProvider{ID: model.AIProviderID, Type: "anthropic"}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestAgentsModelLegacyStateAdoption upgrades state written by the +// pre-organization-scoped schema: step 1 writes state without organization_id, +// step 2 runs the real provider with organization_id added to config and must +// adopt it via an in-place update (never a replace, which would delete against +// an unknown organization). +func TestAgentsModelLegacyStateAdoption(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + ts := time.Unix(1700000000, 0).UTC() + model := codersdk.ChatModel{ + ID: uuid.New(), + OrganizationID: orgID, + AIProviderID: uuid.New(), + Model: "claude-3-5-sonnet-20241022", + DisplayName: "Claude Sonnet", + Enabled: true, + ContextLimit: 200000, + CompressionThreshold: 70, + CreatedAt: ts, + UpdatedAt: ts, + } + + var patched atomic.Bool + srv := fakeChatModelServer(t, orgID, model, &patched) + + providerBlock := `provider "coderd" { + url = "` + srv.URL + `" + token = "test-token" +} +` + resourceArgs := ` ai_provider_id = "` + model.AIProviderID.String() + `" + model = "` + model.Model + `" + context_limit = 200000 +} +` + legacyFactories := map[string]func() (tfprotov6.ProviderServer, error){ + "coderd": providerserver.NewProtocol6WithError(&legacyCoderdProvider{model: model}), + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + Steps: []resource.TestStep{ + { + ProtoV6ProviderFactories: legacyFactories, + Config: providerBlock + ` +resource "coderd_agents_model" "test" { +` + resourceArgs, + }, + { + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Config: providerBlock + ` +resource "coderd_agents_model" "test" { + organization_id = "` + orgID.String() + `" +` + resourceArgs, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("coderd_agents_model.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_agents_model.test", tfjsonpath.New("organization_id"), knownvalue.StringExact(orgID.String())), + }, + }, + }, + }) + + require.True(t, patched.Load(), "expected the adoption apply to PATCH the organization-scoped route") +} From ed6183b9934c64c1537df136da4fb91d8f808658 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 11:33:08 +0000 Subject: [PATCH 12/13] feat(coderd_agents_model)!: import by organization name --- docs/resources/agents_model.md | 10 +++++----- examples/resources/coderd_agents_model/import.sh | 6 +++--- internal/provider/agents_model_resource.go | 10 +++++----- internal/provider/agents_model_resource_test.go | 13 +++++++++---- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index 3ce3060..9c373ce 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -6,7 +6,7 @@ description: |- ~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. ~> Warning This resource is only compatible with Coder version 2.37.0 https://github.com/coder/coder/releases/tag/v2.37.0 and later. - Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see coderd_ai_provider) along with context, compression, and optional JSON tuning settings. Import IDs use /. + Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see coderd_ai_provider) along with context, compression, and optional JSON tuning settings. Import IDs use /. --- # coderd_agents_model (Resource) @@ -16,7 +16,7 @@ description: |- ~> **Warning** This resource is only compatible with Coder version [2.37.0](https://github.com/coder/coder/releases/tag/v2.37.0) and later. -Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. Import IDs use `/`. +Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. Import IDs use `/`. ## Example Usage @@ -89,14 +89,14 @@ 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 must be the organization UUID and Agents model configuration UUID returned by Coder. -$ terraform import coderd_agents_model.sonnet / +# The ID supplied must be the organization name and the Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet / ``` 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_agents_model.sonnet - id = "/" + id = "/" } ``` diff --git a/examples/resources/coderd_agents_model/import.sh b/examples/resources/coderd_agents_model/import.sh index b1b9f7a..8e26c7d 100644 --- a/examples/resources/coderd_agents_model/import.sh +++ b/examples/resources/coderd_agents_model/import.sh @@ -1,10 +1,10 @@ -# The ID supplied must be the organization UUID and Agents model configuration UUID returned by Coder. -$ terraform import coderd_agents_model.sonnet / +# The ID supplied must be the organization name and the Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet / ``` 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_agents_model.sonnet - id = "/" + id = "/" } diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index 9df05a0..bc737eb 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -119,7 +119,7 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq resp.Schema = schema.Schema{ MarkdownDescription: "~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments.\n\n" + "~> **Warning**\nThis resource is only compatible with Coder version [" + agentsModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + agentsModelMinVersion + ") and later.\n\n" + - "Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. Import IDs use `/`.", + "Configures an organization-scoped, admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. Import IDs use `/`.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Agents model configuration ID.", @@ -409,12 +409,12 @@ func (r *AgentsModelResource) Delete(ctx context.Context, req resource.DeleteReq func (r *AgentsModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { parts := strings.Split(req.ID, "/") if len(parts) != 2 { - resp.Diagnostics.AddError("Invalid Import ID", "Expected `/`.") + resp.Diagnostics.AddError("Invalid Import ID", "Expected `/`.") return } - organizationID, err := uuid.Parse(parts[0]) + org, err := r.data.Client.OrganizationByName(ctx, parts[0]) if err != nil { - resp.Diagnostics.AddError("Invalid Import ID", fmt.Sprintf("Unable to parse organization ID as UUID: %s", err)) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get organization %q: %s", parts[0], err)) return } id, err := uuid.Parse(parts[1]) @@ -422,7 +422,7 @@ func (r *AgentsModelResource) ImportState(ctx context.Context, req resource.Impo resp.Diagnostics.AddError("Invalid Import ID", fmt.Sprintf("Unable to parse Agents model ID as UUID: %s", err)) return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), organizationID.String())...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), org.ID.String())...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id.String())...) } diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 4ae3f13..3db8df3 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -841,7 +841,8 @@ func TestAccAgentsModelResourceImportNoDrift(t *testing.T) { aiProvider := createAccAgentsModelAIProvider(ctx, t, client) // Create the model out-of-band so state is first populated by import (Read). - organizationID := accDefaultOrganizationID(ctx, t, client) + organization := accDefaultOrganization(ctx, t, client) + organizationID := organization.ID exp := codersdk.NewExperimentalClient(client) created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProvider.ID, @@ -884,7 +885,7 @@ resource "coderd_agents_model" "sonnet" { Config: cfg, ResourceName: "coderd_agents_model.sonnet", ImportState: true, - ImportStateId: organizationID.String() + "/" + created.ID.String(), + ImportStateId: organization.Name + "/" + created.ID.String(), ImportStatePersist: true, }, { @@ -1072,12 +1073,16 @@ func skipUnlessAgentsModelEndpoint(ctx context.Context, t *testing.T, client *co } } -func accDefaultOrganizationID(ctx context.Context, t *testing.T, client *codersdk.Client) uuid.UUID { +func accDefaultOrganization(ctx context.Context, t *testing.T, client *codersdk.Client) codersdk.Organization { t.Helper() organizations, err := client.Organizations(ctx) require.NoError(t, err, "list organizations") require.NotEmpty(t, organizations, "first user must belong to an organization") - return organizations[0].ID + return organizations[0] +} + +func accDefaultOrganizationID(ctx context.Context, t *testing.T, client *codersdk.Client) uuid.UUID { + return accDefaultOrganization(ctx, t, client).ID } func createAccAgentsModelAIProviderOfType(ctx context.Context, t *testing.T, client *codersdk.Client, req codersdk.CreateAIProviderRequest) codersdk.AIProvider { From d70bf3ce33e16166266d7404551206b74237b014 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 12:34:37 +0000 Subject: [PATCH 13/13] test(coderd_agents_model): cover unknown organization_id replacement An organization_id that is unknown at plan time (e.g. a terraform_data output) conservatively forces replacement, matching the framework's stock RequiresReplaceIfConfigured semantics. Deferring on unknown instead would downgrade a real organization move to an in-place update that PATCHes the old organization. Prove the conservative plan lands correctly end-to-end: the apply deletes the model from the prior organization and recreates it in the resolved one. --- .../provider/agents_model_resource_test.go | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 3db8df3..087ea93 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -1365,3 +1365,123 @@ resource "coderd_agents_model" "test" { require.True(t, patched.Load(), "expected the adoption apply to PATCH the organization-scoped route") } + +// TestAgentsModelUnknownOrganizationReplacement moves a model to an +// organization_id that is unknown at plan time (a terraform_data output). The +// plan must conservatively force replacement — matching the framework's stock +// RequiresReplaceIfConfigured, which has no unknown guard either — because +// deferring on unknown would downgrade a real organization move to an +// in-place update that PATCHes the old organization and silently ignores the +// configured one. The apply must delete the model from the old organization +// and recreate it in the resolved one. +func TestAgentsModelUnknownOrganizationReplacement(t *testing.T) { + t.Parallel() + + orgA := uuid.New() + orgB := uuid.New() + providerID := uuid.New() + ts := time.Unix(1700000000, 0).UTC() + newModel := func(orgID uuid.UUID) codersdk.ChatModel { + return codersdk.ChatModel{ + ID: uuid.New(), + OrganizationID: orgID, + AIProviderID: providerID, + Model: "claude-3-5-sonnet-20241022", + DisplayName: "Claude Sonnet", + Enabled: true, + ContextLimit: 200000, + CompressionThreshold: 70, + CreatedAt: ts, + UpdatedAt: ts, + } + } + modelA := newModel(orgA) + modelB := newModel(orgB) + + writeJSON := func(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + require.NoError(t, json.NewEncoder(w).Encode(v)) + } + var deletedFromA, createdInB atomic.Bool + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + // Configure fetches the current user and entitlements; the user + // payload decodes acceptably for both. + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"id":"%s","username":"admin","organization_ids":["%s"]}`, uuid.NewString(), orgA) + }) + mux.HandleFunc(fmt.Sprintf("POST /api/v2/organizations/%s/chats/models", orgA), func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, modelA) + }) + mux.HandleFunc(fmt.Sprintf("GET /api/v2/organizations/%s/chats/models/%s", orgA, modelA.ID), func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, modelA) + }) + mux.HandleFunc(fmt.Sprintf("DELETE /api/v2/organizations/%s/chats/models/%s", orgA, modelA.ID), func(w http.ResponseWriter, _ *http.Request) { + deletedFromA.Store(true) + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc(fmt.Sprintf("POST /api/v2/organizations/%s/chats/models", orgB), func(w http.ResponseWriter, _ *http.Request) { + createdInB.Store(true) + writeJSON(w, http.StatusCreated, modelB) + }) + mux.HandleFunc(fmt.Sprintf("GET /api/v2/organizations/%s/chats/models/%s", orgB, modelB.ID), func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, modelB) + }) + mux.HandleFunc(fmt.Sprintf("DELETE /api/v2/organizations/%s/chats/models/%s", orgB, modelB.ID), func(w http.ResponseWriter, _ *http.Request) { + // Post-test destroy cleanup. + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("GET /api/v2/ai/providers/"+providerID.String(), func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, codersdk.AIProvider{ID: providerID, Type: "anthropic"}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + providerBlock := `provider "coderd" { + url = "` + srv.URL + `" + token = "test-token" +} +` + resourceArgs := ` ai_provider_id = "` + providerID.String() + `" + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 +} +` + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: providerBlock + ` +resource "coderd_agents_model" "test" { + organization_id = "` + orgA.String() + `" +` + resourceArgs, + }, + { + // terraform_data.org is new in this step, so its output is + // unknown while the move is planned. + Config: providerBlock + ` +resource "terraform_data" "org" { + input = "` + orgB.String() + `" +} + +resource "coderd_agents_model" "test" { + organization_id = terraform_data.org.output +` + resourceArgs, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("coderd_agents_model.test", plancheck.ResourceActionReplace), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_agents_model.test", tfjsonpath.New("organization_id"), knownvalue.StringExact(orgB.String())), + statecheck.ExpectKnownValue("coderd_agents_model.test", tfjsonpath.New("id"), knownvalue.StringExact(modelB.ID.String())), + }, + }, + }, + }) + + require.True(t, deletedFromA.Load(), "expected the replace to DELETE the model in the prior organization") + require.True(t, createdInB.Load(), "expected the replace to recreate the model in the resolved organization") +}