From e20d0c033dc88fcce9a873fa8503485bdd495b38 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 08:16:35 +0000 Subject: [PATCH 01/12] feat!: make coderd_default_agents_model per-organization --- docs/resources/default_agents_model.md | 37 +-- .../coderd_default_agents_model/import.sh | 6 +- .../coderd_default_agents_model/resource.tf | 9 +- .../provider/default_agents_model_resource.go | 134 +++++++---- .../default_agents_model_resource_test.go | 216 ++++++++++++++---- 5 files changed, 296 insertions(+), 106 deletions(-) diff --git a/docs/resources/default_agents_model.md b/docs/resources/default_agents_model.md index f1be352..4c398a2 100644 --- a/docs/resources/default_agents_model.md +++ b/docs/resources/default_agents_model.md @@ -4,20 +4,25 @@ page_title: "coderd_default_agents_model Resource - terraform-provider-coderd" subcategory: "" description: |- ~> This resource is experimental. Changes are expected, and it is not recommended for production use. - Selects which coderd_agents_model is the deployment-wide default chat model for Coder Agents. - Coder enforces a single default model globally: marking a model as default automatically demotes the previous default in the same operation. Because the default is a global singleton, only one coderd_default_agents_model resource should exist per deployment. - Destroying this resource does not clear the default server-side. Coder always keeps exactly one model marked as default and force-promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. + ~> Warning + This resource is only compatible with Coder version 2.37.0 https://github.com/coder/coder/releases/tag/v2.37.0 and later. + Selects which coderd_agents_model is the default chat model for Coder Agents in an organization. + Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one coderd_default_agents_model resource should exist per organization. + Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. --- # coderd_default_agents_model (Resource) ~> This resource is experimental. Changes are expected, and it is not recommended for production use. -Selects which `coderd_agents_model` is the deployment-wide default chat model for Coder Agents. +~> **Warning** +This resource is only compatible with Coder version [2.37.0](https://github.com/coder/coder/releases/tag/v2.37.0) and later. -Coder enforces a single default model globally: marking a model as default automatically demotes the previous default in the same operation. Because the default is a global singleton, only one `coderd_default_agents_model` resource should exist per deployment. +Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization. -Destroying this resource does not clear the default server-side. Coder always keeps exactly one model marked as default and force-promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. +Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one `coderd_default_agents_model` resource should exist per organization. + +Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. ## Example Usage @@ -43,11 +48,12 @@ resource "coderd_agents_model" "sonnet" { context_limit = 200000 } -# Mark the Sonnet model as the deployment-wide default for Coder Agents. -# Setting a new default automatically demotes the previous one, so only a single -# coderd_default_agents_model resource should exist per deployment. +# Mark the Sonnet model as the default for Coder Agents in its organization. +# Setting a new default automatically demotes the previous one in that +# organization, so use one resource per organization. resource "coderd_default_agents_model" "default" { - model_id = coderd_agents_model.sonnet.id + organization_id = coderd_agents_model.sonnet.organization_id + model_id = coderd_agents_model.sonnet.id } ``` @@ -56,11 +62,12 @@ resource "coderd_default_agents_model" "default" { ### Required -- `model_id` (String) ID of the `coderd_agents_model` to mark as the deployment-wide default. Usually this is `coderd_agents_model..id`. +- `model_id` (String) ID of the `coderd_agents_model` to mark as the organization's default. Usually this is `coderd_agents_model..id`. +- `organization_id` (String) Organization ID whose default Agents model is managed. ### Read-Only -- `id` (String) Constant identifier for the singleton default Agents model pointer. Always `default`. +- `id` (String) Organization ID that identifies this organization's default Agents model selection. ## Import @@ -69,14 +76,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 is a coderd_agents_model UUID, e.g. coderd_agents_model..id. -$ terraform import coderd_default_agents_model.default +# The ID supplied is the organization UUID whose default model should be imported. +$ terraform import coderd_default_agents_model.default ``` 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_default_agents_model.default - id = "" + id = "" } ``` diff --git a/examples/resources/coderd_default_agents_model/import.sh b/examples/resources/coderd_default_agents_model/import.sh index 770fe0d..fc66d37 100644 --- a/examples/resources/coderd_default_agents_model/import.sh +++ b/examples/resources/coderd_default_agents_model/import.sh @@ -1,10 +1,10 @@ -# The ID supplied is a coderd_agents_model UUID, e.g. coderd_agents_model..id. -$ terraform import coderd_default_agents_model.default +# The ID supplied is the organization UUID whose default model should be imported. +$ terraform import coderd_default_agents_model.default ``` 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_default_agents_model.default - id = "" + id = "" } diff --git a/examples/resources/coderd_default_agents_model/resource.tf b/examples/resources/coderd_default_agents_model/resource.tf index 4de8662..1227c8d 100644 --- a/examples/resources/coderd_default_agents_model/resource.tf +++ b/examples/resources/coderd_default_agents_model/resource.tf @@ -19,9 +19,10 @@ resource "coderd_agents_model" "sonnet" { context_limit = 200000 } -# Mark the Sonnet model as the deployment-wide default for Coder Agents. -# Setting a new default automatically demotes the previous one, so only a single -# coderd_default_agents_model resource should exist per deployment. +# Mark the Sonnet model as the default for Coder Agents in its organization. +# Setting a new default automatically demotes the previous one in that +# organization, so use one resource per organization. resource "coderd_default_agents_model" "default" { - model_id = coderd_agents_model.sonnet.id + organization_id = coderd_agents_model.sonnet.organization_id + model_id = coderd_agents_model.sonnet.id } diff --git a/internal/provider/default_agents_model_resource.go b/internal/provider/default_agents_model_resource.go index 426a17a..bdb09b3 100644 --- a/internal/provider/default_agents_model_resource.go +++ b/internal/provider/default_agents_model_resource.go @@ -2,22 +2,24 @@ package provider import ( "context" + "errors" "fmt" + "net/http" "github.com/coder/coder/v2/codersdk" "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-log/tflog" ) -// defaultAgentsModelID is the constant resource ID for the singleton default -// Agents model pointer. Coder enforces exactly one default chat model globally, -// so this resource has no scope key and uses a stable identifier instead. -const defaultAgentsModelID = "default" +// defaultAgentsModelMinVersion is the first Coder release that can include the +// organization-scoped chat model API. +const defaultAgentsModelMinVersion = "2.37.0" var ( _ resource.Resource = &DefaultAgentsModelResource{} @@ -39,8 +41,9 @@ func (r *DefaultAgentsModelResource) experimentalClient() *codersdk.Experimental } type DefaultAgentsModelResourceModel struct { - ID types.String `tfsdk:"id"` - ModelID UUID `tfsdk:"model_id"` + ID UUID `tfsdk:"id"` + OrganizationID UUID `tfsdk:"organization_id"` + ModelID UUID `tfsdk:"model_id"` } func (r *DefaultAgentsModelResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -57,21 +60,32 @@ func (r *DefaultAgentsModelResource) ModifyPlan(ctx context.Context, req resourc func (r *DefaultAgentsModelResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "~> This resource is experimental. Changes are expected, and it is not recommended for production use.\n\n" + - "Selects which `coderd_agents_model` is the deployment-wide default chat model for Coder Agents.\n\n" + - "Coder enforces a single default model globally: marking a model as default automatically demotes the " + - "previous default in the same operation. Because the default is a global singleton, only one " + - "`coderd_default_agents_model` resource should exist per deployment.\n\n" + - "Destroying this resource does not clear the default server-side. Coder always keeps exactly one model " + - "marked as default and force-promotes a replacement when the current default is removed, so deleting this " + - "resource only stops Terraform from managing which model is default.", + "~> **Warning**\nThis resource is only compatible with Coder version [" + defaultAgentsModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + defaultAgentsModelMinVersion + ") and later.\n\n" + + "Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization.\n\n" + + "Coder enforces a single default model per organization: marking a model as default automatically demotes the " + + "previous default in the same operation. Only one `coderd_default_agents_model` resource should exist per organization.\n\n" + + "Destroying this resource does not clear the default server-side. Coder requires a default once models exist " + + "and promotes a replacement when the current default is removed, so deleting this resource only stops " + + "Terraform from managing which model is default.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - MarkdownDescription: "Constant identifier for the singleton default Agents model pointer. Always `default`.", + MarkdownDescription: "Organization ID that identifies this organization's default Agents model selection.", + CustomType: UUIDType, Computed: true, - Default: stringdefault.StaticString(defaultAgentsModelID), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "organization_id": schema.StringAttribute{ + MarkdownDescription: "Organization ID whose default Agents model is managed.", + CustomType: UUIDType, + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "model_id": schema.StringAttribute{ - MarkdownDescription: "ID of the `coderd_agents_model` to mark as the deployment-wide default. Usually this is `coderd_agents_model..id`.", + MarkdownDescription: "ID of the `coderd_agents_model` to mark as the organization's default. Usually this is `coderd_agents_model..id`.", CustomType: UUIDType, Required: true, }, @@ -100,11 +114,13 @@ func (r *DefaultAgentsModelResource) Create(ctx context.Context, req resource.Cr if resp.Diagnostics.HasError() { return } - - tflog.Info(ctx, "setting default Agents model", map[string]any{"model_id": plan.ModelID.ValueString()}) - state, err := r.setDefault(ctx, plan.ModelID.ValueUUID()) + tflog.Info(ctx, "setting default Agents model", map[string]any{ + "organization_id": plan.OrganizationID.ValueString(), + "model_id": plan.ModelID.ValueString(), + }) + state, err := r.setDefault(ctx, plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID()) if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to set default Agents model, got error: %s", err)) + resp.Diagnostics.Append(defaultAgentsModelDiag("set", plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) return } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) @@ -117,8 +133,12 @@ func (r *DefaultAgentsModelResource) Read(ctx context.Context, req resource.Read return } - configs, err := r.experimentalClient().ChatModels(ctx, r.data.DefaultOrganizationID) + configs, err := r.experimentalClient().ChatModels(ctx, state.OrganizationID.ValueUUID()) if err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read default Agents model, got error: %s", err)) return } @@ -131,46 +151,51 @@ 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. + // Coder requires a default whenever any models exist, so reaching here means + // there are no models in this organization. Treat the selection as deleted. resp.Diagnostics.AddWarning("Client Warning", - fmt.Sprintf("No default Agents model found among %d model config(s). Marking as deleted.", len(configs.Models))) + fmt.Sprintf("No default Agents model found among %d model config(s) in organization %s. Marking as deleted.", len(configs.Models), state.OrganizationID.ValueString())) resp.State.RemoveResource(ctx) } func (r *DefaultAgentsModelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan DefaultAgentsModelResourceModel + var plan, state DefaultAgentsModelResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } - tflog.Info(ctx, "updating default Agents model", map[string]any{"model_id": plan.ModelID.ValueString()}) - state, err := r.setDefault(ctx, plan.ModelID.ValueUUID()) + tflog.Info(ctx, "updating default Agents model", map[string]any{ + "organization_id": state.OrganizationID.ValueString(), + "model_id": plan.ModelID.ValueString(), + }) + updated, err := r.setDefault(ctx, state.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID()) if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update default Agents model, got error: %s", err)) + resp.Diagnostics.Append(defaultAgentsModelDiag("update", state.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) return } - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &updated)...) } func (r *DefaultAgentsModelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Coder requires a default once models exist and has no API for unsetting it. tflog.Info(ctx, "deleting coderd_default_agents_model is a no-op; Coder retains its current default model") } func (r *DefaultAgentsModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - // The import ID seeds model_id, but Read immediately overwrites it with - // whichever model Coder currently reports as default, so any value (even a - // stale or arbitrary UUID) self-corrects. Import does not promote a model. - resource.ImportStatePassthroughID(ctx, path.Root("model_id"), req, resp) + // Import by organization ID. Read resolves the organization's current + // default model without promoting or otherwise modifying any model. + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), req.ID)...) } -// setDefault marks the given model config as the deployment-wide default and -// returns the resulting resource state. The request carries only is_default; +// setDefault marks the given model config as the default for its organization +// and returns the resulting resource state. The request carries only is_default; // 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().UpdateChatModel(ctx, r.data.DefaultOrganizationID, modelID, codersdk.UpdateChatModelRequest{ +// previous default in that organization. +func (r *DefaultAgentsModelResource) setDefault(ctx context.Context, organizationID, modelID uuid.UUID) (DefaultAgentsModelResourceModel, error) { + updated, err := r.experimentalClient().UpdateChatModel(ctx, organizationID, modelID, codersdk.UpdateChatModelRequest{ IsDefault: new(true), }) if err != nil { @@ -180,11 +205,32 @@ 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. +// default into resource state. The organization UUID is the natural identity +// because each organization has at most one default model. func stateFromDefaultModelConfig(config codersdk.ChatModel) DefaultAgentsModelResourceModel { return DefaultAgentsModelResourceModel{ - ID: types.StringValue(defaultAgentsModelID), - ModelID: UUIDValue(config.ID), + ID: UUIDValue(config.OrganizationID), + OrganizationID: UUIDValue(config.OrganizationID), + ModelID: UUIDValue(config.ID), + } +} + +func defaultAgentsModelDiag(action string, organizationID, modelID 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/%s", organizationID, modelID) + diags.AddError( + "Unsupported Coder Version", + fmt.Sprintf("Unable to %s the default Agents model: the deployment returned 404 for %s. "+ + "This endpoint requires Coder version %s or later; upgrade the deployment, or remove "+ + "`coderd_default_agents_model` from your configuration. Original error: %s", + action, endpoint, defaultAgentsModelMinVersion, err), + ) + return diags } + + diags.AddError("Client Error", fmt.Sprintf("Unable to %s the default Agents model, got error: %s", action, err)) + return diags } diff --git a/internal/provider/default_agents_model_resource_test.go b/internal/provider/default_agents_model_resource_test.go index 60f9719..ea6ea30 100644 --- a/internal/provider/default_agents_model_resource_test.go +++ b/internal/provider/default_agents_model_resource_test.go @@ -2,7 +2,9 @@ package provider import ( "context" + "errors" "fmt" + "net/http" "os" "testing" @@ -18,11 +20,19 @@ import ( func TestDefaultAgentsModelStateFromModelConfig(t *testing.T) { t.Parallel() - id := uuid.New() - 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()) + organizationID := uuid.New() + modelID := uuid.New() + state := stateFromDefaultModelConfig(codersdk.ChatModel{ + ID: modelID, + OrganizationID: organizationID, + IsDefault: true, + }) + require.Equal(t, organizationID, state.ID.ValueUUID()) + require.Equal(t, organizationID.String(), state.ID.ValueString()) + require.Equal(t, organizationID, state.OrganizationID.ValueUUID()) + require.Equal(t, organizationID.String(), state.OrganizationID.ValueString()) + require.Equal(t, modelID, state.ModelID.ValueUUID()) + require.Equal(t, modelID.String(), state.ModelID.ValueString()) } // TestDefaultAgentsModelResourceValidationDefersUnknownConfig checks validation @@ -45,7 +55,8 @@ variable "model_id" { } resource "coderd_default_agents_model" "default" { - model_id = var.model_id + organization_id = "` + uuid.NewString() + `" + model_id = var.model_id } ` resource.Test(t, resource.TestCase{ @@ -66,6 +77,49 @@ resource "coderd_default_agents_model" "default" { }) } +// TestDefaultAgentsModelResourceDefersUnknownOrganizationID checks planning +// succeeds when organization_id comes from another resource and is therefore +// unknown until apply. +func TestDefaultAgentsModelResourceDefersUnknownOrganizationID(t *testing.T) { + t.Parallel() + + 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_default_agents_model" "default" { + organization_id = terraform_data.organization.output + model_id = "` + uuid.NewString() + `" +} +` + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg, + ConfigVariables: config.Variables{ + "organization_id": config.StringVariable(uuid.NewString()), + }, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + }) +} + func TestAccDefaultAgentsModelResource(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { @@ -73,7 +127,8 @@ func TestAccDefaultAgentsModelResource(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "default_agents_model_acc", integration.UseLicense) - skipUnlessAgentsModelEndpoint(ctx, t, client) + organizationID := accDefaultOrganizationID(ctx, t, client) + skipIfDefaultAgentsModelUnsupported(ctx, t, client, organizationID) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) cfg := func(defaultModel string) string { @@ -96,9 +151,10 @@ resource "coderd_agents_model" "opus" { } resource "coderd_default_agents_model" "default" { - model_id = coderd_agents_model.%s.id + organization_id = %q + model_id = coderd_agents_model.%s.id } -`, client.URL.String(), client.SessionToken(), aiProvider.ID.String(), aiProvider.ID.String(), defaultModel) +`, client.URL.String(), client.SessionToken(), aiProvider.ID.String(), aiProvider.ID.String(), organizationID.String(), defaultModel) } resource.Test(t, resource.TestCase{ @@ -109,9 +165,10 @@ resource "coderd_default_agents_model" "default" { { Config: cfg("sonnet"), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "id", "default"), + resource.TestCheckResourceAttr("coderd_default_agents_model.default", "id", organizationID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.default", "organization_id", organizationID.String()), resource.TestCheckResourceAttrPair("coderd_default_agents_model.default", "model_id", "coderd_agents_model.sonnet", "id"), - checkServerDefaultMatchesResource(ctx, t, client), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), ), }, { @@ -120,7 +177,7 @@ resource "coderd_default_agents_model" "default" { Config: cfg("opus"), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrPair("coderd_default_agents_model.default", "model_id", "coderd_agents_model.opus", "id"), - checkServerDefaultMatchesResource(ctx, t, client), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), ), }, { @@ -129,17 +186,11 @@ resource "coderd_default_agents_model" "default" { PlanOnly: true, }, { - // Import by the model_id UUID; Read reconciles to the current default. + // Import by organization UUID; Read resolves its current default. ResourceName: "coderd_default_agents_model.default", ImportState: true, ImportStateVerify: true, - ImportStateIdFunc: func(s *terraform.State) (string, error) { - rs, ok := s.RootModule().Resources["coderd_default_agents_model.default"] - if !ok { - return "", fmt.Errorf("coderd_default_agents_model.default not found in state") - } - return rs.Primary.Attributes["model_id"], nil - }, + ImportStateId: organizationID.String(), }, }, }) @@ -159,12 +210,12 @@ 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) + skipIfDefaultAgentsModelUnsupported(ctx, t, client, organizationID) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) - sonnet := createAccChatModel(ctx, t, client, aiProvider.ID, "claude-3-5-sonnet-20241022") - opus := createAccChatModel(ctx, t, client, aiProvider.ID, "claude-3-opus-20240229") + sonnet := createAccChatModel(ctx, t, client, organizationID, aiProvider.ID, "claude-3-5-sonnet-20241022") + opus := createAccChatModel(ctx, t, client, organizationID, aiProvider.ID, "claude-3-opus-20240229") exp := codersdk.NewExperimentalClient(client) cfg := fmt.Sprintf(` @@ -174,9 +225,10 @@ provider "coderd" { } resource "coderd_default_agents_model" "default" { - model_id = %q + organization_id = %q + model_id = %q } -`, client.URL.String(), client.SessionToken(), sonnet.ID.String()) +`, client.URL.String(), client.SessionToken(), organizationID.String(), sonnet.ID.String()) resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -185,7 +237,7 @@ resource "coderd_default_agents_model" "default" { CheckDestroy: func(*terraform.State) error { // Destroying the pointer must not clear the server default: Coder still // reports exactly one default, and it remains the last model we selected. - defaults := serverDefaultModelIDs(ctx, t, client) + defaults := serverDefaultModelIDs(ctx, t, client, organizationID) if len(defaults) != 1 { return fmt.Errorf("expected exactly one default model after destroy, got %d: %v", len(defaults), defaults) } @@ -199,7 +251,7 @@ resource "coderd_default_agents_model" "default" { Config: cfg, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("coderd_default_agents_model.default", "model_id", sonnet.ID.String()), - checkServerDefaultMatchesResource(ctx, t, client), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), ), }, { @@ -220,18 +272,102 @@ resource "coderd_default_agents_model" "default" { Config: cfg, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("coderd_default_agents_model.default", "model_id", sonnet.ID.String()), - checkServerDefaultMatchesResource(ctx, t, client), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), ), }, }, }) } +func TestAccDefaultAgentsModelResourceOrganizationIsolation(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + ctx := t.Context() + client := integration.StartCoder(ctx, t, "default_agents_model_org_isolation_acc", integration.UseLicense) + defaultOrganizationID := accDefaultOrganizationID(ctx, t, client) + skipIfDefaultAgentsModelUnsupported(ctx, t, client, defaultOrganizationID) + + otherOrganization, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "default-model-isolation", + DisplayName: "Default Model Isolation", + }) + require.NoError(t, err, "create second organization") + t.Cleanup(func() { + _ = client.DeleteOrganization(context.WithoutCancel(t.Context()), otherOrganization.ID.String()) + }) + + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + createAccChatModel(ctx, t, client, defaultOrganizationID, aiProvider.ID, "claude-3-5-sonnet-20241022") + defaultOrgSecond := createAccChatModel(ctx, t, client, defaultOrganizationID, aiProvider.ID, "claude-3-opus-20240229") + otherOrgFirst := createAccChatModel(ctx, t, client, otherOrganization.ID, aiProvider.ID, "claude-3-5-sonnet-20241022") + createAccChatModel(ctx, t, client, otherOrganization.ID, aiProvider.ID, "claude-3-opus-20240229") + + cfg := fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_default_agents_model" "default_org" { + organization_id = %q + model_id = %q +} + +resource "coderd_default_agents_model" "other_org" { + organization_id = %q + model_id = %q +} +`, client.URL.String(), client.SessionToken(), defaultOrganizationID.String(), defaultOrgSecond.ID.String(), otherOrganization.ID.String(), otherOrgFirst.ID.String()) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_default_agents_model.default_org", "id", defaultOrganizationID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.default_org", "organization_id", defaultOrganizationID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.default_org", "model_id", defaultOrgSecond.ID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.other_org", "id", otherOrganization.ID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.other_org", "organization_id", otherOrganization.ID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.other_org", "model_id", otherOrgFirst.ID.String()), + checkServerDefaultMatchesResource(ctx, t, client, defaultOrganizationID, "coderd_default_agents_model.default_org"), + checkServerDefaultMatchesResource(ctx, t, client, otherOrganization.ID, "coderd_default_agents_model.other_org"), + ), + }, + { + Config: cfg, + PlanOnly: true, + }, + }, + }) +} + +func skipIfDefaultAgentsModelUnsupported(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID uuid.UUID) { + t.Helper() + + // Main devel builds report the previous minor's version, so a semver minimum + // cannot distinguish them from releases that do not have this route. + _, err := codersdk.NewExperimentalClient(client).ChatModels(ctx, organizationID) + if err == nil { + return + } + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { + t.Skipf("deployment does not support org-scoped chat models") + } + require.NoError(t, err, "probe org-scoped chat models") +} + // createAccChatModel creates a chat model config directly via the SDK so it // exists independently of any Terraform-managed resource. -func createAccChatModel(ctx context.Context, t *testing.T, client *codersdk.Client, aiProviderID uuid.UUID, model string) codersdk.ChatModel { +func createAccChatModel(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID, aiProviderID uuid.UUID, model string) codersdk.ChatModel { t.Helper() - organizationID := accDefaultOrganizationID(ctx, t, client) exp := codersdk.NewExperimentalClient(client) created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ AIProviderID: &aiProviderID, @@ -244,11 +380,11 @@ func createAccChatModel(ctx context.Context, t *testing.T, client *codersdk.Clie return created } -// serverDefaultModelIDs returns the IDs of every model Coder reports as default. -// 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 { +// serverDefaultModelIDs returns the IDs of every model Coder reports as default +// in one organization. Coder enforces a single default per organization, so a +// healthy organization with models returns one ID. +func serverDefaultModelIDs(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID uuid.UUID) []uuid.UUID { t.Helper() - organizationID := accDefaultOrganizationID(ctx, t, client) exp := codersdk.NewExperimentalClient(client) configs, err := exp.ChatModels(ctx, organizationID) require.NoError(t, err, "list chat models") @@ -262,16 +398,16 @@ func serverDefaultModelIDs(ctx context.Context, t *testing.T, client *codersdk.C } // checkServerDefaultMatchesResource asserts Coder reports exactly one default -// model and that it matches the resource's model_id attribute in state. -func checkServerDefaultMatchesResource(ctx context.Context, t *testing.T, client *codersdk.Client) resource.TestCheckFunc { +// model in the organization and that it matches the named resource's model_id. +func checkServerDefaultMatchesResource(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID uuid.UUID, resourceName string) resource.TestCheckFunc { return func(s *terraform.State) error { - defaults := serverDefaultModelIDs(ctx, t, client) + defaults := serverDefaultModelIDs(ctx, t, client, organizationID) if len(defaults) != 1 { - return fmt.Errorf("expected exactly one default model, got %d: %v", len(defaults), defaults) + return fmt.Errorf("expected exactly one default model in organization %s, got %d: %v", organizationID, len(defaults), defaults) } - rs, ok := s.RootModule().Resources["coderd_default_agents_model.default"] + rs, ok := s.RootModule().Resources[resourceName] if !ok { - return fmt.Errorf("coderd_default_agents_model.default not found in state") + return fmt.Errorf("%s not found in state", resourceName) } if got := rs.Primary.Attributes["model_id"]; got != defaults[0].String() { return fmt.Errorf("server default %s does not match resource model_id %s", defaults[0], got) From 4db76bce4bd6bbd59526aa61e7f44e22b4e36c6a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 12:23:24 +0000 Subject: [PATCH 02/12] fix(coderd_default_agents_model): migrate legacy state --- .../provider/default_agents_model_resource.go | 93 ++++++++++++- .../default_agents_model_resource_test.go | 128 ++++++++++++++++++ 2 files changed, 214 insertions(+), 7 deletions(-) diff --git a/internal/provider/default_agents_model_resource.go b/internal/provider/default_agents_model_resource.go index bdb09b3..5ff7970 100644 --- a/internal/provider/default_agents_model_resource.go +++ b/internal/provider/default_agents_model_resource.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) @@ -22,10 +23,11 @@ import ( const defaultAgentsModelMinVersion = "2.37.0" var ( - _ resource.Resource = &DefaultAgentsModelResource{} - _ resource.ResourceWithConfigure = &DefaultAgentsModelResource{} - _ resource.ResourceWithImportState = &DefaultAgentsModelResource{} - _ resource.ResourceWithModifyPlan = &DefaultAgentsModelResource{} + _ resource.Resource = &DefaultAgentsModelResource{} + _ resource.ResourceWithConfigure = &DefaultAgentsModelResource{} + _ resource.ResourceWithImportState = &DefaultAgentsModelResource{} + _ resource.ResourceWithModifyPlan = &DefaultAgentsModelResource{} + _ resource.ResourceWithUpgradeState = &DefaultAgentsModelResource{} ) func NewDefaultAgentsModelResource() resource.Resource { @@ -46,6 +48,14 @@ type DefaultAgentsModelResourceModel struct { ModelID UUID `tfsdk:"model_id"` } +// defaultAgentsModelResourceModelV0 is the model for schema version 0, when the +// resource tracked the deployment-wide default under the constant id "default" +// with no organization. +type defaultAgentsModelResourceModelV0 struct { + ID types.String `tfsdk:"id"` + ModelID UUID `tfsdk:"model_id"` +} + func (r *DefaultAgentsModelResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_default_agents_model" } @@ -59,6 +69,9 @@ func (r *DefaultAgentsModelResource) ModifyPlan(ctx context.Context, req resourc func (r *DefaultAgentsModelResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ + // Version 1 replaced the constant id "default" (schema version 0) with + // the organization UUID when defaults became per-organization. + Version: 1, MarkdownDescription: "~> This resource is experimental. Changes are expected, and it is not recommended for production use.\n\n" + "~> **Warning**\nThis resource is only compatible with Coder version [" + defaultAgentsModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + defaultAgentsModelMinVersion + ") and later.\n\n" + "Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization.\n\n" + @@ -93,6 +106,50 @@ func (r *DefaultAgentsModelResource) Schema(ctx context.Context, req resource.Sc } } +// defaultAgentsModelSchemaV0 is the resource schema at version 0 (provider +// <= 0.0.23), when the default was deployment-wide: id held the constant +// string "default" and there was no organization_id. Only the attribute types +// matter here; id must be a plain string because "default" is not a UUID. +func defaultAgentsModelSchemaV0() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "model_id": schema.StringAttribute{ + CustomType: UUIDType, + Required: true, + }, + }, + } +} + +func (r *DefaultAgentsModelResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + priorSchema := defaultAgentsModelSchemaV0() + return map[int64]resource.StateUpgrader{ + 0: { + PriorSchema: &priorSchema, + StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { + var prior defaultAgentsModelResourceModelV0 + resp.Diagnostics.Append(req.State.Get(ctx, &prior)...) + if resp.Diagnostics.HasError() { + return + } + // Leave id and organization_id null rather than resolving them + // here: Read and Update already treat a null organization_id as + // legacy state and recover the organization the v0 resource + // managed (Coder's default organization) through the + // compatibility route, rewriting both fields. + resp.Diagnostics.Append(resp.State.Set(ctx, &DefaultAgentsModelResourceModel{ + ID: NewUUIDNull(), + OrganizationID: NewUUIDNull(), + ModelID: prior.ModelID, + })...) + }, + }, + } +} + func (r *DefaultAgentsModelResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return @@ -133,7 +190,16 @@ func (r *DefaultAgentsModelResource) Read(ctx context.Context, req resource.Read return } - configs, err := r.experimentalClient().ChatModels(ctx, state.OrganizationID.ValueUUID()) + var configs codersdk.OrganizationChatModelsResponse + var err error + if state.OrganizationID.IsNull() || state.OrganizationID.IsUnknown() { + // Legacy state predates organization_id. The old unscoped resource managed + // models in Coder's default organization, which may differ from the + // provider user's first organization. + configs, err = legacyDefaultOrganizationChatModels(ctx, r.data.Client) + } else { + configs, err = r.experimentalClient().ChatModels(ctx, state.OrganizationID.ValueUUID()) + } if err != nil { if isNotFound(err) { resp.State.RemoveResource(ctx) @@ -170,9 +236,22 @@ func (r *DefaultAgentsModelResource) Update(ctx context.Context, req resource.Up "organization_id": state.OrganizationID.ValueString(), "model_id": plan.ModelID.ValueString(), }) - updated, err := r.setDefault(ctx, state.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID()) + organizationID := state.OrganizationID.ValueUUID() + if state.OrganizationID.IsNull() || state.OrganizationID.IsUnknown() { + config, found, err := legacyDefaultOrganizationChatModel(ctx, r.data.Client, plan.ModelID.ValueUUID()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy default Agents model state, got error: %s", err)) + return + } + if !found { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update default Agents model because model %s no longer exists.", plan.ModelID.ValueString())) + return + } + organizationID = config.OrganizationID + } + updated, err := r.setDefault(ctx, organizationID, plan.ModelID.ValueUUID()) if err != nil { - resp.Diagnostics.Append(defaultAgentsModelDiag("update", state.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) + resp.Diagnostics.Append(defaultAgentsModelDiag("update", organizationID, plan.ModelID.ValueUUID(), err)...) return } resp.Diagnostics.Append(resp.State.Set(ctx, &updated)...) diff --git a/internal/provider/default_agents_model_resource_test.go b/internal/provider/default_agents_model_resource_test.go index ea6ea30..bba64bd 100644 --- a/internal/provider/default_agents_model_resource_test.go +++ b/internal/provider/default_agents_model_resource_test.go @@ -11,6 +11,9 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" @@ -35,6 +38,50 @@ func TestDefaultAgentsModelStateFromModelConfig(t *testing.T) { require.Equal(t, modelID.String(), state.ModelID.ValueString()) } +// TestDefaultAgentsModelUpgradeStateV0 upgrades legacy (schema version 0) +// state, which held the constant id "default" and no organization_id, to +// version 1: id and organization_id become null (Read resolves them through +// the legacy compatibility route) and model_id is preserved. +func TestDefaultAgentsModelUpgradeStateV0(t *testing.T) { + t.Parallel() + + ctx := t.Context() + modelID := uuid.New() + + r := &DefaultAgentsModelResource{} + upgrader, ok := r.UpgradeState(ctx)[0] + require.True(t, ok) + require.NotNil(t, upgrader.PriorSchema) + require.NotNil(t, upgrader.StateUpgrader) + + priorRaw := tftypes.NewValue(upgrader.PriorSchema.Type().TerraformType(ctx), map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "default"), + "model_id": tftypes.NewValue(tftypes.String, modelID.String()), + }) + + var schemaResp fwresource.SchemaResponse + r.Schema(ctx, fwresource.SchemaRequest{}, &schemaResp) + require.False(t, schemaResp.Diagnostics.HasError(), schemaResp.Diagnostics) + + req := fwresource.UpgradeStateRequest{ + State: &tfsdk.State{Raw: priorRaw, Schema: *upgrader.PriorSchema}, + } + resp := &fwresource.UpgradeStateResponse{ + State: tfsdk.State{ + Raw: tftypes.NewValue(schemaResp.Schema.Type().TerraformType(ctx), nil), + Schema: schemaResp.Schema, + }, + } + upgrader.StateUpgrader(ctx, req, resp) + require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics) + + var upgraded DefaultAgentsModelResourceModel + require.False(t, resp.State.Get(ctx, &upgraded).HasError()) + require.True(t, upgraded.ID.IsNull()) + require.True(t, upgraded.OrganizationID.IsNull()) + require.Equal(t, modelID, upgraded.ModelID.ValueUUID()) +} + // TestDefaultAgentsModelResourceValidationDefersUnknownConfig checks validation // passes when model_id is unknown, like when it comes from an unset variable. func TestDefaultAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { @@ -120,6 +167,87 @@ resource "coderd_default_agents_model" "default" { }) } +// TestAccDefaultAgentsModelResourceUpgradeFromV0_0_23 creates schema version 0 +// state with the last provider release that used the deployment-wide default +// model, then switches to the in-repository provider. The current provider must +// upgrade id = "default", recover the default organization during Read, and +// converge without changing the selected server-side model. +func TestAccDefaultAgentsModelResourceUpgradeFromV0_0_23(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + ctx := t.Context() + client := integration.StartCoder(ctx, t, "default_agents_model_upgrade_v0_0_23_acc", integration.UseLicense) + organizationID := accDefaultOrganizationID(ctx, t, client) + skipIfDefaultAgentsModelUnsupported(ctx, t, client, organizationID) + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + model := createAccChatModel(ctx, t, client, organizationID, aiProvider.ID, "claude-3-5-sonnet-20241022") + + // The first model in an organization is automatically the default. Import it + // with v0.0.23 rather than creating coderd_default_agents_model: that release's + // unscoped PATCH route no longer exists in Coder 2.37, while its import and + // compatibility-list Read paths still produce the exact released v0 state. + cfg := fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_default_agents_model" "default" { + model_id = %q +} +`, client.URL.String(), client.SessionToken(), model.ID.String()) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: cfg, + ExternalProviders: map[string]resource.ExternalProvider{ + "coderd": { + Source: "coder/coderd", + VersionConstraint: "= 0.0.23", + }, + }, + ResourceName: "coderd_default_agents_model.default", + ImportState: true, + ImportStateId: model.ID.String(), + ImportStatePersist: true, + ImportStateCheck: func(states []*terraform.InstanceState) error { + if len(states) != 1 { + return fmt.Errorf("expected one imported resource, got %d", len(states)) + } + if got := states[0].Attributes["id"]; got != "default" { + return fmt.Errorf("expected legacy id %q, got %q", "default", got) + } + if got := states[0].Attributes["model_id"]; got != model.ID.String() { + return fmt.Errorf("expected legacy model_id %q, got %q", model.ID, got) + } + return nil + }, + }, + { + Config: cfg, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_default_agents_model.default", "id", organizationID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.default", "organization_id", organizationID.String()), + resource.TestCheckResourceAttr("coderd_default_agents_model.default", "model_id", model.ID.String()), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), + ), + }, + { + Config: cfg, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + PlanOnly: true, + }, + }, + }) +} + func TestAccDefaultAgentsModelResource(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { From 6d05654db17e293184afcaed7f17dfb3f1c9c431 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 14:30:51 +0000 Subject: [PATCH 03/12] feat!: rename default Agents model resource --- ...gents_model.md => agents_default_model.md} | 14 +- .../import.sh | 4 +- .../resource.tf | 2 +- integration/agents-model-test/main.tf | 11 +- integration/integration_test.go | 2 +- ...ce.go => agents_default_model_resource.go} | 160 ++++--------- ... => agents_default_model_resource_test.go} | 212 ++++-------------- internal/provider/provider.go | 2 +- 8 files changed, 101 insertions(+), 306 deletions(-) rename docs/resources/{default_agents_model.md => agents_default_model.md} (89%) rename examples/resources/{coderd_default_agents_model => coderd_agents_default_model}/import.sh (72%) rename examples/resources/{coderd_default_agents_model => coderd_agents_default_model}/resource.tf (94%) rename internal/provider/{default_agents_model_resource.go => agents_default_model_resource.go} (56%) rename internal/provider/{default_agents_model_resource_test.go => agents_default_model_resource_test.go} (63%) diff --git a/docs/resources/default_agents_model.md b/docs/resources/agents_default_model.md similarity index 89% rename from docs/resources/default_agents_model.md rename to docs/resources/agents_default_model.md index 4c398a2..fb31dce 100644 --- a/docs/resources/default_agents_model.md +++ b/docs/resources/agents_default_model.md @@ -1,17 +1,17 @@ --- # generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "coderd_default_agents_model Resource - terraform-provider-coderd" +page_title: "coderd_agents_default_model Resource - terraform-provider-coderd" subcategory: "" description: |- ~> This resource is experimental. Changes are expected, and it is not recommended for production use. ~> Warning This resource is only compatible with Coder version 2.37.0 https://github.com/coder/coder/releases/tag/v2.37.0 and later. Selects which coderd_agents_model is the default chat model for Coder Agents in an organization. - Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one coderd_default_agents_model resource should exist per organization. + Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one coderd_agents_default_model resource should exist per organization. Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. --- -# coderd_default_agents_model (Resource) +# coderd_agents_default_model (Resource) ~> This resource is experimental. Changes are expected, and it is not recommended for production use. @@ -20,7 +20,7 @@ This resource is only compatible with Coder version [2.37.0](https://github.com/ Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization. -Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one `coderd_default_agents_model` resource should exist per organization. +Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one `coderd_agents_default_model` resource should exist per organization. Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. @@ -51,7 +51,7 @@ resource "coderd_agents_model" "sonnet" { # Mark the Sonnet model as the default for Coder Agents in its organization. # Setting a new default automatically demotes the previous one in that # organization, so use one resource per organization. -resource "coderd_default_agents_model" "default" { +resource "coderd_agents_default_model" "default" { organization_id = coderd_agents_model.sonnet.organization_id model_id = coderd_agents_model.sonnet.id } @@ -77,13 +77,13 @@ The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/c ```shell # The ID supplied is the organization UUID whose default model should be imported. -$ terraform import coderd_default_agents_model.default +$ terraform import coderd_agents_default_model.default ``` 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_default_agents_model.default + to = coderd_agents_default_model.default id = "" } ``` diff --git a/examples/resources/coderd_default_agents_model/import.sh b/examples/resources/coderd_agents_default_model/import.sh similarity index 72% rename from examples/resources/coderd_default_agents_model/import.sh rename to examples/resources/coderd_agents_default_model/import.sh index fc66d37..e10e3a0 100644 --- a/examples/resources/coderd_default_agents_model/import.sh +++ b/examples/resources/coderd_agents_default_model/import.sh @@ -1,10 +1,10 @@ # The ID supplied is the organization UUID whose default model should be imported. -$ terraform import coderd_default_agents_model.default +$ terraform import coderd_agents_default_model.default ``` 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_default_agents_model.default + to = coderd_agents_default_model.default id = "" } diff --git a/examples/resources/coderd_default_agents_model/resource.tf b/examples/resources/coderd_agents_default_model/resource.tf similarity index 94% rename from examples/resources/coderd_default_agents_model/resource.tf rename to examples/resources/coderd_agents_default_model/resource.tf index 1227c8d..8e26719 100644 --- a/examples/resources/coderd_default_agents_model/resource.tf +++ b/examples/resources/coderd_agents_default_model/resource.tf @@ -22,7 +22,7 @@ resource "coderd_agents_model" "sonnet" { # Mark the Sonnet model as the default for Coder Agents in its organization. # Setting a new default automatically demotes the previous one in that # organization, so use one resource per organization. -resource "coderd_default_agents_model" "default" { +resource "coderd_agents_default_model" "default" { organization_id = coderd_agents_model.sonnet.organization_id model_id = coderd_agents_model.sonnet.id } diff --git a/integration/agents-model-test/main.tf b/integration/agents-model-test/main.tf index 873c0dd..c8ae57b 100644 --- a/integration/agents-model-test/main.tf +++ b/integration/agents-model-test/main.tf @@ -111,9 +111,10 @@ resource "coderd_agents_model" "gpt_mini" { }) } -# Select Claude Sonnet as the deployment-wide default. Coder auto-promotes the -# first model created (claude_opus) to default, so this resource demotes it and -# proves the pointer overrides the server's automatic choice end-to-end. -resource "coderd_default_agents_model" "default" { - model_id = coderd_agents_model.claude_sonnet.id +# Select Claude Sonnet as the default in its organization. Coder auto-promotes +# the first model created (claude_opus), so this resource demotes it and proves +# the pointer overrides the server's automatic choice end-to-end. +resource "coderd_agents_default_model" "default" { + organization_id = coderd_agents_model.claude_sonnet.organization_id + model_id = coderd_agents_model.claude_sonnet.id } diff --git a/integration/integration_test.go b/integration/integration_test.go index bceeed4..47b8206 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -292,7 +292,7 @@ func TestIntegration(t *testing.T) { t.Errorf("model_config for %s mismatch (-want +got):\n%s", m.Model, diff) } } - // coderd_default_agents_model.default points at claude_sonnet, which + // coderd_agents_default_model.default points at claude_sonnet, which // demotes the auto-promoted claude_opus, so Sonnet is the sole default. assert.Equal(t, []string{"claude-sonnet-4-6"}, defaults) }, diff --git a/internal/provider/default_agents_model_resource.go b/internal/provider/agents_default_model_resource.go similarity index 56% rename from internal/provider/default_agents_model_resource.go rename to internal/provider/agents_default_model_resource.go index 5ff7970..22f2780 100644 --- a/internal/provider/default_agents_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -14,69 +14,56 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) -// defaultAgentsModelMinVersion is the first Coder release that can include the +// agentsDefaultModelMinVersion is the first Coder release that can include the // organization-scoped chat model API. -const defaultAgentsModelMinVersion = "2.37.0" +const agentsDefaultModelMinVersion = "2.37.0" var ( - _ resource.Resource = &DefaultAgentsModelResource{} - _ resource.ResourceWithConfigure = &DefaultAgentsModelResource{} - _ resource.ResourceWithImportState = &DefaultAgentsModelResource{} - _ resource.ResourceWithModifyPlan = &DefaultAgentsModelResource{} - _ resource.ResourceWithUpgradeState = &DefaultAgentsModelResource{} + _ resource.Resource = &AgentsDefaultModelResource{} + _ resource.ResourceWithConfigure = &AgentsDefaultModelResource{} + _ resource.ResourceWithImportState = &AgentsDefaultModelResource{} + _ resource.ResourceWithModifyPlan = &AgentsDefaultModelResource{} ) -func NewDefaultAgentsModelResource() resource.Resource { - return &DefaultAgentsModelResource{} +func NewAgentsDefaultModelResource() resource.Resource { + return &AgentsDefaultModelResource{} } -type DefaultAgentsModelResource struct { +type AgentsDefaultModelResource struct { data *CoderdProviderData } -func (r *DefaultAgentsModelResource) experimentalClient() *codersdk.ExperimentalClient { +func (r *AgentsDefaultModelResource) experimentalClient() *codersdk.ExperimentalClient { return codersdk.NewExperimentalClient(r.data.Client) } -type DefaultAgentsModelResourceModel struct { +type AgentsDefaultModelResourceModel struct { ID UUID `tfsdk:"id"` OrganizationID UUID `tfsdk:"organization_id"` ModelID UUID `tfsdk:"model_id"` } -// defaultAgentsModelResourceModelV0 is the model for schema version 0, when the -// resource tracked the deployment-wide default under the constant id "default" -// with no organization. -type defaultAgentsModelResourceModelV0 struct { - ID types.String `tfsdk:"id"` - ModelID UUID `tfsdk:"model_id"` +func (r *AgentsDefaultModelResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_agents_default_model" } -func (r *DefaultAgentsModelResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_default_agents_model" -} - -func (r *DefaultAgentsModelResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { +func (r *AgentsDefaultModelResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { resp.Diagnostics.AddWarning( "Experimental Resource", - "coderd_default_agents_model is experimental. Changes are expected, and it is not recommended for production use.", + "coderd_agents_default_model is experimental. Changes are expected, and it is not recommended for production use.", ) } -func (r *DefaultAgentsModelResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *AgentsDefaultModelResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - // Version 1 replaced the constant id "default" (schema version 0) with - // the organization UUID when defaults became per-organization. - Version: 1, MarkdownDescription: "~> This resource is experimental. Changes are expected, and it is not recommended for production use.\n\n" + - "~> **Warning**\nThis resource is only compatible with Coder version [" + defaultAgentsModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + defaultAgentsModelMinVersion + ") and later.\n\n" + + "~> **Warning**\nThis resource is only compatible with Coder version [" + agentsDefaultModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + agentsDefaultModelMinVersion + ") and later.\n\n" + "Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization.\n\n" + "Coder enforces a single default model per organization: marking a model as default automatically demotes the " + - "previous default in the same operation. Only one `coderd_default_agents_model` resource should exist per organization.\n\n" + + "previous default in the same operation. Only one `coderd_agents_default_model` resource should exist per organization.\n\n" + "Destroying this resource does not clear the default server-side. Coder requires a default once models exist " + "and promotes a replacement when the current default is removed, so deleting this resource only stops " + "Terraform from managing which model is default.", @@ -106,51 +93,7 @@ func (r *DefaultAgentsModelResource) Schema(ctx context.Context, req resource.Sc } } -// defaultAgentsModelSchemaV0 is the resource schema at version 0 (provider -// <= 0.0.23), when the default was deployment-wide: id held the constant -// string "default" and there was no organization_id. Only the attribute types -// matter here; id must be a plain string because "default" is not a UUID. -func defaultAgentsModelSchemaV0() schema.Schema { - return schema.Schema{ - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "model_id": schema.StringAttribute{ - CustomType: UUIDType, - Required: true, - }, - }, - } -} - -func (r *DefaultAgentsModelResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { - priorSchema := defaultAgentsModelSchemaV0() - return map[int64]resource.StateUpgrader{ - 0: { - PriorSchema: &priorSchema, - StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { - var prior defaultAgentsModelResourceModelV0 - resp.Diagnostics.Append(req.State.Get(ctx, &prior)...) - if resp.Diagnostics.HasError() { - return - } - // Leave id and organization_id null rather than resolving them - // here: Read and Update already treat a null organization_id as - // legacy state and recover the organization the v0 resource - // managed (Coder's default organization) through the - // compatibility route, rewriting both fields. - resp.Diagnostics.Append(resp.State.Set(ctx, &DefaultAgentsModelResourceModel{ - ID: NewUUIDNull(), - OrganizationID: NewUUIDNull(), - ModelID: prior.ModelID, - })...) - }, - }, - } -} - -func (r *DefaultAgentsModelResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { +func (r *AgentsDefaultModelResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } @@ -165,8 +108,8 @@ func (r *DefaultAgentsModelResource) Configure(ctx context.Context, req resource r.data = data } -func (r *DefaultAgentsModelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan DefaultAgentsModelResourceModel +func (r *AgentsDefaultModelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan AgentsDefaultModelResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return @@ -177,29 +120,20 @@ func (r *DefaultAgentsModelResource) Create(ctx context.Context, req resource.Cr }) state, err := r.setDefault(ctx, plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID()) if err != nil { - resp.Diagnostics.Append(defaultAgentsModelDiag("set", plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) + resp.Diagnostics.Append(agentsDefaultModelDiag("set", plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) return } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } -func (r *DefaultAgentsModelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state DefaultAgentsModelResourceModel +func (r *AgentsDefaultModelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state AgentsDefaultModelResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } - var configs codersdk.OrganizationChatModelsResponse - var err error - if state.OrganizationID.IsNull() || state.OrganizationID.IsUnknown() { - // Legacy state predates organization_id. The old unscoped resource managed - // models in Coder's default organization, which may differ from the - // provider user's first organization. - configs, err = legacyDefaultOrganizationChatModels(ctx, r.data.Client) - } else { - configs, err = r.experimentalClient().ChatModels(ctx, state.OrganizationID.ValueUUID()) - } + configs, err := r.experimentalClient().ChatModels(ctx, state.OrganizationID.ValueUUID()) if err != nil { if isNotFound(err) { resp.State.RemoveResource(ctx) @@ -211,7 +145,7 @@ func (r *DefaultAgentsModelResource) Read(ctx context.Context, req resource.Read for _, config := range configs.Models { if config.IsDefault { - state = stateFromDefaultModelConfig(config) + state = stateFromAgentsDefaultModelConfig(config) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) return } @@ -224,8 +158,8 @@ func (r *DefaultAgentsModelResource) Read(ctx context.Context, req resource.Read resp.State.RemoveResource(ctx) } -func (r *DefaultAgentsModelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan, state DefaultAgentsModelResourceModel +func (r *AgentsDefaultModelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state AgentsDefaultModelResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { @@ -237,32 +171,20 @@ func (r *DefaultAgentsModelResource) Update(ctx context.Context, req resource.Up "model_id": plan.ModelID.ValueString(), }) organizationID := state.OrganizationID.ValueUUID() - if state.OrganizationID.IsNull() || state.OrganizationID.IsUnknown() { - config, found, err := legacyDefaultOrganizationChatModel(ctx, r.data.Client, plan.ModelID.ValueUUID()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to resolve the organization for legacy default Agents model state, got error: %s", err)) - return - } - if !found { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update default Agents model because model %s no longer exists.", plan.ModelID.ValueString())) - return - } - organizationID = config.OrganizationID - } updated, err := r.setDefault(ctx, organizationID, plan.ModelID.ValueUUID()) if err != nil { - resp.Diagnostics.Append(defaultAgentsModelDiag("update", organizationID, plan.ModelID.ValueUUID(), err)...) + resp.Diagnostics.Append(agentsDefaultModelDiag("update", organizationID, plan.ModelID.ValueUUID(), err)...) return } resp.Diagnostics.Append(resp.State.Set(ctx, &updated)...) } -func (r *DefaultAgentsModelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { +func (r *AgentsDefaultModelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // Coder requires a default once models exist and has no API for unsetting it. - tflog.Info(ctx, "deleting coderd_default_agents_model is a no-op; Coder retains its current default model") + tflog.Info(ctx, "deleting coderd_agents_default_model is a no-op; Coder retains its current default model") } -func (r *DefaultAgentsModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { +func (r *AgentsDefaultModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { // Import by organization ID. Read resolves the organization's current // default model without promoting or otherwise modifying any model. resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) @@ -273,28 +195,28 @@ func (r *DefaultAgentsModelResource) ImportState(ctx context.Context, req resour // and returns the resulting resource state. The request carries only is_default; // Coder merges it into the existing model config and atomically demotes the // previous default in that organization. -func (r *DefaultAgentsModelResource) setDefault(ctx context.Context, organizationID, modelID uuid.UUID) (DefaultAgentsModelResourceModel, error) { +func (r *AgentsDefaultModelResource) setDefault(ctx context.Context, organizationID, modelID uuid.UUID) (AgentsDefaultModelResourceModel, error) { updated, err := r.experimentalClient().UpdateChatModel(ctx, organizationID, modelID, codersdk.UpdateChatModelRequest{ IsDefault: new(true), }) if err != nil { - return DefaultAgentsModelResourceModel{}, err + return AgentsDefaultModelResourceModel{}, err } - return stateFromDefaultModelConfig(updated), nil + return stateFromAgentsDefaultModelConfig(updated), nil } -// stateFromDefaultModelConfig maps the model config that Coder reports as the +// stateFromAgentsDefaultModelConfig maps the model config that Coder reports as the // default into resource state. The organization UUID is the natural identity // because each organization has at most one default model. -func stateFromDefaultModelConfig(config codersdk.ChatModel) DefaultAgentsModelResourceModel { - return DefaultAgentsModelResourceModel{ +func stateFromAgentsDefaultModelConfig(config codersdk.ChatModel) AgentsDefaultModelResourceModel { + return AgentsDefaultModelResourceModel{ ID: UUIDValue(config.OrganizationID), OrganizationID: UUIDValue(config.OrganizationID), ModelID: UUIDValue(config.ID), } } -func defaultAgentsModelDiag(action string, organizationID, modelID uuid.UUID, err error) diag.Diagnostics { +func agentsDefaultModelDiag(action string, organizationID, modelID uuid.UUID, err error) diag.Diagnostics { var diags diag.Diagnostics var sdkErr *codersdk.Error @@ -304,8 +226,8 @@ func defaultAgentsModelDiag(action string, organizationID, modelID uuid.UUID, er "Unsupported Coder Version", fmt.Sprintf("Unable to %s the default Agents model: the deployment returned 404 for %s. "+ "This endpoint requires Coder version %s or later; upgrade the deployment, or remove "+ - "`coderd_default_agents_model` from your configuration. Original error: %s", - action, endpoint, defaultAgentsModelMinVersion, err), + "`coderd_agents_default_model` from your configuration. Original error: %s", + action, endpoint, agentsDefaultModelMinVersion, err), ) return diags } diff --git a/internal/provider/default_agents_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go similarity index 63% rename from internal/provider/default_agents_model_resource_test.go rename to internal/provider/agents_default_model_resource_test.go index bba64bd..51aade7 100644 --- a/internal/provider/default_agents_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -11,21 +11,18 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" - fwresource "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stretchr/testify/require" ) -func TestDefaultAgentsModelStateFromModelConfig(t *testing.T) { +func TestAgentsDefaultModelStateFromModelConfig(t *testing.T) { t.Parallel() organizationID := uuid.New() modelID := uuid.New() - state := stateFromDefaultModelConfig(codersdk.ChatModel{ + state := stateFromAgentsDefaultModelConfig(codersdk.ChatModel{ ID: modelID, OrganizationID: organizationID, IsDefault: true, @@ -38,53 +35,9 @@ func TestDefaultAgentsModelStateFromModelConfig(t *testing.T) { require.Equal(t, modelID.String(), state.ModelID.ValueString()) } -// TestDefaultAgentsModelUpgradeStateV0 upgrades legacy (schema version 0) -// state, which held the constant id "default" and no organization_id, to -// version 1: id and organization_id become null (Read resolves them through -// the legacy compatibility route) and model_id is preserved. -func TestDefaultAgentsModelUpgradeStateV0(t *testing.T) { - t.Parallel() - - ctx := t.Context() - modelID := uuid.New() - - r := &DefaultAgentsModelResource{} - upgrader, ok := r.UpgradeState(ctx)[0] - require.True(t, ok) - require.NotNil(t, upgrader.PriorSchema) - require.NotNil(t, upgrader.StateUpgrader) - - priorRaw := tftypes.NewValue(upgrader.PriorSchema.Type().TerraformType(ctx), map[string]tftypes.Value{ - "id": tftypes.NewValue(tftypes.String, "default"), - "model_id": tftypes.NewValue(tftypes.String, modelID.String()), - }) - - var schemaResp fwresource.SchemaResponse - r.Schema(ctx, fwresource.SchemaRequest{}, &schemaResp) - require.False(t, schemaResp.Diagnostics.HasError(), schemaResp.Diagnostics) - - req := fwresource.UpgradeStateRequest{ - State: &tfsdk.State{Raw: priorRaw, Schema: *upgrader.PriorSchema}, - } - resp := &fwresource.UpgradeStateResponse{ - State: tfsdk.State{ - Raw: tftypes.NewValue(schemaResp.Schema.Type().TerraformType(ctx), nil), - Schema: schemaResp.Schema, - }, - } - upgrader.StateUpgrader(ctx, req, resp) - require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics) - - var upgraded DefaultAgentsModelResourceModel - require.False(t, resp.State.Get(ctx, &upgraded).HasError()) - require.True(t, upgraded.ID.IsNull()) - require.True(t, upgraded.OrganizationID.IsNull()) - require.Equal(t, modelID, upgraded.ModelID.ValueUUID()) -} - -// TestDefaultAgentsModelResourceValidationDefersUnknownConfig checks validation +// TestAgentsDefaultModelResourceValidationDefersUnknownConfig checks validation // passes when model_id is unknown, like when it comes from an unset variable. -func TestDefaultAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { +func TestAgentsDefaultModelResourceValidationDefersUnknownConfig(t *testing.T) { t.Parallel() // PlanOnly reaches provider Configure(), which fetches the current user @@ -101,7 +54,7 @@ variable "model_id" { type = string } -resource "coderd_default_agents_model" "default" { +resource "coderd_agents_default_model" "default" { organization_id = "` + uuid.NewString() + `" model_id = var.model_id } @@ -124,10 +77,10 @@ resource "coderd_default_agents_model" "default" { }) } -// TestDefaultAgentsModelResourceDefersUnknownOrganizationID checks planning +// TestAgentsDefaultModelResourceDefersUnknownOrganizationID checks planning // succeeds when organization_id comes from another resource and is therefore // unknown until apply. -func TestDefaultAgentsModelResourceDefersUnknownOrganizationID(t *testing.T) { +func TestAgentsDefaultModelResourceDefersUnknownOrganizationID(t *testing.T) { t.Parallel() srv := newMockServer(nil) @@ -146,7 +99,7 @@ resource "terraform_data" "organization" { input = var.organization_id } -resource "coderd_default_agents_model" "default" { +resource "coderd_agents_default_model" "default" { organization_id = terraform_data.organization.output model_id = "` + uuid.NewString() + `" } @@ -167,96 +120,15 @@ resource "coderd_default_agents_model" "default" { }) } -// TestAccDefaultAgentsModelResourceUpgradeFromV0_0_23 creates schema version 0 -// state with the last provider release that used the deployment-wide default -// model, then switches to the in-repository provider. The current provider must -// upgrade id = "default", recover the default organization during Read, and -// converge without changing the selected server-side model. -func TestAccDefaultAgentsModelResourceUpgradeFromV0_0_23(t *testing.T) { - t.Parallel() - if os.Getenv("TF_ACC") == "" { - t.Skip("Acceptance tests are disabled.") - } - - ctx := t.Context() - client := integration.StartCoder(ctx, t, "default_agents_model_upgrade_v0_0_23_acc", integration.UseLicense) - organizationID := accDefaultOrganizationID(ctx, t, client) - skipIfDefaultAgentsModelUnsupported(ctx, t, client, organizationID) - aiProvider := createAccAgentsModelAIProvider(ctx, t, client) - model := createAccChatModel(ctx, t, client, organizationID, aiProvider.ID, "claude-3-5-sonnet-20241022") - - // The first model in an organization is automatically the default. Import it - // with v0.0.23 rather than creating coderd_default_agents_model: that release's - // unscoped PATCH route no longer exists in Coder 2.37, while its import and - // compatibility-list Read paths still produce the exact released v0 state. - cfg := fmt.Sprintf(` -provider "coderd" { - url = %q - token = %q -} - -resource "coderd_default_agents_model" "default" { - model_id = %q -} -`, client.URL.String(), client.SessionToken(), model.ID.String()) - - resource.Test(t, resource.TestCase{ - IsUnitTest: true, - PreCheck: func() { testAccPreCheck(t) }, - Steps: []resource.TestStep{ - { - Config: cfg, - ExternalProviders: map[string]resource.ExternalProvider{ - "coderd": { - Source: "coder/coderd", - VersionConstraint: "= 0.0.23", - }, - }, - ResourceName: "coderd_default_agents_model.default", - ImportState: true, - ImportStateId: model.ID.String(), - ImportStatePersist: true, - ImportStateCheck: func(states []*terraform.InstanceState) error { - if len(states) != 1 { - return fmt.Errorf("expected one imported resource, got %d", len(states)) - } - if got := states[0].Attributes["id"]; got != "default" { - return fmt.Errorf("expected legacy id %q, got %q", "default", got) - } - if got := states[0].Attributes["model_id"]; got != model.ID.String() { - return fmt.Errorf("expected legacy model_id %q, got %q", model.ID, got) - } - return nil - }, - }, - { - Config: cfg, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "id", organizationID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "organization_id", organizationID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "model_id", model.ID.String()), - checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), - ), - }, - { - Config: cfg, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - PlanOnly: true, - }, - }, - }) -} - -func TestAccDefaultAgentsModelResource(t *testing.T) { +func TestAccAgentsDefaultModelResource(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { t.Skip("Acceptance tests are disabled.") } ctx := t.Context() - client := integration.StartCoder(ctx, t, "default_agents_model_acc", integration.UseLicense) + client := integration.StartCoder(ctx, t, "agents_default_model_acc", integration.UseLicense) organizationID := accDefaultOrganizationID(ctx, t, client) - skipIfDefaultAgentsModelUnsupported(ctx, t, client, organizationID) + skipIfAgentsDefaultModelUnsupported(ctx, t, client, organizationID) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) cfg := func(defaultModel string) string { @@ -278,7 +150,7 @@ resource "coderd_agents_model" "opus" { context_limit = 200000 } -resource "coderd_default_agents_model" "default" { +resource "coderd_agents_default_model" "default" { organization_id = %q model_id = coderd_agents_model.%s.id } @@ -293,10 +165,10 @@ resource "coderd_default_agents_model" "default" { { Config: cfg("sonnet"), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "id", organizationID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "organization_id", organizationID.String()), - resource.TestCheckResourceAttrPair("coderd_default_agents_model.default", "model_id", "coderd_agents_model.sonnet", "id"), - checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), + resource.TestCheckResourceAttr("coderd_agents_default_model.default", "id", organizationID.String()), + resource.TestCheckResourceAttr("coderd_agents_default_model.default", "organization_id", organizationID.String()), + resource.TestCheckResourceAttrPair("coderd_agents_default_model.default", "model_id", "coderd_agents_model.sonnet", "id"), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_agents_default_model.default"), ), }, { @@ -304,8 +176,8 @@ resource "coderd_default_agents_model" "default" { // the same operation, so exactly one model remains default. Config: cfg("opus"), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrPair("coderd_default_agents_model.default", "model_id", "coderd_agents_model.opus", "id"), - checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), + resource.TestCheckResourceAttrPair("coderd_agents_default_model.default", "model_id", "coderd_agents_model.opus", "id"), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_agents_default_model.default"), ), }, { @@ -315,7 +187,7 @@ resource "coderd_default_agents_model" "default" { }, { // Import by organization UUID; Read resolves its current default. - ResourceName: "coderd_default_agents_model.default", + ResourceName: "coderd_agents_default_model.default", ImportState: true, ImportStateVerify: true, ImportStateId: organizationID.String(), @@ -324,22 +196,22 @@ resource "coderd_default_agents_model" "default" { }) } -// TestAccDefaultAgentsModelResourceDriftAndDelete proves two things against +// TestAccAgentsDefaultModelResourceDriftAndDelete proves two things against // models created out-of-band (so they outlive the Terraform resource): // // - Read detects an external change to the default and Terraform reconciles // back to the configured model. // - Delete is a no-op: Coder keeps exactly one model marked default, so // destroying the pointer leaves the server's default untouched. -func TestAccDefaultAgentsModelResourceDriftAndDelete(t *testing.T) { +func TestAccAgentsDefaultModelResourceDriftAndDelete(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { t.Skip("Acceptance tests are disabled.") } ctx := t.Context() - client := integration.StartCoder(ctx, t, "default_agents_model_drift_acc", integration.UseLicense) + client := integration.StartCoder(ctx, t, "agents_default_model_drift_acc", integration.UseLicense) organizationID := accDefaultOrganizationID(ctx, t, client) - skipIfDefaultAgentsModelUnsupported(ctx, t, client, organizationID) + skipIfAgentsDefaultModelUnsupported(ctx, t, client, organizationID) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) sonnet := createAccChatModel(ctx, t, client, organizationID, aiProvider.ID, "claude-3-5-sonnet-20241022") @@ -352,7 +224,7 @@ provider "coderd" { token = %q } -resource "coderd_default_agents_model" "default" { +resource "coderd_agents_default_model" "default" { organization_id = %q model_id = %q } @@ -378,8 +250,8 @@ resource "coderd_default_agents_model" "default" { { Config: cfg, Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "model_id", sonnet.ID.String()), - checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), + resource.TestCheckResourceAttr("coderd_agents_default_model.default", "model_id", sonnet.ID.String()), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_agents_default_model.default"), ), }, { @@ -399,24 +271,24 @@ resource "coderd_default_agents_model" "default" { // Re-applying reconciles the default back to sonnet. Config: cfg, Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "model_id", sonnet.ID.String()), - checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_default_agents_model.default"), + resource.TestCheckResourceAttr("coderd_agents_default_model.default", "model_id", sonnet.ID.String()), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_agents_default_model.default"), ), }, }, }) } -func TestAccDefaultAgentsModelResourceOrganizationIsolation(t *testing.T) { +func TestAccAgentsDefaultModelResourceOrganizationIsolation(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { t.Skip("Acceptance tests are disabled.") } ctx := t.Context() - client := integration.StartCoder(ctx, t, "default_agents_model_org_isolation_acc", integration.UseLicense) + client := integration.StartCoder(ctx, t, "agents_default_model_org_isolation_acc", integration.UseLicense) defaultOrganizationID := accDefaultOrganizationID(ctx, t, client) - skipIfDefaultAgentsModelUnsupported(ctx, t, client, defaultOrganizationID) + skipIfAgentsDefaultModelUnsupported(ctx, t, client, defaultOrganizationID) otherOrganization, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ Name: "default-model-isolation", @@ -439,12 +311,12 @@ provider "coderd" { token = %q } -resource "coderd_default_agents_model" "default_org" { +resource "coderd_agents_default_model" "default_org" { organization_id = %q model_id = %q } -resource "coderd_default_agents_model" "other_org" { +resource "coderd_agents_default_model" "other_org" { organization_id = %q model_id = %q } @@ -458,14 +330,14 @@ resource "coderd_default_agents_model" "other_org" { { Config: cfg, Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default_org", "id", defaultOrganizationID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.default_org", "organization_id", defaultOrganizationID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.default_org", "model_id", defaultOrgSecond.ID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.other_org", "id", otherOrganization.ID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.other_org", "organization_id", otherOrganization.ID.String()), - resource.TestCheckResourceAttr("coderd_default_agents_model.other_org", "model_id", otherOrgFirst.ID.String()), - checkServerDefaultMatchesResource(ctx, t, client, defaultOrganizationID, "coderd_default_agents_model.default_org"), - checkServerDefaultMatchesResource(ctx, t, client, otherOrganization.ID, "coderd_default_agents_model.other_org"), + resource.TestCheckResourceAttr("coderd_agents_default_model.default_org", "id", defaultOrganizationID.String()), + resource.TestCheckResourceAttr("coderd_agents_default_model.default_org", "organization_id", defaultOrganizationID.String()), + resource.TestCheckResourceAttr("coderd_agents_default_model.default_org", "model_id", defaultOrgSecond.ID.String()), + resource.TestCheckResourceAttr("coderd_agents_default_model.other_org", "id", otherOrganization.ID.String()), + resource.TestCheckResourceAttr("coderd_agents_default_model.other_org", "organization_id", otherOrganization.ID.String()), + resource.TestCheckResourceAttr("coderd_agents_default_model.other_org", "model_id", otherOrgFirst.ID.String()), + checkServerDefaultMatchesResource(ctx, t, client, defaultOrganizationID, "coderd_agents_default_model.default_org"), + checkServerDefaultMatchesResource(ctx, t, client, otherOrganization.ID, "coderd_agents_default_model.other_org"), ), }, { @@ -476,7 +348,7 @@ resource "coderd_default_agents_model" "other_org" { }) } -func skipIfDefaultAgentsModelUnsupported(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID uuid.UUID) { +func skipIfAgentsDefaultModelUnsupported(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID uuid.UUID) { t.Helper() // Main devel builds report the previous minor's version, so a semver minimum diff --git a/internal/provider/provider.go b/internal/provider/provider.go index cbc8b44..411f3e8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -236,7 +236,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewAIProviderResource, NewAgentsMCPServerResource, NewAgentsModelResource, - NewDefaultAgentsModelResource, + NewAgentsDefaultModelResource, NewAgentsSystemPromptResource, NewOAuth2ProviderSettingsResource, } From 5a8b990c7c1543f666a2618531e49a7e50ae1fc2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 14:46:15 +0000 Subject: [PATCH 04/12] fix(coderd_agents_default_model): handle replacement 404s --- .../provider/agents_default_model_resource.go | 92 +++++-- .../agents_default_model_resource_test.go | 254 ++++++++++++++++++ 2 files changed, 331 insertions(+), 15 deletions(-) diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index 22f2780..64cd998 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -73,7 +73,7 @@ func (r *AgentsDefaultModelResource) Schema(ctx context.Context, req resource.Sc CustomType: UUIDType, Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), + useStateForUnknownUnlessChanged("organization_id"), }, }, "organization_id": schema.StringAttribute{ @@ -120,7 +120,7 @@ func (r *AgentsDefaultModelResource) Create(ctx context.Context, req resource.Cr }) state, err := r.setDefault(ctx, plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID()) if err != nil { - resp.Diagnostics.Append(agentsDefaultModelDiag("set", plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) + resp.Diagnostics.Append(r.agentsDefaultModelDiag(ctx, "set", plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) return } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) @@ -135,8 +135,8 @@ func (r *AgentsDefaultModelResource) Read(ctx context.Context, req resource.Read configs, err := r.experimentalClient().ChatModels(ctx, state.OrganizationID.ValueUUID()) if err != nil { - if isNotFound(err) { - resp.State.RemoveResource(ctx) + if isHTTPNotFound(err) { + resp.Diagnostics.Append(r.agentsDefaultModelCollection404Diag(ctx, "read", state.OrganizationID.ValueUUID(), err, err)...) return } resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read default Agents model, got error: %s", err)) @@ -173,7 +173,7 @@ func (r *AgentsDefaultModelResource) Update(ctx context.Context, req resource.Up organizationID := state.OrganizationID.ValueUUID() updated, err := r.setDefault(ctx, organizationID, plan.ModelID.ValueUUID()) if err != nil { - resp.Diagnostics.Append(agentsDefaultModelDiag("update", organizationID, plan.ModelID.ValueUUID(), err)...) + resp.Diagnostics.Append(r.agentsDefaultModelDiag(ctx, "update", organizationID, plan.ModelID.ValueUUID(), err)...) return } resp.Diagnostics.Append(resp.State.Set(ctx, &updated)...) @@ -216,22 +216,84 @@ func stateFromAgentsDefaultModelConfig(config codersdk.ChatModel) AgentsDefaultM } } -func agentsDefaultModelDiag(action string, organizationID, modelID uuid.UUID, err error) diag.Diagnostics { +func (r *AgentsDefaultModelResource) agentsDefaultModelDiag(ctx context.Context, action string, organizationID, modelID uuid.UUID, err error) diag.Diagnostics { var diags diag.Diagnostics + if !isHTTPNotFound(err) { + diags.AddError("Client Error", fmt.Sprintf("Unable to %s the default Agents model, got error: %s", action, err)) + return diags + } - var sdkErr *codersdk.Error - if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - endpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID) + endpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID) + _, collectionErr := r.experimentalClient().ChatModels(ctx, organizationID) + if collectionErr == nil { diags.AddError( - "Unsupported Coder Version", - fmt.Sprintf("Unable to %s the default Agents model: the deployment returned 404 for %s. "+ - "This endpoint requires Coder version %s or later; upgrade the deployment, or remove "+ - "`coderd_agents_default_model` from your configuration. Original error: %s", - action, endpoint, agentsDefaultModelMinVersion, err), + "Default Agents Model Not Found or Inaccessible", + fmt.Sprintf("Unable to %s the default Agents model: %s returned 404, but the organization's chat model collection is available. "+ + "Model %s does not exist in organization %s or is inaccessible. Original error: %s", + action, endpoint, modelID, organizationID, err), ) return diags } + if !isHTTPNotFound(collectionErr) { + diags.AddError( + "Client Error", + fmt.Sprintf("Unable to %s the default Agents model, and unable to determine whether the 404 from %s is model-specific because probing the organization's chat model collection failed. "+ + "Original error: %s. Collection probe error: %s", + action, endpoint, err, collectionErr), + ) + return diags + } + + return r.agentsDefaultModelCollection404Diag(ctx, action, organizationID, err, collectionErr) +} + +// agentsDefaultModelCollection404Diag classifies a 404 from an organization's +// chat model collection. A second probe against the provider's known-valid +// default organization distinguishes an inaccessible organization from a Coder +// version that does not expose the organization-scoped endpoint at all. +func (r *AgentsDefaultModelResource) agentsDefaultModelCollection404Diag(ctx context.Context, action string, organizationID uuid.UUID, originalErr, collectionErr error) diag.Diagnostics { + var diags diag.Diagnostics + defaultOrganizationID := r.data.DefaultOrganizationID + defaultEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) + + capabilityErr := collectionErr + if organizationID != defaultOrganizationID { + _, capabilityErr = r.experimentalClient().ChatModels(ctx, defaultOrganizationID) + if capabilityErr == nil { + detail := fmt.Sprintf("Unable to %s the default Agents model: the chat model collection for organization %s returned 404, while the same endpoint is available for the provider's default organization %s. "+ + "The configured organization does not exist or is inaccessible. Original error: %s", + action, organizationID, defaultOrganizationID, originalErr) + if collectionErr.Error() != originalErr.Error() { + detail += fmt.Sprintf(" Collection probe error: %s", collectionErr) + } + diags.AddError("Organization Not Found or Inaccessible", detail) + return diags + } + } + + if isHTTPNotFound(capabilityErr) { + originalEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID) + detail := fmt.Sprintf("Unable to %s the default Agents model: the deployment returned 404 for %s, and the capability probe against the provider's known-valid default organization at %s also returned 404. "+ + "This endpoint requires Coder version %s or later; upgrade the deployment, or remove `coderd_agents_default_model` from your configuration. Original error: %s", + action, originalEndpoint, defaultEndpoint, agentsDefaultModelMinVersion, originalErr) + if capabilityErr.Error() != originalErr.Error() { + detail += fmt.Sprintf(" Capability probe error: %s", capabilityErr) + } + diags.AddError("Unsupported Coder Version", detail) + return diags + } - diags.AddError("Client Error", fmt.Sprintf("Unable to %s the default Agents model, got error: %s", action, err)) + detail := fmt.Sprintf("Unable to %s the default Agents model after the organization's chat model collection returned 404, and unable to determine whether the endpoint is supported because the capability probe against %s failed. "+ + "Original error: %s. Capability probe error: %s", + action, defaultEndpoint, originalErr, capabilityErr) + if collectionErr.Error() != originalErr.Error() { + detail += fmt.Sprintf(" Collection probe error: %s", collectionErr) + } + diags.AddError("Client Error", detail) return diags } + +func isHTTPNotFound(err error) bool { + var sdkErr *codersdk.Error + return errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound +} diff --git a/internal/provider/agents_default_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go index 51aade7..25dc274 100644 --- a/internal/provider/agents_default_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -5,12 +5,20 @@ import ( "errors" "fmt" "net/http" + "net/http/httptest" + "net/url" "os" "testing" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/google/uuid" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" @@ -35,6 +43,252 @@ func TestAgentsDefaultModelStateFromModelConfig(t *testing.T) { require.Equal(t, modelID.String(), state.ModelID.ValueString()) } +func TestAgentsDefaultModelIDPlanModifier(t *testing.T) { + t.Parallel() + + ctx := t.Context() + oldOrganizationID := uuid.New() + newOrganizationID := uuid.New() + modelID := uuid.New() + + r := &AgentsDefaultModelResource{} + var schemaResp fwresource.SchemaResponse + r.Schema(ctx, fwresource.SchemaRequest{}, &schemaResp) + require.False(t, schemaResp.Diagnostics.HasError(), schemaResp.Diagnostics) + + idAttribute, ok := schemaResp.Schema.Attributes["id"].(schema.StringAttribute) + require.True(t, ok) + require.Len(t, idAttribute.PlanModifiers, 1) + modifier := idAttribute.PlanModifiers[0] + + raw := func(id tftypes.Value, organizationID uuid.UUID) tftypes.Value { + return tftypes.NewValue(schemaResp.Schema.Type().TerraformType(ctx), map[string]tftypes.Value{ + "id": id, + "organization_id": tftypes.NewValue(tftypes.String, organizationID.String()), + "model_id": tftypes.NewValue(tftypes.String, modelID.String()), + }) + } + state := tfsdk.State{ + Schema: schemaResp.Schema, + Raw: raw(tftypes.NewValue(tftypes.String, oldOrganizationID.String()), oldOrganizationID), + } + + for _, tc := range []struct { + name string + plannedOrganization uuid.UUID + want types.String + }{ + { + name: "retains id when organization is unchanged", + plannedOrganization: oldOrganizationID, + want: types.StringValue(oldOrganizationID.String()), + }, + { + name: "leaves id unknown when organization changes", + plannedOrganization: newOrganizationID, + want: types.StringUnknown(), + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + plan := tfsdk.Plan{ + Schema: schemaResp.Schema, + Raw: raw(tftypes.NewValue(tftypes.String, tftypes.UnknownValue), tc.plannedOrganization), + } + resp := &planmodifier.StringResponse{PlanValue: types.StringUnknown()} + modifier.PlanModifyString(ctx, planmodifier.StringRequest{ + ConfigValue: types.StringNull(), + PlanValue: types.StringUnknown(), + StateValue: types.StringValue(oldOrganizationID.String()), + Plan: plan, + State: state, + }, resp) + + require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics) + require.Equal(t, tc.want, resp.PlanValue) + }) + } +} + +func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + targetCollectionStatus int + defaultCollectionStatus int + wantSummary string + }{ + { + name: "model missing", + targetCollectionStatus: http.StatusOK, + wantSummary: "Default Agents Model Not Found or Inaccessible", + }, + { + name: "organization missing", + targetCollectionStatus: http.StatusNotFound, + defaultCollectionStatus: http.StatusOK, + wantSummary: "Organization Not Found or Inaccessible", + }, + { + name: "unsupported endpoint", + targetCollectionStatus: http.StatusNotFound, + defaultCollectionStatus: http.StatusNotFound, + wantSummary: "Unsupported Coder Version", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + targetOrganizationID := uuid.New() + defaultOrganizationID := uuid.New() + modelID := uuid.New() + targetCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) + defaultCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) + modelPath := fmt.Sprintf("%s/%s", targetCollectionPath, modelID) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch { + case req.Method == http.MethodPatch && req.URL.Path == modelPath: + writeJSON(w, http.StatusNotFound, codersdk.Response{Message: "Not Found."}) + case req.Method == http.MethodGet && req.URL.Path == targetCollectionPath: + writeAgentsDefaultModelCollectionResponse(w, tc.targetCollectionStatus) + case req.Method == http.MethodGet && req.URL.Path == defaultCollectionPath: + writeAgentsDefaultModelCollectionResponse(w, tc.defaultCollectionStatus) + default: + writeJSON(w, http.StatusInternalServerError, codersdk.Response{Message: "unexpected request"}) + } + })) + t.Cleanup(srv.Close) + + r := newAgentsDefaultModelTestResource(t, srv.URL, defaultOrganizationID) + _, err := r.setDefault(t.Context(), targetOrganizationID, modelID) + require.Error(t, err) + + diags := r.agentsDefaultModelDiag(t.Context(), "set", targetOrganizationID, modelID, err) + require.Len(t, diags.Errors(), 1) + require.Equal(t, tc.wantSummary, diags.Errors()[0].Summary()) + require.Contains(t, diags.Errors()[0].Detail(), "Original error:") + require.Contains(t, diags.Errors()[0].Detail(), "Not Found.") + if tc.wantSummary == "Unsupported Coder Version" { + require.Contains(t, diags.Errors()[0].Detail(), agentsDefaultModelMinVersion) + } else { + require.NotContains(t, diags.Errors()[0].Detail(), "requires Coder version") + } + }) + } +} + +func TestAgentsDefaultModelReadCollection404(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + targetCollectionStatus int + defaultCollectionStatus int + wantSummary string + wantRemoved bool + }{ + { + name: "missing organization retains state", + targetCollectionStatus: http.StatusNotFound, + defaultCollectionStatus: http.StatusOK, + wantSummary: "Organization Not Found or Inaccessible", + }, + { + name: "unsupported endpoint retains state", + targetCollectionStatus: http.StatusNotFound, + defaultCollectionStatus: http.StatusNotFound, + wantSummary: "Unsupported Coder Version", + }, + { + name: "empty supported collection removes state", + targetCollectionStatus: http.StatusOK, + wantRemoved: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + targetOrganizationID := uuid.New() + defaultOrganizationID := uuid.New() + modelID := uuid.New() + targetCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) + defaultCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case targetCollectionPath: + writeAgentsDefaultModelCollectionResponse(w, tc.targetCollectionStatus) + case defaultCollectionPath: + writeAgentsDefaultModelCollectionResponse(w, tc.defaultCollectionStatus) + default: + writeJSON(w, http.StatusInternalServerError, codersdk.Response{Message: "unexpected request"}) + } + })) + t.Cleanup(srv.Close) + + r := newAgentsDefaultModelTestResource(t, srv.URL, defaultOrganizationID) + state := agentsDefaultModelTestState(t, r, AgentsDefaultModelResourceModel{ + ID: UUIDValue(targetOrganizationID), + OrganizationID: UUIDValue(targetOrganizationID), + ModelID: UUIDValue(modelID), + }) + resp := &fwresource.ReadResponse{State: state} + r.Read(t.Context(), fwresource.ReadRequest{State: state}, resp) + + require.Equal(t, tc.wantRemoved, resp.State.Raw.IsNull()) + if tc.wantSummary == "" { + require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics) + return + } + require.Len(t, resp.Diagnostics.Errors(), 1) + require.Equal(t, tc.wantSummary, resp.Diagnostics.Errors()[0].Summary()) + require.Contains(t, resp.Diagnostics.Errors()[0].Detail(), "Original error:") + require.Contains(t, resp.Diagnostics.Errors()[0].Detail(), "Not Found.") + }) + } +} + +func newAgentsDefaultModelTestResource(t *testing.T, serverURL string, defaultOrganizationID uuid.UUID) *AgentsDefaultModelResource { + t.Helper() + + parsedURL, err := url.Parse(serverURL) + require.NoError(t, err) + return &AgentsDefaultModelResource{data: &CoderdProviderData{ + Client: codersdk.New(parsedURL), + DefaultOrganizationID: defaultOrganizationID, + }} +} + +func agentsDefaultModelTestState(t *testing.T, r *AgentsDefaultModelResource, model AgentsDefaultModelResourceModel) tfsdk.State { + t.Helper() + + ctx := t.Context() + var schemaResp fwresource.SchemaResponse + r.Schema(ctx, fwresource.SchemaRequest{}, &schemaResp) + require.False(t, schemaResp.Diagnostics.HasError(), schemaResp.Diagnostics) + + state := tfsdk.State{ + Schema: schemaResp.Schema, + Raw: tftypes.NewValue(schemaResp.Schema.Type().TerraformType(ctx), nil), + } + require.False(t, state.Set(ctx, &model).HasError()) + return state +} + +func writeAgentsDefaultModelCollectionResponse(w http.ResponseWriter, status int) { + if status == 0 { + status = http.StatusInternalServerError + } + if status != http.StatusOK { + writeJSON(w, status, codersdk.Response{Message: statusMessage(status)}) + return + } + writeJSON(w, http.StatusOK, codersdk.OrganizationChatModelsResponse{}) +} + // TestAgentsDefaultModelResourceValidationDefersUnknownConfig checks validation // passes when model_id is unknown, like when it comes from an unset variable. func TestAgentsDefaultModelResourceValidationDefersUnknownConfig(t *testing.T) { From bf911cfca88d9123802a4bce4ff1730f54c78646 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 15:45:31 +0000 Subject: [PATCH 05/12] fix(coderd_agents_default_model): remove missing organizations --- .../provider/agents_default_model_resource.go | 27 ++++++++++++------- .../agents_default_model_resource_test.go | 4 +-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index 64cd998..6b35794 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -133,10 +133,17 @@ func (r *AgentsDefaultModelResource) Read(ctx context.Context, req resource.Read return } - configs, err := r.experimentalClient().ChatModels(ctx, state.OrganizationID.ValueUUID()) + organizationID := state.OrganizationID.ValueUUID() + configs, err := r.experimentalClient().ChatModels(ctx, organizationID) if err != nil { if isHTTPNotFound(err) { - resp.Diagnostics.Append(r.agentsDefaultModelCollection404Diag(ctx, "read", state.OrganizationID.ValueUUID(), err, err)...) + diags, organizationNotFound := r.agentsDefaultModelCollection404Diag(ctx, "read", organizationID, err, err) + if organizationNotFound { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Organization %s not found or inaccessible. Marking its default Agents model selection as deleted.", organizationID)) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.Append(diags...) return } resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read default Agents model, got error: %s", err)) @@ -244,14 +251,16 @@ func (r *AgentsDefaultModelResource) agentsDefaultModelDiag(ctx context.Context, return diags } - return r.agentsDefaultModelCollection404Diag(ctx, action, organizationID, err, collectionErr) + diags, _ = r.agentsDefaultModelCollection404Diag(ctx, action, organizationID, err, collectionErr) + return diags } // agentsDefaultModelCollection404Diag classifies a 404 from an organization's // chat model collection. A second probe against the provider's known-valid -// default organization distinguishes an inaccessible organization from a Coder -// version that does not expose the organization-scoped endpoint at all. -func (r *AgentsDefaultModelResource) agentsDefaultModelCollection404Diag(ctx context.Context, action string, organizationID uuid.UUID, originalErr, collectionErr error) diag.Diagnostics { +// default organization distinguishes a missing or inaccessible organization +// from a Coder version that does not expose the organization-scoped endpoint. +// The boolean result is true for the former case. +func (r *AgentsDefaultModelResource) agentsDefaultModelCollection404Diag(ctx context.Context, action string, organizationID uuid.UUID, originalErr, collectionErr error) (diag.Diagnostics, bool) { var diags diag.Diagnostics defaultOrganizationID := r.data.DefaultOrganizationID defaultEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) @@ -267,7 +276,7 @@ func (r *AgentsDefaultModelResource) agentsDefaultModelCollection404Diag(ctx con detail += fmt.Sprintf(" Collection probe error: %s", collectionErr) } diags.AddError("Organization Not Found or Inaccessible", detail) - return diags + return diags, true } } @@ -280,7 +289,7 @@ func (r *AgentsDefaultModelResource) agentsDefaultModelCollection404Diag(ctx con detail += fmt.Sprintf(" Capability probe error: %s", capabilityErr) } diags.AddError("Unsupported Coder Version", detail) - return diags + return diags, false } detail := fmt.Sprintf("Unable to %s the default Agents model after the organization's chat model collection returned 404, and unable to determine whether the endpoint is supported because the capability probe against %s failed. "+ @@ -290,7 +299,7 @@ func (r *AgentsDefaultModelResource) agentsDefaultModelCollection404Diag(ctx con detail += fmt.Sprintf(" Collection probe error: %s", collectionErr) } diags.AddError("Client Error", detail) - return diags + return diags, false } func isHTTPNotFound(err error) bool { diff --git a/internal/provider/agents_default_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go index 25dc274..6c6a537 100644 --- a/internal/provider/agents_default_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -191,10 +191,10 @@ func TestAgentsDefaultModelReadCollection404(t *testing.T) { wantRemoved bool }{ { - name: "missing organization retains state", + name: "missing organization removes state", targetCollectionStatus: http.StatusNotFound, defaultCollectionStatus: http.StatusOK, - wantSummary: "Organization Not Found or Inaccessible", + wantRemoved: true, }, { name: "unsupported endpoint retains state", From 33e17a99a7f6d7c23fa1cefa2dbd564253e9e35f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 16:36:00 +0000 Subject: [PATCH 06/12] fix(coderd_agents_default_model): remove default org fallback --- .../provider/agents_default_model_resource.go | 63 ++------------ .../agents_default_model_resource_test.go | 83 +++++++------------ 2 files changed, 38 insertions(+), 108 deletions(-) diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index 6b35794..23210d3 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -137,13 +137,8 @@ func (r *AgentsDefaultModelResource) Read(ctx context.Context, req resource.Read configs, err := r.experimentalClient().ChatModels(ctx, organizationID) if err != nil { if isHTTPNotFound(err) { - diags, organizationNotFound := r.agentsDefaultModelCollection404Diag(ctx, "read", organizationID, err, err) - if organizationNotFound { - resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Organization %s not found or inaccessible. Marking its default Agents model selection as deleted.", organizationID)) - resp.State.RemoveResource(ctx) - return - } - resp.Diagnostics.Append(diags...) + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Organization %s not found or inaccessible. Marking its default Agents model selection as deleted.", organizationID)) + resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read default Agents model, got error: %s", err)) @@ -251,57 +246,15 @@ func (r *AgentsDefaultModelResource) agentsDefaultModelDiag(ctx context.Context, return diags } - diags, _ = r.agentsDefaultModelCollection404Diag(ctx, action, organizationID, err, collectionErr) + diags.AddError( + "Organization Not Found or Inaccessible", + fmt.Sprintf("Unable to %s the default Agents model: the chat model collection for organization %s returned 404. "+ + "The organization does not exist or is inaccessible. Original error: %s. Collection probe error: %s", + action, organizationID, err, collectionErr), + ) return diags } -// agentsDefaultModelCollection404Diag classifies a 404 from an organization's -// chat model collection. A second probe against the provider's known-valid -// default organization distinguishes a missing or inaccessible organization -// from a Coder version that does not expose the organization-scoped endpoint. -// The boolean result is true for the former case. -func (r *AgentsDefaultModelResource) agentsDefaultModelCollection404Diag(ctx context.Context, action string, organizationID uuid.UUID, originalErr, collectionErr error) (diag.Diagnostics, bool) { - var diags diag.Diagnostics - defaultOrganizationID := r.data.DefaultOrganizationID - defaultEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) - - capabilityErr := collectionErr - if organizationID != defaultOrganizationID { - _, capabilityErr = r.experimentalClient().ChatModels(ctx, defaultOrganizationID) - if capabilityErr == nil { - detail := fmt.Sprintf("Unable to %s the default Agents model: the chat model collection for organization %s returned 404, while the same endpoint is available for the provider's default organization %s. "+ - "The configured organization does not exist or is inaccessible. Original error: %s", - action, organizationID, defaultOrganizationID, originalErr) - if collectionErr.Error() != originalErr.Error() { - detail += fmt.Sprintf(" Collection probe error: %s", collectionErr) - } - diags.AddError("Organization Not Found or Inaccessible", detail) - return diags, true - } - } - - if isHTTPNotFound(capabilityErr) { - originalEndpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", organizationID) - detail := fmt.Sprintf("Unable to %s the default Agents model: the deployment returned 404 for %s, and the capability probe against the provider's known-valid default organization at %s also returned 404. "+ - "This endpoint requires Coder version %s or later; upgrade the deployment, or remove `coderd_agents_default_model` from your configuration. Original error: %s", - action, originalEndpoint, defaultEndpoint, agentsDefaultModelMinVersion, originalErr) - if capabilityErr.Error() != originalErr.Error() { - detail += fmt.Sprintf(" Capability probe error: %s", capabilityErr) - } - diags.AddError("Unsupported Coder Version", detail) - return diags, false - } - - detail := fmt.Sprintf("Unable to %s the default Agents model after the organization's chat model collection returned 404, and unable to determine whether the endpoint is supported because the capability probe against %s failed. "+ - "Original error: %s. Capability probe error: %s", - action, defaultEndpoint, originalErr, capabilityErr) - if collectionErr.Error() != originalErr.Error() { - detail += fmt.Sprintf(" Collection probe error: %s", collectionErr) - } - diags.AddError("Client Error", detail) - return diags, false -} - func isHTTPNotFound(err error) bool { var sdkErr *codersdk.Error return errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound diff --git a/internal/provider/agents_default_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go index 6c6a537..7d7ecc7 100644 --- a/internal/provider/agents_default_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "os" + "sync/atomic" "testing" "github.com/coder/coder/v2/codersdk" @@ -115,10 +116,9 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { t.Parallel() for _, tc := range []struct { - name string - targetCollectionStatus int - defaultCollectionStatus int - wantSummary string + name string + targetCollectionStatus int + wantSummary string }{ { name: "model missing", @@ -126,16 +126,9 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { wantSummary: "Default Agents Model Not Found or Inaccessible", }, { - name: "organization missing", - targetCollectionStatus: http.StatusNotFound, - defaultCollectionStatus: http.StatusOK, - wantSummary: "Organization Not Found or Inaccessible", - }, - { - name: "unsupported endpoint", - targetCollectionStatus: http.StatusNotFound, - defaultCollectionStatus: http.StatusNotFound, - wantSummary: "Unsupported Coder Version", + name: "organization missing", + targetCollectionStatus: http.StatusNotFound, + wantSummary: "Organization Not Found or Inaccessible", }, } { t.Run(tc.name, func(t *testing.T) { @@ -145,17 +138,16 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { defaultOrganizationID := uuid.New() modelID := uuid.New() targetCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) - defaultCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) modelPath := fmt.Sprintf("%s/%s", targetCollectionPath, modelID) + var requestCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + requestCount.Add(1) switch { case req.Method == http.MethodPatch && req.URL.Path == modelPath: writeJSON(w, http.StatusNotFound, codersdk.Response{Message: "Not Found."}) case req.Method == http.MethodGet && req.URL.Path == targetCollectionPath: writeAgentsDefaultModelCollectionResponse(w, tc.targetCollectionStatus) - case req.Method == http.MethodGet && req.URL.Path == defaultCollectionPath: - writeAgentsDefaultModelCollectionResponse(w, tc.defaultCollectionStatus) default: writeJSON(w, http.StatusInternalServerError, codersdk.Response{Message: "unexpected request"}) } @@ -171,11 +163,7 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { require.Equal(t, tc.wantSummary, diags.Errors()[0].Summary()) require.Contains(t, diags.Errors()[0].Detail(), "Original error:") require.Contains(t, diags.Errors()[0].Detail(), "Not Found.") - if tc.wantSummary == "Unsupported Coder Version" { - require.Contains(t, diags.Errors()[0].Detail(), agentsDefaultModelMinVersion) - } else { - require.NotContains(t, diags.Errors()[0].Detail(), "requires Coder version") - } + require.Equal(t, int32(2), requestCount.Load(), "expected only the model request and its organization collection probe") }) } } @@ -184,28 +172,22 @@ func TestAgentsDefaultModelReadCollection404(t *testing.T) { t.Parallel() for _, tc := range []struct { - name string - targetCollectionStatus int - defaultCollectionStatus int - wantSummary string - wantRemoved bool + name string + targetCollectionStatus int + providerDefaultMatchesTargetID bool }{ { - name: "missing organization removes state", - targetCollectionStatus: http.StatusNotFound, - defaultCollectionStatus: http.StatusOK, - wantRemoved: true, + name: "configured provider default organization missing", + targetCollectionStatus: http.StatusNotFound, + providerDefaultMatchesTargetID: true, }, { - name: "unsupported endpoint retains state", - targetCollectionStatus: http.StatusNotFound, - defaultCollectionStatus: http.StatusNotFound, - wantSummary: "Unsupported Coder Version", + name: "other organization missing", + targetCollectionStatus: http.StatusNotFound, }, { - name: "empty supported collection removes state", + name: "empty supported collection", targetCollectionStatus: http.StatusOK, - wantRemoved: true, }, } { t.Run(tc.name, func(t *testing.T) { @@ -213,19 +195,20 @@ func TestAgentsDefaultModelReadCollection404(t *testing.T) { targetOrganizationID := uuid.New() defaultOrganizationID := uuid.New() + if tc.providerDefaultMatchesTargetID { + defaultOrganizationID = targetOrganizationID + } modelID := uuid.New() targetCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) - defaultCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", defaultOrganizationID) + var requestCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - switch req.URL.Path { - case targetCollectionPath: - writeAgentsDefaultModelCollectionResponse(w, tc.targetCollectionStatus) - case defaultCollectionPath: - writeAgentsDefaultModelCollectionResponse(w, tc.defaultCollectionStatus) - default: + requestCount.Add(1) + if req.URL.Path != targetCollectionPath { writeJSON(w, http.StatusInternalServerError, codersdk.Response{Message: "unexpected request"}) + return } + writeAgentsDefaultModelCollectionResponse(w, tc.targetCollectionStatus) })) t.Cleanup(srv.Close) @@ -238,15 +221,9 @@ func TestAgentsDefaultModelReadCollection404(t *testing.T) { resp := &fwresource.ReadResponse{State: state} r.Read(t.Context(), fwresource.ReadRequest{State: state}, resp) - require.Equal(t, tc.wantRemoved, resp.State.Raw.IsNull()) - if tc.wantSummary == "" { - require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics) - return - } - require.Len(t, resp.Diagnostics.Errors(), 1) - require.Equal(t, tc.wantSummary, resp.Diagnostics.Errors()[0].Summary()) - require.Contains(t, resp.Diagnostics.Errors()[0].Detail(), "Original error:") - require.Contains(t, resp.Diagnostics.Errors()[0].Detail(), "Not Found.") + require.True(t, resp.State.Raw.IsNull()) + require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics) + require.Equal(t, int32(1), requestCount.Load(), "expected only the resource organization's collection to be requested") }) } } From 24c828824bc159ec2f9847732d95b73c2bcfb149 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 17:40:52 +0000 Subject: [PATCH 07/12] fix(coderd_agents_default_model): detect unsupported endpoints --- .../provider/agents_default_model_resource.go | 28 +++++++++++++-- .../agents_default_model_resource_test.go | 34 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index 23210d3..cca1791 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -246,11 +246,33 @@ func (r *AgentsDefaultModelResource) agentsDefaultModelDiag(ctx context.Context, return diags } + organizationEndpoint := fmt.Sprintf("/api/v2/organizations/%s", organizationID) + _, organizationErr := r.data.Client.Organization(ctx, organizationID) + if organizationErr == nil { + diags.AddError( + "Agents Default Model Endpoint Unavailable", + fmt.Sprintf("Unable to %s the default Agents model: the model endpoint %s and the organization's chat model collection both returned 404, but the organization is available at %s. "+ + "This resource requires Coder version %s or later; upgrade the deployment, or remove `coderd_agents_default_model` from your configuration. "+ + "Original error: %s. Collection probe error: %s", + action, endpoint, organizationEndpoint, agentsDefaultModelMinVersion, err, collectionErr), + ) + return diags + } + if !isHTTPNotFound(organizationErr) { + diags.AddError( + "Client Error", + fmt.Sprintf("Unable to %s the default Agents model, and unable to determine whether the organization's chat model endpoint is supported because probing %s failed. "+ + "Original error: %s. Collection probe error: %s. Organization probe error: %s", + action, organizationEndpoint, err, collectionErr, organizationErr), + ) + return diags + } + diags.AddError( "Organization Not Found or Inaccessible", - fmt.Sprintf("Unable to %s the default Agents model: the chat model collection for organization %s returned 404. "+ - "The organization does not exist or is inaccessible. Original error: %s. Collection probe error: %s", - action, organizationID, err, collectionErr), + fmt.Sprintf("Unable to %s the default Agents model: the chat model collection and %s both returned 404. "+ + "Organization %s does not exist or is inaccessible. Original error: %s. Collection probe error: %s. Organization probe error: %s", + action, organizationEndpoint, organizationID, err, collectionErr, organizationErr), ) return diags } diff --git a/internal/provider/agents_default_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go index 7d7ecc7..4ed9df3 100644 --- a/internal/provider/agents_default_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -118,17 +118,39 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { for _, tc := range []struct { name string targetCollectionStatus int + organizationStatus int wantSummary string + wantDetailContains []string + wantRequestCount int32 }{ { name: "model missing", targetCollectionStatus: http.StatusOK, wantSummary: "Default Agents Model Not Found or Inaccessible", + wantRequestCount: 2, + }, + { + name: "endpoint unavailable", + targetCollectionStatus: http.StatusNotFound, + organizationStatus: http.StatusOK, + wantSummary: "Agents Default Model Endpoint Unavailable", + wantDetailContains: []string{agentsDefaultModelMinVersion}, + wantRequestCount: 3, }, { name: "organization missing", targetCollectionStatus: http.StatusNotFound, + organizationStatus: http.StatusNotFound, wantSummary: "Organization Not Found or Inaccessible", + wantRequestCount: 3, + }, + { + name: "organization probe fails", + targetCollectionStatus: http.StatusNotFound, + organizationStatus: http.StatusInternalServerError, + wantSummary: "Client Error", + wantDetailContains: []string{"Organization probe error:"}, + wantRequestCount: 3, }, } { t.Run(tc.name, func(t *testing.T) { @@ -139,6 +161,7 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { modelID := uuid.New() targetCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) modelPath := fmt.Sprintf("%s/%s", targetCollectionPath, modelID) + organizationPath := fmt.Sprintf("/api/v2/organizations/%s", targetOrganizationID) var requestCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { @@ -148,6 +171,12 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { writeJSON(w, http.StatusNotFound, codersdk.Response{Message: "Not Found."}) case req.Method == http.MethodGet && req.URL.Path == targetCollectionPath: writeAgentsDefaultModelCollectionResponse(w, tc.targetCollectionStatus) + case req.Method == http.MethodGet && req.URL.Path == organizationPath: + if tc.organizationStatus == http.StatusOK { + writeJSON(w, http.StatusOK, codersdk.Organization{MinimalOrganization: codersdk.MinimalOrganization{ID: targetOrganizationID, Name: "target"}}) + return + } + writeJSON(w, tc.organizationStatus, codersdk.Response{Message: statusMessage(tc.organizationStatus)}) default: writeJSON(w, http.StatusInternalServerError, codersdk.Response{Message: "unexpected request"}) } @@ -163,7 +192,10 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { require.Equal(t, tc.wantSummary, diags.Errors()[0].Summary()) require.Contains(t, diags.Errors()[0].Detail(), "Original error:") require.Contains(t, diags.Errors()[0].Detail(), "Not Found.") - require.Equal(t, int32(2), requestCount.Load(), "expected only the model request and its organization collection probe") + for _, want := range tc.wantDetailContains { + require.Contains(t, diags.Errors()[0].Detail(), want) + } + require.Equal(t, tc.wantRequestCount, requestCount.Load()) }) } } From c1837308ad62f970a495634904d7cb453928b11e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 18:17:48 +0000 Subject: [PATCH 08/12] fix(coderd_agents_default_model): handle alternate not-found responses --- internal/provider/agents_default_model_resource.go | 2 +- internal/provider/agents_default_model_resource_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index cca1791..340b144 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -136,7 +136,7 @@ func (r *AgentsDefaultModelResource) Read(ctx context.Context, req resource.Read organizationID := state.OrganizationID.ValueUUID() configs, err := r.experimentalClient().ChatModels(ctx, organizationID) if err != nil { - if isHTTPNotFound(err) { + if isNotFound(err) { resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Organization %s not found or inaccessible. Marking its default Agents model selection as deleted.", organizationID)) resp.State.RemoveResource(ctx) return diff --git a/internal/provider/agents_default_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go index 4ed9df3..51c1e5e 100644 --- a/internal/provider/agents_default_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -217,6 +217,10 @@ func TestAgentsDefaultModelReadCollection404(t *testing.T) { name: "other organization missing", targetCollectionStatus: http.StatusNotFound, }, + { + name: "organization missing with Coder 400 response", + targetCollectionStatus: http.StatusBadRequest, + }, { name: "empty supported collection", targetCollectionStatus: http.StatusOK, @@ -291,6 +295,10 @@ func writeAgentsDefaultModelCollectionResponse(w http.ResponseWriter, status int if status == 0 { status = http.StatusInternalServerError } + if status == http.StatusBadRequest { + writeJSON(w, status, codersdk.Response{Message: "must be an existing uuid or username"}) + return + } if status != http.StatusOK { writeJSON(w, status, codersdk.Response{Message: statusMessage(status)}) return From 824066388c6f873580e1c5e8525fababb96baa03 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 10:03:49 +0000 Subject: [PATCH 09/12] fix(coderd_agents_default_model): migrate renamed state --- docs/resources/agents_default_model.md | 3 + .../provider/agents_default_model_resource.go | 74 ++++++++++++++++++- .../agents_default_model_resource_test.go | 50 ++++++++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/docs/resources/agents_default_model.md b/docs/resources/agents_default_model.md index fb31dce..d7c3d71 100644 --- a/docs/resources/agents_default_model.md +++ b/docs/resources/agents_default_model.md @@ -7,6 +7,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. Selects which coderd_agents_model is the default chat model for Coder Agents in an organization. + Existing coderd_default_agents_model state can be migrated with a Terraform moved block. The legacy deployment-wide selection is assigned to the provider's default organization. Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one coderd_agents_default_model resource should exist per organization. Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. --- @@ -20,6 +21,8 @@ This resource is only compatible with Coder version [2.37.0](https://github.com/ Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization. +Existing `coderd_default_agents_model` state can be migrated with a Terraform `moved` block. The legacy deployment-wide selection is assigned to the provider's default organization. + Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one `coderd_agents_default_model` resource should exist per organization. Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index 340b144..f6d0b14 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" "github.com/coder/coder/v2/codersdk" "github.com/google/uuid" @@ -14,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) @@ -26,6 +28,7 @@ var ( _ resource.ResourceWithConfigure = &AgentsDefaultModelResource{} _ resource.ResourceWithImportState = &AgentsDefaultModelResource{} _ resource.ResourceWithModifyPlan = &AgentsDefaultModelResource{} + _ resource.ResourceWithMoveState = &AgentsDefaultModelResource{} ) func NewAgentsDefaultModelResource() resource.Resource { @@ -46,6 +49,13 @@ type AgentsDefaultModelResourceModel struct { ModelID UUID `tfsdk:"model_id"` } +// legacyDefaultAgentsModelResourceModel is the v0 state shape published by +// coderd_default_agents_model in provider v0.0.23. +type legacyDefaultAgentsModelResourceModel struct { + ID types.String `tfsdk:"id"` + ModelID types.String `tfsdk:"model_id"` +} + func (r *AgentsDefaultModelResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_agents_default_model" } @@ -62,6 +72,7 @@ func (r *AgentsDefaultModelResource) Schema(ctx context.Context, req resource.Sc MarkdownDescription: "~> This resource is experimental. Changes are expected, and it is not recommended for production use.\n\n" + "~> **Warning**\nThis resource is only compatible with Coder version [" + agentsDefaultModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + agentsDefaultModelMinVersion + ") and later.\n\n" + "Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization.\n\n" + + "Existing `coderd_default_agents_model` state can be migrated with a Terraform `moved` block. The legacy deployment-wide selection is assigned to the provider's default organization.\n\n" + "Coder enforces a single default model per organization: marking a model as default automatically demotes the " + "previous default in the same operation. Only one `coderd_agents_default_model` resource should exist per organization.\n\n" + "Destroying this resource does not clear the default server-side. Coder requires a default once models exist " + @@ -93,6 +104,67 @@ func (r *AgentsDefaultModelResource) Schema(ctx context.Context, req resource.Sc } } +func (r *AgentsDefaultModelResource) MoveState(ctx context.Context) []resource.StateMover { + return []resource.StateMover{ + { + SourceSchema: &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "model_id": schema.StringAttribute{ + Required: true, + }, + }, + }, + StateMover: func(ctx context.Context, req resource.MoveStateRequest, resp *resource.MoveStateResponse) { + if req.SourceTypeName != "coderd_default_agents_model" || + req.SourceSchemaVersion != 0 || + !strings.HasSuffix(req.SourceProviderAddress, "coder/coderd") { + return + } + if r.data == nil { + resp.Diagnostics.AddError( + "Unable to Move Default Agents Model State", + "The provider was not configured before Terraform attempted to move coderd_default_agents_model state.", + ) + return + } + + if req.SourceState == nil { + resp.Diagnostics.AddError( + "Unable to Move Default Agents Model State", + "Terraform did not provide state matching the coderd_default_agents_model schema.", + ) + return + } + + var source legacyDefaultAgentsModelResourceModel + resp.Diagnostics.Append(req.SourceState.Get(ctx, &source)...) + if resp.Diagnostics.HasError() { + return + } + modelID, err := uuid.Parse(source.ModelID.ValueString()) + if err != nil { + resp.Diagnostics.AddAttributeError( + path.Root("model_id"), + "Unable to Move Default Agents Model State", + fmt.Sprintf("The legacy model ID is not a valid UUID: %s", err), + ) + return + } + + organizationID := r.data.DefaultOrganizationID + resp.Diagnostics.Append(resp.TargetState.Set(ctx, AgentsDefaultModelResourceModel{ + ID: UUIDValue(organizationID), + OrganizationID: UUIDValue(organizationID), + ModelID: UUIDValue(modelID), + })...) + }, + }, + } +} + func (r *AgentsDefaultModelResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return @@ -225,7 +297,7 @@ func (r *AgentsDefaultModelResource) agentsDefaultModelDiag(ctx context.Context, return diags } - endpoint := fmt.Sprintf("/api/experimental/organizations/%s/chats/models/%s", organizationID, modelID) + endpoint := fmt.Sprintf("/api/v2/organizations/%s/chats/models/%s", organizationID, modelID) _, collectionErr := r.experimentalClient().ChatModels(ctx, organizationID) if collectionErr == nil { diags.AddError( diff --git a/internal/provider/agents_default_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go index 51c1e5e..3ee49c7 100644 --- a/internal/provider/agents_default_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -44,6 +44,52 @@ func TestAgentsDefaultModelStateFromModelConfig(t *testing.T) { require.Equal(t, modelID.String(), state.ModelID.ValueString()) } +func TestAgentsDefaultModelMoveState(t *testing.T) { + t.Parallel() + + ctx := t.Context() + organizationID := uuid.New() + modelID := uuid.New() + r := &AgentsDefaultModelResource{data: &CoderdProviderData{DefaultOrganizationID: organizationID}} + movers := r.MoveState(ctx) + require.Len(t, movers, 1) + require.NotNil(t, movers[0].SourceSchema) + + sourceSchema := *movers[0].SourceSchema + sourceState := tfsdk.State{ + Schema: sourceSchema, + Raw: tftypes.NewValue(sourceSchema.Type().TerraformType(ctx), nil), + } + require.False(t, sourceState.Set(ctx, legacyDefaultAgentsModelResourceModel{ + ID: types.StringValue("default"), + ModelID: types.StringValue(modelID.String()), + }).HasError()) + + var targetSchemaResp fwresource.SchemaResponse + r.Schema(ctx, fwresource.SchemaRequest{}, &targetSchemaResp) + require.False(t, targetSchemaResp.Diagnostics.HasError(), targetSchemaResp.Diagnostics) + targetSchema := targetSchemaResp.Schema + resp := &fwresource.MoveStateResponse{ + TargetState: tfsdk.State{ + Schema: targetSchema, + Raw: tftypes.NewValue(targetSchema.Type().TerraformType(ctx), nil), + }, + } + movers[0].StateMover(ctx, fwresource.MoveStateRequest{ + SourceProviderAddress: "registry.example.com/coder/coderd", + SourceSchemaVersion: 0, + SourceState: &sourceState, + SourceTypeName: "coderd_default_agents_model", + }, resp) + require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics) + + var got AgentsDefaultModelResourceModel + require.False(t, resp.TargetState.Get(ctx, &got).HasError()) + require.Equal(t, organizationID, got.ID.ValueUUID()) + require.Equal(t, organizationID, got.OrganizationID.ValueUUID()) + require.Equal(t, modelID, got.ModelID.ValueUUID()) +} + func TestAgentsDefaultModelIDPlanModifier(t *testing.T) { t.Parallel() @@ -159,7 +205,7 @@ func TestAgentsDefaultModelPatch404Diagnostics(t *testing.T) { targetOrganizationID := uuid.New() defaultOrganizationID := uuid.New() modelID := uuid.New() - targetCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) + targetCollectionPath := fmt.Sprintf("/api/v2/organizations/%s/chats/models", targetOrganizationID) modelPath := fmt.Sprintf("%s/%s", targetCollectionPath, modelID) organizationPath := fmt.Sprintf("/api/v2/organizations/%s", targetOrganizationID) var requestCount atomic.Int32 @@ -235,7 +281,7 @@ func TestAgentsDefaultModelReadCollection404(t *testing.T) { defaultOrganizationID = targetOrganizationID } modelID := uuid.New() - targetCollectionPath := fmt.Sprintf("/api/experimental/organizations/%s/chats/models", targetOrganizationID) + targetCollectionPath := fmt.Sprintf("/api/v2/organizations/%s/chats/models", targetOrganizationID) var requestCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { From 11f499c6b859324f2d470ba6a76949e5b82e76f4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 10:27:47 +0000 Subject: [PATCH 10/12] docs(coderd_agents_default_model): omit migration guidance --- docs/resources/agents_default_model.md | 3 --- internal/provider/agents_default_model_resource.go | 1 - 2 files changed, 4 deletions(-) diff --git a/docs/resources/agents_default_model.md b/docs/resources/agents_default_model.md index d7c3d71..fb31dce 100644 --- a/docs/resources/agents_default_model.md +++ b/docs/resources/agents_default_model.md @@ -7,7 +7,6 @@ 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. Selects which coderd_agents_model is the default chat model for Coder Agents in an organization. - Existing coderd_default_agents_model state can be migrated with a Terraform moved block. The legacy deployment-wide selection is assigned to the provider's default organization. Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one coderd_agents_default_model resource should exist per organization. Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. --- @@ -21,8 +20,6 @@ This resource is only compatible with Coder version [2.37.0](https://github.com/ Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization. -Existing `coderd_default_agents_model` state can be migrated with a Terraform `moved` block. The legacy deployment-wide selection is assigned to the provider's default organization. - Coder enforces a single default model per organization: marking a model as default automatically demotes the previous default in the same operation. Only one `coderd_agents_default_model` resource should exist per organization. Destroying this resource does not clear the default server-side. Coder requires a default once models exist and promotes a replacement when the current default is removed, so deleting this resource only stops Terraform from managing which model is default. diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index f6d0b14..4269e0b 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -72,7 +72,6 @@ func (r *AgentsDefaultModelResource) Schema(ctx context.Context, req resource.Sc MarkdownDescription: "~> This resource is experimental. Changes are expected, and it is not recommended for production use.\n\n" + "~> **Warning**\nThis resource is only compatible with Coder version [" + agentsDefaultModelMinVersion + "](https://github.com/coder/coder/releases/tag/v" + agentsDefaultModelMinVersion + ") and later.\n\n" + "Selects which `coderd_agents_model` is the default chat model for Coder Agents in an organization.\n\n" + - "Existing `coderd_default_agents_model` state can be migrated with a Terraform `moved` block. The legacy deployment-wide selection is assigned to the provider's default organization.\n\n" + "Coder enforces a single default model per organization: marking a model as default automatically demotes the " + "previous default in the same operation. Only one `coderd_agents_default_model` resource should exist per organization.\n\n" + "Destroying this resource does not clear the default server-side. Coder requires a default once models exist " + From e9f01cf25b650d436397fd133040ae8f8d1bbb95 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 11:34:47 +0000 Subject: [PATCH 11/12] feat(coderd_agents_default_model)!: import by organization name --- docs/resources/agents_default_model.md | 6 +++--- .../resources/coderd_agents_default_model/import.sh | 6 +++--- internal/provider/agents_default_model_resource.go | 13 +++++++++---- .../provider/agents_default_model_resource_test.go | 7 ++++--- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/resources/agents_default_model.md b/docs/resources/agents_default_model.md index fb31dce..97d754b 100644 --- a/docs/resources/agents_default_model.md +++ b/docs/resources/agents_default_model.md @@ -76,14 +76,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 is the organization UUID whose default model should be imported. -$ terraform import coderd_agents_default_model.default +# The ID supplied is the name of the organization whose default model should be imported. +$ terraform import coderd_agents_default_model.default ``` 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_default_model.default - id = "" + id = "" } ``` diff --git a/examples/resources/coderd_agents_default_model/import.sh b/examples/resources/coderd_agents_default_model/import.sh index e10e3a0..38b8002 100644 --- a/examples/resources/coderd_agents_default_model/import.sh +++ b/examples/resources/coderd_agents_default_model/import.sh @@ -1,10 +1,10 @@ -# The ID supplied is the organization UUID whose default model should be imported. -$ terraform import coderd_agents_default_model.default +# The ID supplied is the name of the organization whose default model should be imported. +$ terraform import coderd_agents_default_model.default ``` 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_default_model.default - id = "" + id = "" } diff --git a/internal/provider/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go index 4269e0b..8ad538c 100644 --- a/internal/provider/agents_default_model_resource.go +++ b/internal/provider/agents_default_model_resource.go @@ -258,10 +258,15 @@ func (r *AgentsDefaultModelResource) Delete(ctx context.Context, req resource.De } func (r *AgentsDefaultModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - // Import by organization ID. Read resolves the organization's current - // default model without promoting or otherwise modifying any model. - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), req.ID)...) + // Import by organization name (or ID). Read resolves the organization's + // current default model without promoting or otherwise modifying any model. + org, err := r.data.Client.OrganizationByName(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get organization %q: %s", req.ID, err)) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), org.ID.String())...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), org.ID.String())...) } // setDefault marks the given model config as the default for its organization diff --git a/internal/provider/agents_default_model_resource_test.go b/internal/provider/agents_default_model_resource_test.go index 3ee49c7..fe4cf02 100644 --- a/internal/provider/agents_default_model_resource_test.go +++ b/internal/provider/agents_default_model_resource_test.go @@ -444,7 +444,8 @@ func TestAccAgentsDefaultModelResource(t *testing.T) { } ctx := t.Context() client := integration.StartCoder(ctx, t, "agents_default_model_acc", integration.UseLicense) - organizationID := accDefaultOrganizationID(ctx, t, client) + organization := accDefaultOrganization(ctx, t, client) + organizationID := organization.ID skipIfAgentsDefaultModelUnsupported(ctx, t, client, organizationID) aiProvider := createAccAgentsModelAIProvider(ctx, t, client) @@ -503,11 +504,11 @@ resource "coderd_agents_default_model" "default" { PlanOnly: true, }, { - // Import by organization UUID; Read resolves its current default. + // Import by organization name; Read resolves its current default. ResourceName: "coderd_agents_default_model.default", ImportState: true, ImportStateVerify: true, - ImportStateId: organizationID.String(), + ImportStateId: organization.Name, }, }, }) From d54038a92cabda8994556e08d7ee0b8758e0d2e4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 11:36:09 +0000 Subject: [PATCH 12/12] feat(coderd_agents_mcp_server)!: import by organization name and slug --- docs/resources/agents_mcp_server.md | 10 +++---- .../coderd_agents_mcp_server/import.sh | 6 ++--- .../provider/agents_mcp_server_resource.go | 27 ++++++++++++++----- .../agents_mcp_server_resource_test.go | 2 +- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/docs/resources/agents_mcp_server.md b/docs/resources/agents_mcp_server.md index e2ec4e8..3ee6167 100644 --- a/docs/resources/agents_mcp_server.md +++ b/docs/resources/agents_mcp_server.md @@ -7,7 +7,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. -> _wo attributes are write-only https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments: their values are sent to Coder but never stored in Terraform state. This resource therefore requires Terraform 1.11 or later. - Configures an organization-scoped MCP server for Coder Agents. Import IDs use /. Changing url, auth_type, oauth2_token_url, oauth2_revocation_url, or oauth2_client_id invalidates users' stored OAuth tokens. + Configures an organization-scoped MCP server for Coder Agents. Import IDs use /. Changing url, auth_type, oauth2_token_url, oauth2_revocation_url, or oauth2_client_id invalidates users' stored OAuth tokens. Coder runs OAuth2 discovery and dynamic client registration only when a server is created with auth_type = "oauth2" and no manual endpoints; updates never re-run discovery. To switch an existing server from manual OAuth2 configuration back to discovery, replace the resource (for example with terraform apply -replace). Removing the manual OAuth2 attributes from configuration leaves the stored values unmanaged rather than clearing them. --- @@ -20,7 +20,7 @@ This resource is only compatible with Coder version [2.37.0](https://github.com/ -> `_wo` attributes are [write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments): their values are sent to Coder but never stored in Terraform state. This resource therefore requires Terraform 1.11 or later. -Configures an organization-scoped MCP server for Coder Agents. Import IDs use `/`. Changing `url`, `auth_type`, `oauth2_token_url`, `oauth2_revocation_url`, or `oauth2_client_id` invalidates users' stored OAuth tokens. +Configures an organization-scoped MCP server for Coder Agents. Import IDs use `/`. Changing `url`, `auth_type`, `oauth2_token_url`, `oauth2_revocation_url`, or `oauth2_client_id` invalidates users' stored OAuth tokens. Coder runs OAuth2 discovery and dynamic client registration only when a server is created with `auth_type = "oauth2"` and no manual endpoints; updates never re-run discovery. To switch an existing server from manual OAuth2 configuration back to discovery, replace the resource (for example with `terraform apply -replace`). Removing the manual OAuth2 attributes from configuration leaves the stored values unmanaged rather than clearing them. @@ -100,14 +100,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 must contain the organization UUID and MCP server configuration UUID. -$ terraform import coderd_agents_mcp_server.example / +# The ID must contain the organization name and the MCP server slug. +$ terraform import coderd_agents_mcp_server.example / ``` 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_mcp_server.example - id = "/" + id = "/" } ``` diff --git a/examples/resources/coderd_agents_mcp_server/import.sh b/examples/resources/coderd_agents_mcp_server/import.sh index f276ab9..f8db1e6 100644 --- a/examples/resources/coderd_agents_mcp_server/import.sh +++ b/examples/resources/coderd_agents_mcp_server/import.sh @@ -1,10 +1,10 @@ -# The ID must contain the organization UUID and MCP server configuration UUID. -$ terraform import coderd_agents_mcp_server.example / +# The ID must contain the organization name and the MCP server slug. +$ terraform import coderd_agents_mcp_server.example / ``` 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_mcp_server.example - id = "/" + id = "/" } diff --git a/internal/provider/agents_mcp_server_resource.go b/internal/provider/agents_mcp_server_resource.go index 40025cb..0bce152 100644 --- a/internal/provider/agents_mcp_server_resource.go +++ b/internal/provider/agents_mcp_server_resource.go @@ -183,7 +183,7 @@ func (r *AgentsMCPServerResource) Schema(ctx context.Context, req resource.Schem MarkdownDescription: "~> This resource is experimental. Changes are expected, and it is not recommended for production use.\n\n" + "~> **Warning**\nThis resource is only compatible with Coder version [" + agentsMCPServerMinVersion + "](https://github.com/coder/coder/releases/tag/v" + agentsMCPServerMinVersion + ") and later.\n\n" + "-> `_wo` attributes are [write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments): their values are sent to Coder but never stored in Terraform state. This resource therefore requires Terraform 1.11 or later.\n\n" + - "Configures an organization-scoped MCP server for Coder Agents. Import IDs use `/`. Changing `url`, `auth_type`, `oauth2_token_url`, `oauth2_revocation_url`, or `oauth2_client_id` invalidates users' stored OAuth tokens.\n\n" + + "Configures an organization-scoped MCP server for Coder Agents. Import IDs use `/`. Changing `url`, `auth_type`, `oauth2_token_url`, `oauth2_revocation_url`, or `oauth2_client_id` invalidates users' stored OAuth tokens.\n\n" + "Coder runs OAuth2 discovery and dynamic client registration only when a server is created with `auth_type = \"oauth2\"` and no manual endpoints; updates never re-run discovery. To switch an existing server from manual OAuth2 configuration back to discovery, replace the resource (for example with `terraform apply -replace`). Removing the manual OAuth2 attributes from configuration leaves the stored values unmanaged rather than clearing them.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ @@ -602,20 +602,33 @@ func (r *AgentsMCPServerResource) Delete(ctx context.Context, req resource.Delet func (r *AgentsMCPServerResource) 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]) + // Slugs are unique per organization, but the get-by-ID endpoint only + // accepts UUIDs, so resolve the slug from the organization's list. + configs, err := r.data.Client.MCPServerConfigs(ctx, org.ID) if err != nil { - resp.Diagnostics.AddError("Invalid Import ID", fmt.Sprintf("Unable to parse MCP server ID as UUID: %s", err)) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to list MCP servers for organization %q: %s", parts[0], err)) return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), organizationID.String())...) + var id uuid.UUID + for _, config := range configs { + if config.Slug == parts[1] { + id = config.ID + break + } + } + if id == uuid.Nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("No MCP server with slug %q exists in organization %q.", parts[1], parts[0])) + return + } + 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_mcp_server_resource_test.go b/internal/provider/agents_mcp_server_resource_test.go index dcb3b3b..01df0e2 100644 --- a/internal/provider/agents_mcp_server_resource_test.go +++ b/internal/provider/agents_mcp_server_resource_test.go @@ -700,7 +700,7 @@ resource "terraform_data" "nullendpoint" { if !ok { return "", fmt.Errorf("coderd_agents_mcp_server.test not found in state") } - return rs.Primary.Attributes["organization_id"] + "/" + rs.Primary.ID, nil + return organizations[0].Name + "/" + rs.Primary.Attributes["slug"], nil }, ImportStateVerifyIgnore: []string{ "oauth2_client_secret_wo",