diff --git a/docs/resources/agents_default_model.md b/docs/resources/agents_default_model.md new file mode 100644 index 0000000..97d754b --- /dev/null +++ b/docs/resources/agents_default_model.md @@ -0,0 +1,89 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +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_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_agents_default_model (Resource) + +~> 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_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. + +## Example Usage + +```terraform +variable "anthropic_api_key" { + type = string + sensitive = true +} + +resource "coderd_ai_provider" "anthropic" { + type = "anthropic" + name = "anthropic" + base_url = "https://api.anthropic.com" + + api_key_wo = var.anthropic_api_key + api_key_wo_version = 1 +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = coderd_ai_provider.anthropic.id + model = "claude-3-5-sonnet-20241022" + display_name = "Claude 3.5 Sonnet" + context_limit = 200000 +} + +# 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_agents_default_model" "default" { + organization_id = coderd_agents_model.sonnet.organization_id + model_id = coderd_agents_model.sonnet.id +} +``` + + +## Schema + +### Required + +- `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) Organization ID that identifies this organization's default Agents model selection. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# The ID supplied 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 = "" +} +``` 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/docs/resources/default_agents_model.md b/docs/resources/default_agents_model.md deleted file mode 100644 index f1be352..0000000 --- a/docs/resources/default_agents_model.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -# generated by https://github.com/hashicorp/terraform-plugin-docs -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. ---- - -# 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. - -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. - -## Example Usage - -```terraform -variable "anthropic_api_key" { - type = string - sensitive = true -} - -resource "coderd_ai_provider" "anthropic" { - type = "anthropic" - name = "anthropic" - base_url = "https://api.anthropic.com" - - api_key_wo = var.anthropic_api_key - api_key_wo_version = 1 -} - -resource "coderd_agents_model" "sonnet" { - ai_provider_id = coderd_ai_provider.anthropic.id - model = "claude-3-5-sonnet-20241022" - display_name = "Claude 3.5 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. -resource "coderd_default_agents_model" "default" { - model_id = coderd_agents_model.sonnet.id -} -``` - - -## Schema - -### Required - -- `model_id` (String) ID of the `coderd_agents_model` to mark as the deployment-wide default. Usually this is `coderd_agents_model..id`. - -### Read-Only - -- `id` (String) Constant identifier for the singleton default Agents model pointer. Always `default`. - -## Import - -Import is supported using the following syntax: - -The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: - -```shell -# The ID supplied is a coderd_agents_model UUID, e.g. coderd_agents_model..id. -$ 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 = "" -} -``` diff --git a/examples/resources/coderd_agents_default_model/import.sh b/examples/resources/coderd_agents_default_model/import.sh new file mode 100644 index 0000000..38b8002 --- /dev/null +++ b/examples/resources/coderd_agents_default_model/import.sh @@ -0,0 +1,10 @@ +# 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 = "" +} diff --git a/examples/resources/coderd_default_agents_model/resource.tf b/examples/resources/coderd_agents_default_model/resource.tf similarity index 57% rename from examples/resources/coderd_default_agents_model/resource.tf rename to examples/resources/coderd_agents_default_model/resource.tf index 4de8662..8e26719 100644 --- a/examples/resources/coderd_default_agents_model/resource.tf +++ b/examples/resources/coderd_agents_default_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. -resource "coderd_default_agents_model" "default" { - model_id = coderd_agents_model.sonnet.id +# 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_agents_default_model" "default" { + organization_id = coderd_agents_model.sonnet.organization_id + model_id = coderd_agents_model.sonnet.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/examples/resources/coderd_default_agents_model/import.sh b/examples/resources/coderd_default_agents_model/import.sh deleted file mode 100644 index 770fe0d..0000000 --- a/examples/resources/coderd_default_agents_model/import.sh +++ /dev/null @@ -1,10 +0,0 @@ -# The ID supplied is a coderd_agents_model UUID, e.g. coderd_agents_model..id. -$ 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 = "" -} 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/agents_default_model_resource.go b/internal/provider/agents_default_model_resource.go new file mode 100644 index 0000000..8ad538c --- /dev/null +++ b/internal/provider/agents_default_model_resource.go @@ -0,0 +1,359 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// agentsDefaultModelMinVersion is the first Coder release that can include the +// organization-scoped chat model API. +const agentsDefaultModelMinVersion = "2.37.0" + +var ( + _ resource.Resource = &AgentsDefaultModelResource{} + _ resource.ResourceWithConfigure = &AgentsDefaultModelResource{} + _ resource.ResourceWithImportState = &AgentsDefaultModelResource{} + _ resource.ResourceWithModifyPlan = &AgentsDefaultModelResource{} + _ resource.ResourceWithMoveState = &AgentsDefaultModelResource{} +) + +func NewAgentsDefaultModelResource() resource.Resource { + return &AgentsDefaultModelResource{} +} + +type AgentsDefaultModelResource struct { + data *CoderdProviderData +} + +func (r *AgentsDefaultModelResource) experimentalClient() *codersdk.ExperimentalClient { + return codersdk.NewExperimentalClient(r.data.Client) +} + +type AgentsDefaultModelResourceModel struct { + ID UUID `tfsdk:"id"` + OrganizationID UUID `tfsdk:"organization_id"` + 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" +} + +func (r *AgentsDefaultModelResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resp.Diagnostics.AddWarning( + "Experimental Resource", + "coderd_agents_default_model is experimental. Changes are expected, and it is not recommended for production use.", + ) +} + +func (r *AgentsDefaultModelResource) 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" + + "~> **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_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.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Organization ID that identifies this organization's default Agents model selection.", + CustomType: UUIDType, + Computed: true, + PlanModifiers: []planmodifier.String{ + useStateForUnknownUnlessChanged("organization_id"), + }, + }, + "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 organization's default. Usually this is `coderd_agents_model..id`.", + CustomType: UUIDType, + Required: true, + }, + }, + } +} + +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 + } + data, ok := req.ProviderData.(*CoderdProviderData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.data = data +} + +func (r *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 + } + 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.Append(r.agentsDefaultModelDiag(ctx, "set", plan.OrganizationID.ValueUUID(), plan.ModelID.ValueUUID(), err)...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +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 + } + + organizationID := state.OrganizationID.ValueUUID() + configs, err := r.experimentalClient().ChatModels(ctx, organizationID) + if err != nil { + 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 + } + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read default Agents model, got error: %s", err)) + return + } + + for _, config := range configs.Models { + if config.IsDefault { + state = stateFromAgentsDefaultModelConfig(config) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + return + } + } + + // 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) in organization %s. Marking as deleted.", len(configs.Models), state.OrganizationID.ValueString())) + resp.State.RemoveResource(ctx) +} + +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() { + return + } + + tflog.Info(ctx, "updating default Agents model", map[string]any{ + "organization_id": state.OrganizationID.ValueString(), + "model_id": plan.ModelID.ValueString(), + }) + organizationID := state.OrganizationID.ValueUUID() + updated, err := r.setDefault(ctx, organizationID, plan.ModelID.ValueUUID()) + if err != nil { + resp.Diagnostics.Append(r.agentsDefaultModelDiag(ctx, "update", organizationID, plan.ModelID.ValueUUID(), err)...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &updated)...) +} + +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_agents_default_model is a no-op; Coder retains its current default model") +} + +func (r *AgentsDefaultModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // 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 +// 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 *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 AgentsDefaultModelResourceModel{}, err + } + return stateFromAgentsDefaultModelConfig(updated), nil +} + +// 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 stateFromAgentsDefaultModelConfig(config codersdk.ChatModel) AgentsDefaultModelResourceModel { + return AgentsDefaultModelResourceModel{ + ID: UUIDValue(config.OrganizationID), + OrganizationID: UUIDValue(config.OrganizationID), + ModelID: UUIDValue(config.ID), + } +} + +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 + } + + endpoint := fmt.Sprintf("/api/v2/organizations/%s/chats/models/%s", organizationID, modelID) + _, collectionErr := r.experimentalClient().ChatModels(ctx, organizationID) + if collectionErr == nil { + diags.AddError( + "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 + } + + 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 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 +} + +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 new file mode 100644 index 0000000..fe4cf02 --- /dev/null +++ b/internal/provider/agents_default_model_resource_test.go @@ -0,0 +1,735 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sync/atomic" + "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" + "github.com/stretchr/testify/require" +) + +func TestAgentsDefaultModelStateFromModelConfig(t *testing.T) { + t.Parallel() + + organizationID := uuid.New() + modelID := uuid.New() + state := stateFromAgentsDefaultModelConfig(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()) +} + +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() + + 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 + 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) { + t.Parallel() + + targetOrganizationID := uuid.New() + defaultOrganizationID := uuid.New() + modelID := uuid.New() + 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 + + 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 == 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"}) + } + })) + 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.") + for _, want := range tc.wantDetailContains { + require.Contains(t, diags.Errors()[0].Detail(), want) + } + require.Equal(t, tc.wantRequestCount, requestCount.Load()) + }) + } +} + +func TestAgentsDefaultModelReadCollection404(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + targetCollectionStatus int + providerDefaultMatchesTargetID bool + }{ + { + name: "configured provider default organization missing", + targetCollectionStatus: http.StatusNotFound, + providerDefaultMatchesTargetID: true, + }, + { + name: "other organization missing", + targetCollectionStatus: http.StatusNotFound, + }, + { + name: "organization missing with Coder 400 response", + targetCollectionStatus: http.StatusBadRequest, + }, + { + name: "empty supported collection", + targetCollectionStatus: http.StatusOK, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + targetOrganizationID := uuid.New() + defaultOrganizationID := uuid.New() + if tc.providerDefaultMatchesTargetID { + defaultOrganizationID = targetOrganizationID + } + modelID := uuid.New() + 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) { + 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) + + 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.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") + }) + } +} + +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.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 + } + 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) { + t.Parallel() + + // PlanOnly reaches provider Configure(), which fetches the current user + // and entitlements, so use a mock server instead of an unreachable URL. + srv := newMockServer(nil) + defer srv.Close() + + cfg := `provider "coderd" { + url = "` + srv.URL + `" + token = "test-token" +} + +variable "model_id" { + type = string +} + +resource "coderd_agents_default_model" "default" { + organization_id = "` + uuid.NewString() + `" + model_id = var.model_id +} +` + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // model_id is unknown during the validate walk even though + // ConfigVariables supplies a concrete plan value. + Config: cfg, + ConfigVariables: config.Variables{ + "model_id": config.StringVariable(uuid.NewString()), + }, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +// TestAgentsDefaultModelResourceDefersUnknownOrganizationID checks planning +// succeeds when organization_id comes from another resource and is therefore +// unknown until apply. +func TestAgentsDefaultModelResourceDefersUnknownOrganizationID(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_agents_default_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 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, "agents_default_model_acc", integration.UseLicense) + organization := accDefaultOrganization(ctx, t, client) + organizationID := organization.ID + skipIfAgentsDefaultModelUnsupported(ctx, t, client, organizationID) + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + + cfg := func(defaultModel string) string { + return fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = %q + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 +} + +resource "coderd_agents_model" "opus" { + ai_provider_id = %q + model = "claude-3-opus-20240229" + context_limit = 200000 +} + +resource "coderd_agents_default_model" "default" { + organization_id = %q + model_id = coderd_agents_model.%s.id +} +`, client.URL.String(), client.SessionToken(), aiProvider.ID.String(), aiProvider.ID.String(), organizationID.String(), defaultModel) + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg("sonnet"), + Check: resource.ComposeAggregateTestCheckFunc( + 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"), + ), + }, + { + // Re-point the default to opus. Coder demotes sonnet atomically in + // the same operation, so exactly one model remains default. + Config: cfg("opus"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair("coderd_agents_default_model.default", "model_id", "coderd_agents_model.opus", "id"), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_agents_default_model.default"), + ), + }, + { + // A steady-state re-plan must be empty (no perpetual diff). + Config: cfg("opus"), + PlanOnly: true, + }, + { + // Import by organization name; Read resolves its current default. + ResourceName: "coderd_agents_default_model.default", + ImportState: true, + ImportStateVerify: true, + ImportStateId: organization.Name, + }, + }, + }) +} + +// 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 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, "agents_default_model_drift_acc", integration.UseLicense) + organizationID := accDefaultOrganizationID(ctx, t, client) + skipIfAgentsDefaultModelUnsupported(ctx, t, client, organizationID) + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + + 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(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_agents_default_model" "default" { + organization_id = %q + model_id = %q +} +`, client.URL.String(), client.SessionToken(), organizationID.String(), sonnet.ID.String()) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + 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, organizationID) + if len(defaults) != 1 { + return fmt.Errorf("expected exactly one default model after destroy, got %d: %v", len(defaults), defaults) + } + if defaults[0] != sonnet.ID { + return fmt.Errorf("expected default to remain %s after destroy, got %s", sonnet.ID, defaults[0]) + } + return nil + }, + Steps: []resource.TestStep{ + { + Config: cfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_agents_default_model.default", "model_id", sonnet.ID.String()), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_agents_default_model.default"), + ), + }, + { + // Externally re-point the default to opus, then expect Terraform to + // detect the drift on refresh and plan to restore sonnet. + PreConfig: func() { + _, err := exp.UpdateChatModel(ctx, organizationID, opus.ID, codersdk.UpdateChatModelRequest{ + IsDefault: new(true), + }) + require.NoError(t, err, "externally set opus as default") + }, + Config: cfg, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + { + // Re-applying reconciles the default back to sonnet. + Config: cfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_agents_default_model.default", "model_id", sonnet.ID.String()), + checkServerDefaultMatchesResource(ctx, t, client, organizationID, "coderd_agents_default_model.default"), + ), + }, + }, + }) +} + +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, "agents_default_model_org_isolation_acc", integration.UseLicense) + defaultOrganizationID := accDefaultOrganizationID(ctx, t, client) + skipIfAgentsDefaultModelUnsupported(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_agents_default_model" "default_org" { + organization_id = %q + model_id = %q +} + +resource "coderd_agents_default_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_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"), + ), + }, + { + Config: cfg, + PlanOnly: true, + }, + }, + }) +} + +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 + // 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, organizationID, aiProviderID uuid.UUID, model string) codersdk.ChatModel { + t.Helper() + exp := codersdk.NewExperimentalClient(client) + created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &aiProviderID, + Model: model, + ContextLimit: new(int64(200000)), + }) + require.NoError(t, err, "create chat model config out-of-band") + // WithoutCancel: t.Context() is already cancelled by the time cleanup runs. + t.Cleanup(func() { _ = exp.DeleteChatModel(context.WithoutCancel(t.Context()), organizationID, created.ID) }) + return created +} + +// 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() + exp := codersdk.NewExperimentalClient(client) + configs, err := exp.ChatModels(ctx, organizationID) + require.NoError(t, err, "list chat models") + var defaults []uuid.UUID + for _, c := range configs.Models { + if c.IsDefault { + defaults = append(defaults, c.ID) + } + } + return defaults +} + +// checkServerDefaultMatchesResource asserts Coder reports exactly one default +// 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, organizationID) + if len(defaults) != 1 { + return fmt.Errorf("expected exactly one default model in organization %s, got %d: %v", organizationID, len(defaults), defaults) + } + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + 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) + } + return nil + } +} 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", diff --git a/internal/provider/default_agents_model_resource.go b/internal/provider/default_agents_model_resource.go deleted file mode 100644 index 426a17a..0000000 --- a/internal/provider/default_agents_model_resource.go +++ /dev/null @@ -1,190 +0,0 @@ -package provider - -import ( - "context" - "fmt" - - "github.com/coder/coder/v2/codersdk" - "github.com/google/uuid" - "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-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" - -var ( - _ resource.Resource = &DefaultAgentsModelResource{} - _ resource.ResourceWithConfigure = &DefaultAgentsModelResource{} - _ resource.ResourceWithImportState = &DefaultAgentsModelResource{} - _ resource.ResourceWithModifyPlan = &DefaultAgentsModelResource{} -) - -func NewDefaultAgentsModelResource() resource.Resource { - return &DefaultAgentsModelResource{} -} - -type DefaultAgentsModelResource struct { - data *CoderdProviderData -} - -func (r *DefaultAgentsModelResource) experimentalClient() *codersdk.ExperimentalClient { - return codersdk.NewExperimentalClient(r.data.Client) -} - -type DefaultAgentsModelResourceModel 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" -} - -func (r *DefaultAgentsModelResource) 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.", - ) -} - -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.", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - MarkdownDescription: "Constant identifier for the singleton default Agents model pointer. Always `default`.", - Computed: true, - Default: stringdefault.StaticString(defaultAgentsModelID), - }, - "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`.", - CustomType: UUIDType, - Required: true, - }, - }, - } -} - -func (r *DefaultAgentsModelResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - data, ok := req.ProviderData.(*CoderdProviderData) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), - ) - return - } - r.data = data -} - -func (r *DefaultAgentsModelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan DefaultAgentsModelResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - 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()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to set default Agents model, got error: %s", 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 - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - - configs, err := r.experimentalClient().ChatModels(ctx, r.data.DefaultOrganizationID) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read default Agents model, got error: %s", err)) - return - } - - for _, config := range configs.Models { - if config.IsDefault { - state = stateFromDefaultModelConfig(config) - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) - return - } - } - - // Coder keeps a default model whenever any model exists, so reaching here - // means there are no models at all. Treat the pointer as deleted. - resp.Diagnostics.AddWarning("Client Warning", - fmt.Sprintf("No default Agents model found among %d model config(s). Marking as deleted.", len(configs.Models))) - resp.State.RemoveResource(ctx) -} - -func (r *DefaultAgentsModelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan DefaultAgentsModelResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - 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()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update default Agents model, got error: %s", err)) - return - } - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) -} - -func (r *DefaultAgentsModelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - 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) -} - -// setDefault marks the given model config as the deployment-wide default 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{ - IsDefault: new(true), - }) - if err != nil { - return DefaultAgentsModelResourceModel{}, err - } - return stateFromDefaultModelConfig(updated), nil -} - -// stateFromDefaultModelConfig maps the model config that Coder reports as the -// default into resource state. The resource ID is a constant because the default -// is a global singleton. -func stateFromDefaultModelConfig(config codersdk.ChatModel) DefaultAgentsModelResourceModel { - return DefaultAgentsModelResourceModel{ - ID: types.StringValue(defaultAgentsModelID), - ModelID: UUIDValue(config.ID), - } -} diff --git a/internal/provider/default_agents_model_resource_test.go b/internal/provider/default_agents_model_resource_test.go deleted file mode 100644 index 60f9719..0000000 --- a/internal/provider/default_agents_model_resource_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "os" - "testing" - - "github.com/coder/coder/v2/codersdk" - "github.com/coder/terraform-provider-coderd/integration" - "github.com/google/uuid" - "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) { - 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()) -} - -// TestDefaultAgentsModelResourceValidationDefersUnknownConfig checks validation -// passes when model_id is unknown, like when it comes from an unset variable. -func TestDefaultAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { - t.Parallel() - - // PlanOnly reaches provider Configure(), which fetches the current user - // and entitlements, so use a mock server instead of an unreachable URL. - srv := newMockServer(nil) - defer srv.Close() - - cfg := `provider "coderd" { - url = "` + srv.URL + `" - token = "test-token" -} - -variable "model_id" { - type = string -} - -resource "coderd_default_agents_model" "default" { - model_id = var.model_id -} -` - resource.Test(t, resource.TestCase{ - IsUnitTest: true, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - // model_id is unknown during the validate walk even though - // ConfigVariables supplies a concrete plan value. - Config: cfg, - ConfigVariables: config.Variables{ - "model_id": config.StringVariable(uuid.NewString()), - }, - PlanOnly: true, - ExpectNonEmptyPlan: true, - }, - }, - }) -} - -func TestAccDefaultAgentsModelResource(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) - skipUnlessAgentsModelEndpoint(ctx, t, client) - aiProvider := createAccAgentsModelAIProvider(ctx, t, client) - - cfg := func(defaultModel string) string { - return fmt.Sprintf(` -provider "coderd" { - url = %q - token = %q -} - -resource "coderd_agents_model" "sonnet" { - ai_provider_id = %q - model = "claude-3-5-sonnet-20241022" - context_limit = 200000 -} - -resource "coderd_agents_model" "opus" { - ai_provider_id = %q - model = "claude-3-opus-20240229" - context_limit = 200000 -} - -resource "coderd_default_agents_model" "default" { - model_id = coderd_agents_model.%s.id -} -`, client.URL.String(), client.SessionToken(), aiProvider.ID.String(), aiProvider.ID.String(), defaultModel) - } - - resource.Test(t, resource.TestCase{ - IsUnitTest: true, - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: cfg("sonnet"), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "id", "default"), - resource.TestCheckResourceAttrPair("coderd_default_agents_model.default", "model_id", "coderd_agents_model.sonnet", "id"), - checkServerDefaultMatchesResource(ctx, t, client), - ), - }, - { - // Re-point the default to opus. Coder demotes sonnet atomically in - // 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), - ), - }, - { - // A steady-state re-plan must be empty (no perpetual diff). - Config: cfg("opus"), - PlanOnly: true, - }, - { - // Import by the model_id UUID; Read reconciles to the 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 - }, - }, - }, - }) -} - -// TestAccDefaultAgentsModelResourceDriftAndDelete 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) { - 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) - skipUnlessAgentsModelEndpoint(ctx, t, client) - organizationID := accDefaultOrganizationID(ctx, t, client) - 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") - exp := codersdk.NewExperimentalClient(client) - - cfg := fmt.Sprintf(` -provider "coderd" { - url = %q - token = %q -} - -resource "coderd_default_agents_model" "default" { - model_id = %q -} -`, client.URL.String(), client.SessionToken(), sonnet.ID.String()) - - resource.Test(t, resource.TestCase{ - IsUnitTest: true, - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - 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) - if len(defaults) != 1 { - return fmt.Errorf("expected exactly one default model after destroy, got %d: %v", len(defaults), defaults) - } - if defaults[0] != sonnet.ID { - return fmt.Errorf("expected default to remain %s after destroy, got %s", sonnet.ID, defaults[0]) - } - return nil - }, - Steps: []resource.TestStep{ - { - Config: cfg, - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_default_agents_model.default", "model_id", sonnet.ID.String()), - checkServerDefaultMatchesResource(ctx, t, client), - ), - }, - { - // Externally re-point the default to opus, then expect Terraform to - // detect the drift on refresh and plan to restore sonnet. - PreConfig: func() { - _, err := exp.UpdateChatModel(ctx, organizationID, opus.ID, codersdk.UpdateChatModelRequest{ - IsDefault: new(true), - }) - require.NoError(t, err, "externally set opus as default") - }, - Config: cfg, - PlanOnly: true, - ExpectNonEmptyPlan: true, - }, - { - // 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), - ), - }, - }, - }) -} - -// 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 { - t.Helper() - organizationID := accDefaultOrganizationID(ctx, t, client) - exp := codersdk.NewExperimentalClient(client) - created, err := exp.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ - AIProviderID: &aiProviderID, - Model: model, - ContextLimit: new(int64(200000)), - }) - require.NoError(t, err, "create chat model config out-of-band") - // WithoutCancel: t.Context() is already cancelled by the time cleanup runs. - t.Cleanup(func() { _ = exp.DeleteChatModel(context.WithoutCancel(t.Context()), organizationID, created.ID) }) - 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 { - t.Helper() - organizationID := accDefaultOrganizationID(ctx, t, client) - exp := codersdk.NewExperimentalClient(client) - configs, err := exp.ChatModels(ctx, organizationID) - require.NoError(t, err, "list chat models") - var defaults []uuid.UUID - for _, c := range configs.Models { - if c.IsDefault { - defaults = append(defaults, c.ID) - } - } - return defaults -} - -// 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 { - return func(s *terraform.State) error { - defaults := serverDefaultModelIDs(ctx, t, client) - if len(defaults) != 1 { - return fmt.Errorf("expected exactly one default model, got %d: %v", len(defaults), defaults) - } - rs, ok := s.RootModule().Resources["coderd_default_agents_model.default"] - if !ok { - return fmt.Errorf("coderd_default_agents_model.default not found in state") - } - 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) - } - return nil - } -} 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, }