From 0c22e42b184f92efe660efb4538e612b55a25221 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 18 Jun 2026 09:55:01 +0000 Subject: [PATCH 1/6] Add coderd_experimental_ai_provider resource Adds a Terraform resource for declarative Coder AI provider configuration, supporting all SDK provider types with AWS Bedrock and API-key providers. Secrets use Terraform 1.11+ write-only arguments and are never stored in state. --- docs/resources/experimental_ai_provider.md | 119 ++++ .../coderd_experimental_ai_provider/import.sh | 12 + .../resource.tf | 31 + internal/provider/ai_provider_resource.go | 616 ++++++++++++++++++ .../provider/ai_provider_resource_test.go | 442 +++++++++++++ internal/provider/provider.go | 1 + main.go | 2 +- 7 files changed, 1222 insertions(+), 1 deletion(-) create mode 100644 docs/resources/experimental_ai_provider.md create mode 100644 examples/resources/coderd_experimental_ai_provider/import.sh create mode 100644 examples/resources/coderd_experimental_ai_provider/resource.tf create mode 100644 internal/provider/ai_provider_resource.go create mode 100644 internal/provider/ai_provider_resource_test.go diff --git a/docs/resources/experimental_ai_provider.md b/docs/resources/experimental_ai_provider.md new file mode 100644 index 00000000..6b2f2503 --- /dev/null +++ b/docs/resources/experimental_ai_provider.md @@ -0,0 +1,119 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_experimental_ai_provider Resource - terraform-provider-coderd" +subcategory: "" +description: |- + Experimental Coder AI provider configuration. + _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. + For type = "bedrock", omit settings.bedrock.access_key_wo and settings.bedrock.access_key_secret_wo to use the AWS SDK default credential chain as resolved by the Coder server process (IAM role, IRSA, environment variables, shared config, SSO, IMDS, and more). Set both together to use static IAM-user credentials. +--- + +# coderd_experimental_ai_provider (Resource) + +Experimental Coder AI provider configuration. + +`_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. + +For `type = "bedrock"`, omit `settings.bedrock.access_key_wo` and `settings.bedrock.access_key_secret_wo` to use the AWS SDK default credential chain as resolved by the Coder server process (IAM role, IRSA, environment variables, shared config, SSO, IMDS, and more). Set both together to use static IAM-user credentials. + +## Example Usage + +```terraform +resource "coderd_experimental_ai_provider" "bedrock" { + type = "bedrock" + name = "aws-bedrock" + display_name = "AWS Bedrock" + enabled = true + base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + + settings = { + bedrock = { + model = "anthropic.claude-3-5-sonnet-20241022-v2:0" + small_fast_model = "anthropic.claude-3-5-haiku-20241022-v1:0" + + // Omit these to use the AWS SDK default credential chain from the Coder server + // process (for example IAM role / IRSA). Set both to use static credentials. + // access_key_wo = var.bedrock_access_key + // access_key_secret_wo = var.bedrock_access_key_secret + // credentials_wo_version = 1 + } + } +} + +resource "coderd_experimental_ai_provider" "openai" { + type = "openai" + name = "openai" + display_name = "OpenAI" + enabled = true + base_url = "https://api.openai.com" + + api_key_wo = var.openai_api_key + api_key_wo_version = 1 +} +``` + + +## Schema + +### Required + +- `base_url` (String) Absolute HTTP(S) base URL for the upstream provider endpoint. +- `name` (String) Unique provider name. Must be lowercase alphanumeric words separated by hyphens. +- `type` (String) AI provider type. Valid values are `openai`, `anthropic`, `azure`, `bedrock`, `google`, `openai-compat`, `openrouter`, `vercel`, and `copilot`. + +### Optional + +> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later. + +- `api_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Plaintext API key for the provider. Not valid for `bedrock` or `copilot`. Bump `api_key_wo_version` to rotate it. +- `api_key_wo_version` (Number) Version for the write-only API key. Required when `api_key_wo` is set; bump it whenever `api_key_wo` changes to rotate the stored key. +- `display_name` (String) Display name shown in Coder. If omitted, Coder returns the provider name. +- `enabled` (Boolean) Whether this AI provider is enabled. Defaults to true. +- `settings` (Attributes) Type-specific provider settings. (see [below for nested schema](#nestedatt--settings)) + +### Read-Only + +- `api_key_masked` (String) Masked API key value returned by Coder for display only. +- `created_at` (Number) Creation timestamp as Unix seconds. +- `id` (String) AI provider ID. +- `updated_at` (Number) Last update timestamp as Unix seconds. + + +### Nested Schema for `settings` + +Optional: + +- `bedrock` (Attributes) AWS Bedrock settings. Valid only for `type = "bedrock"` or `type = "anthropic"`. (see [below for nested schema](#nestedatt--settings--bedrock)) + + +### Nested Schema for `settings.bedrock` + +Optional: + +- `access_key_secret_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) AWS secret access key for Bedrock. +- `access_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) AWS access key ID for Bedrock. See [Coder's Amazon Bedrock provider docs](https://coder.com/docs/@v2.34.3/ai-coder/ai-gateway/providers#amazon-bedrock). +- `credentials_wo_version` (Number) Version for Bedrock write-only credentials. Bump this value to send, rotate, or clear credentials. +- `model` (String) Primary Bedrock model identifier. +- `region` (String) AWS region for Bedrock. If omitted, derived from the canonical Bedrock `base_url` attribute. +- `small_fast_model` (String) Small/fast Bedrock model identifier used for background tasks. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# The ID supplied can be either an AI provider UUID or name. +# Existing remote API keys are preserved. Omit api_key_wo and api_key_wo_version +# to leave them unmanaged, or configure both to replace them on a later apply. +$ terraform import coderd_experimental_ai_provider.example openai +``` +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_experimental_ai_provider.example + id = "openai" +} +``` diff --git a/examples/resources/coderd_experimental_ai_provider/import.sh b/examples/resources/coderd_experimental_ai_provider/import.sh new file mode 100644 index 00000000..7aee8aba --- /dev/null +++ b/examples/resources/coderd_experimental_ai_provider/import.sh @@ -0,0 +1,12 @@ +# The ID supplied can be either an AI provider UUID or name. +# Existing remote API keys are preserved. Omit api_key_wo and api_key_wo_version +# to leave them unmanaged, or configure both to replace them on a later apply. +$ terraform import coderd_experimental_ai_provider.example openai +``` +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_experimental_ai_provider.example + id = "openai" +} diff --git a/examples/resources/coderd_experimental_ai_provider/resource.tf b/examples/resources/coderd_experimental_ai_provider/resource.tf new file mode 100644 index 00000000..02acff0d --- /dev/null +++ b/examples/resources/coderd_experimental_ai_provider/resource.tf @@ -0,0 +1,31 @@ +resource "coderd_experimental_ai_provider" "bedrock" { + type = "bedrock" + name = "aws-bedrock" + display_name = "AWS Bedrock" + enabled = true + base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + + settings = { + bedrock = { + model = "anthropic.claude-3-5-sonnet-20241022-v2:0" + small_fast_model = "anthropic.claude-3-5-haiku-20241022-v1:0" + + // Omit these to use the AWS SDK default credential chain from the Coder server + // process (for example IAM role / IRSA). Set both to use static credentials. + // access_key_wo = var.bedrock_access_key + // access_key_secret_wo = var.bedrock_access_key_secret + // credentials_wo_version = 1 + } + } +} + +resource "coderd_experimental_ai_provider" "openai" { + type = "openai" + name = "openai" + display_name = "OpenAI" + enabled = true + base_url = "https://api.openai.com" + + api_key_wo = var.openai_api_key + api_key_wo_version = 1 +} diff --git a/internal/provider/ai_provider_resource.go b/internal/provider/ai_provider_resource.go new file mode 100644 index 00000000..7ef6c7c4 --- /dev/null +++ b/internal/provider/ai_provider_resource.go @@ -0,0 +1,616 @@ +package provider + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + bedrockCanonicalBaseURLRegex = regexp.MustCompile(`(?i)^https://bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com/?$`) + + _ resource.Resource = &AIProviderResource{} + _ resource.ResourceWithImportState = &AIProviderResource{} + _ resource.ResourceWithValidateConfig = &AIProviderResource{} +) + +func NewAIProviderResource() resource.Resource { + return &AIProviderResource{} +} + +type AIProviderResource struct { + data *CoderdProviderData +} + +type AIProviderResourceModel struct { + ID UUID `tfsdk:"id"` + Type types.String `tfsdk:"type"` + Name types.String `tfsdk:"name"` + DisplayName types.String `tfsdk:"display_name"` + Enabled types.Bool `tfsdk:"enabled"` + BaseURL types.String `tfsdk:"base_url"` + APIKeyWO types.String `tfsdk:"api_key_wo"` + APIKeyWOVersion types.Int64 `tfsdk:"api_key_wo_version"` + APIKeyMasked types.String `tfsdk:"api_key_masked"` + Settings *AIProviderSettingsModel `tfsdk:"settings"` + CreatedAt types.Int64 `tfsdk:"created_at"` + UpdatedAt types.Int64 `tfsdk:"updated_at"` +} + +type AIProviderSettingsModel struct { + Bedrock *AIProviderBedrockSettingsModel `tfsdk:"bedrock"` +} + +type AIProviderBedrockSettingsModel struct { + Region types.String `tfsdk:"region"` + Model types.String `tfsdk:"model"` + SmallFastModel types.String `tfsdk:"small_fast_model"` + AccessKeyWO types.String `tfsdk:"access_key_wo"` + AccessKeySecretWO types.String `tfsdk:"access_key_secret_wo"` + CredentialsWOVersion types.Int64 `tfsdk:"credentials_wo_version"` +} + +func (r *AIProviderResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_experimental_ai_provider" +} + +func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Experimental Coder AI provider configuration.\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" + + "For `type = \"bedrock\"`, omit `settings.bedrock.access_key_wo` and `settings.bedrock.access_key_secret_wo` to use the AWS SDK default credential chain as resolved by the Coder server process (IAM role, IRSA, environment variables, shared config, SSO, IMDS, and more). Set both together to use static IAM-user credentials.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "AI provider ID.", + CustomType: UUIDType, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "AI provider type. Valid values are `openai`, `anthropic`, `azure`, `bedrock`, `google`, `openai-compat`, `openrouter`, `vercel`, and `copilot`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + string(codersdk.AIProviderTypeOpenAI), + string(codersdk.AIProviderTypeAnthropic), + string(codersdk.AIProviderTypeAzure), + string(codersdk.AIProviderTypeBedrock), + string(codersdk.AIProviderTypeGoogle), + string(codersdk.AIProviderTypeOpenAICompat), + string(codersdk.AIProviderTypeOpenrouter), + string(codersdk.AIProviderTypeVercel), + string(codersdk.AIProviderTypeCopilot), + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Unique provider name. Must be lowercase alphanumeric words separated by hyphens.", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(codersdk.AIProviderNameRegex, "must be lowercase alphanumeric words separated by hyphens"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name shown in Coder. If omitted, Coder returns the provider name.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "enabled": schema.BoolAttribute{ + MarkdownDescription: "Whether this AI provider is enabled. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "base_url": schema.StringAttribute{ + MarkdownDescription: "Absolute HTTP(S) base URL for the upstream provider endpoint.", + Required: true, + }, + "api_key_wo": schema.StringAttribute{ + MarkdownDescription: "Plaintext API key for the provider. Not valid for `bedrock` or `copilot`. Bump `api_key_wo_version` to rotate it.", + Optional: true, + Sensitive: true, + WriteOnly: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.AlsoRequires(path.MatchRoot("api_key_wo_version")), + }, + }, + "api_key_wo_version": schema.Int64Attribute{ + MarkdownDescription: "Version for the write-only API key. Required when `api_key_wo` is set; bump it whenever `api_key_wo` changes to rotate the stored key.", + Optional: true, + }, + "api_key_masked": schema.StringAttribute{ + MarkdownDescription: "Masked API key value returned by Coder for display only.", + Computed: true, + }, + "settings": schema.SingleNestedAttribute{ + MarkdownDescription: "Type-specific provider settings.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "bedrock": schema.SingleNestedAttribute{ + MarkdownDescription: "AWS Bedrock settings. Valid only for `type = \"bedrock\"` or `type = \"anthropic\"`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "region": schema.StringAttribute{ + MarkdownDescription: "AWS region for Bedrock. If omitted, derived from the canonical Bedrock `base_url` attribute.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "model": schema.StringAttribute{ + MarkdownDescription: "Primary Bedrock model identifier.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "small_fast_model": schema.StringAttribute{ + MarkdownDescription: "Small/fast Bedrock model identifier used for background tasks.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "access_key_wo": schema.StringAttribute{ + MarkdownDescription: "AWS access key ID for Bedrock. See [Coder's Amazon Bedrock provider docs](https://coder.com/docs/@v2.34.3/ai-coder/ai-gateway/providers#amazon-bedrock).", + Optional: true, + Sensitive: true, + WriteOnly: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires( + path.MatchRoot("settings").AtName("bedrock").AtName("access_key_secret_wo"), + path.MatchRoot("settings").AtName("bedrock").AtName("credentials_wo_version"), + ), + }, + }, + "access_key_secret_wo": schema.StringAttribute{ + MarkdownDescription: "AWS secret access key for Bedrock.", + Optional: true, + Sensitive: true, + WriteOnly: true, + Validators: []validator.String{ + stringvalidator.AlsoRequires( + path.MatchRoot("settings").AtName("bedrock").AtName("access_key_wo"), + path.MatchRoot("settings").AtName("bedrock").AtName("credentials_wo_version"), + ), + }, + }, + "credentials_wo_version": schema.Int64Attribute{ + MarkdownDescription: "Version for Bedrock write-only credentials. Bump this value to send, rotate, or clear credentials.", + Optional: true, + }, + }, + }, + }, + }, + "created_at": schema.Int64Attribute{ + MarkdownDescription: "Creation timestamp as Unix seconds.", + Computed: true, + }, + "updated_at": schema.Int64Attribute{ + MarkdownDescription: "Last update timestamp as Unix seconds.", + Computed: true, + }, + }, + } +} + +func (r *AIProviderResource) 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 *AIProviderResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + // The pointer-based model can't decode an unknown settings/bedrock object + // (e.g. settings = var.x), so defer validation until those are known. + var settings types.Object + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("settings"), &settings)...) + if resp.Diagnostics.HasError() { + return + } + if settings.IsUnknown() { + return + } + if !settings.IsNull() { + var bedrock types.Object + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("settings").AtName("bedrock"), &bedrock)...) + if resp.Diagnostics.HasError() { + return + } + if bedrock.IsUnknown() { + return + } + } + + var data AIProviderResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + baseURL := "" + baseURLKnown := !data.BaseURL.IsUnknown() + if baseURLKnown { + baseURL = data.BaseURL.ValueString() + validateAIProviderBaseURL(resp.Diagnostics.AddAttributeError, path.Root("base_url"), baseURL) + } + + if data.Type.IsUnknown() { + return + } + providerType := codersdk.AIProviderType(data.Type.ValueString()) + + if !data.APIKeyWO.IsNull() && !data.APIKeyWO.IsUnknown() && (providerType == codersdk.AIProviderTypeBedrock || providerType == codersdk.AIProviderTypeCopilot) { + resp.Diagnostics.AddAttributeError(path.Root("api_key_wo"), "Invalid Attribute Combination", fmt.Sprintf("`api_key_wo` must not be configured when `type` is `%s`.", providerType)) + } + + bedrock := data.bedrock() + if bedrock == nil { + if providerType == codersdk.AIProviderTypeBedrock { + resp.Diagnostics.AddAttributeError(path.Root("settings"), "Missing Bedrock Settings", "`type = \"bedrock\"` requires `settings.bedrock` with at least `region` or write-only AWS credentials.") + } + return + } + + if providerType != codersdk.AIProviderTypeAnthropic && providerType != codersdk.AIProviderTypeBedrock { + resp.Diagnostics.AddAttributeError(path.Root("settings").AtName("bedrock"), "Invalid Attribute Combination", "`settings.bedrock` is only valid when `type` is `anthropic` or `bedrock`.") + } + accessSet := !bedrock.AccessKeyWO.IsNull() && !bedrock.AccessKeyWO.IsUnknown() + secretSet := !bedrock.AccessKeySecretWO.IsNull() && !bedrock.AccessKeySecretWO.IsUnknown() + if providerType == codersdk.AIProviderTypeBedrock { + if !baseURLKnown || bedrock.Region.IsUnknown() || bedrock.AccessKeyWO.IsUnknown() || bedrock.AccessKeySecretWO.IsUnknown() { + return + } + sdkSettings := codersdk.AIProviderBedrockSettings{ + Region: bedrockRegion(baseURL, bedrock.Region, bedrock.Region), + Model: bedrock.Model.ValueString(), + SmallFastModel: bedrock.SmallFastModel.ValueString(), + } + if accessSet { + accessKey := bedrock.AccessKeyWO.ValueString() + sdkSettings.AccessKey = &accessKey + } + if secretSet { + accessKeySecret := bedrock.AccessKeySecretWO.ValueString() + sdkSettings.AccessKeySecret = &accessKeySecret + } + if !sdkSettings.IsConfigured() { + resp.Diagnostics.AddAttributeError(path.Root("settings").AtName("bedrock"), "Missing Bedrock Settings", "`type = \"bedrock\"` requires Bedrock settings sufficient for the Coder API: set `region` or write-only AWS credentials.") + } + } +} + +func (r *AIProviderResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan, config AIProviderResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + createReq := plan.createRequest(config, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if validations := createReq.Validate(); len(validations) > 0 { + addValidationErrors(&resp.Diagnostics, validations) + return + } + + tflog.Info(ctx, "creating AI provider") + provider, err := r.data.Client.CreateAIProvider(ctx, createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create AI provider, got error: %s", err)) + return + } + + state := plan.stateFromProvider(provider) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *AIProviderResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state AIProviderResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + idOrName := state.Name.ValueString() + if !state.ID.IsNull() && !state.ID.IsUnknown() { + idOrName = state.ID.ValueString() + } + provider, err := r.data.Client.AIProvider(ctx, idOrName) + if err != nil { + if isNotFound(err) { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("AI provider %s not found. Marking as deleted.", idOrName)) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read AI provider, got error: %s", err)) + return + } + + refreshed := state.stateFromProvider(provider) + resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...) +} + +func (r *AIProviderResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state, config AIProviderResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + patch := plan.updateRequest(state, config, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if patch.IsEmpty() { + // Nothing tracked changed; refresh from the server. The Coder + // API rejects empty patches, so there is nothing to send. + provider, err := r.data.Client.AIProvider(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read AI provider, got error: %s", err)) + return + } + refreshed := plan.stateFromProvider(provider) + resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...) + return + } + if validations := patch.Validate(); len(validations) > 0 { + addValidationErrors(&resp.Diagnostics, validations) + return + } + + tflog.Info(ctx, "updating AI provider") + provider, err := r.data.Client.UpdateAIProvider(ctx, state.ID.ValueString(), patch) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update AI provider, got error: %s", err)) + return + } + updated := plan.stateFromProvider(provider) + resp.Diagnostics.Append(resp.State.Set(ctx, &updated)...) +} + +func (r *AIProviderResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state AIProviderResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + idOrName := state.Name.ValueString() + if !state.ID.IsNull() && !state.ID.IsUnknown() { + idOrName = state.ID.ValueString() + } + if err := r.data.Client.DeleteAIProvider(ctx, idOrName); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete AI provider, got error: %s", err)) + } +} + +func (r *AIProviderResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + provider, err := r.data.Client.AIProvider(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to import AI provider %q, got error: %s", req.ID, err)) + return + } + state := AIProviderResourceModel{}.stateFromProvider(provider) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (m AIProviderResourceModel) createRequest(config AIProviderResourceModel, diags *diag.Diagnostics) codersdk.CreateAIProviderRequest { + var apiKeys []string + if !config.APIKeyWO.IsNull() && !config.APIKeyWO.IsUnknown() { + apiKeys = []string{config.APIKeyWO.ValueString()} + } + return codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderType(m.Type.ValueString()), + Name: m.Name.ValueString(), + DisplayName: config.DisplayName.ValueString(), + Enabled: m.Enabled.ValueBool(), + BaseURL: m.BaseURL.ValueString(), + APIKeys: apiKeys, + Settings: m.sdkSettings(config, bedrockCredentialsConfigured(config.bedrock()), diags), + } +} + +func (m AIProviderResourceModel) updateRequest(state, config AIProviderResourceModel, diags *diag.Diagnostics) codersdk.UpdateAIProviderRequest { + var patch codersdk.UpdateAIProviderRequest + if !m.DisplayName.Equal(state.DisplayName) { + v := config.DisplayName.ValueString() + patch.DisplayName = &v + } + if !m.Enabled.Equal(state.Enabled) { + v := m.Enabled.ValueBool() + patch.Enabled = &v + } + if !m.BaseURL.Equal(state.BaseURL) { + v := m.BaseURL.ValueString() + patch.BaseURL = &v + } + + // Send settings whenever they are (or were) present. The server merges + // credentials, so omitting credential pointers leaves stored AWS keys + // untouched; dropping the bedrock block clears settings server-side. + credentialsChanged := credentialsVersionChanged(m.bedrock(), state.bedrock()) + if m.bedrock() != nil || state.bedrock() != nil { + settings := m.sdkSettings(config, credentialsChanged, diags) + patch.Settings = &settings + } + + // Only touch keys when the version changes. The whole key set is one + // key: send the new plaintext to rotate, or an empty list to clear. + if !m.APIKeyWOVersion.Equal(state.APIKeyWOVersion) { + muts := []codersdk.AIProviderKeyMutation{} + if !config.APIKeyWO.IsNull() && !config.APIKeyWO.IsUnknown() { + v := config.APIKeyWO.ValueString() + muts = append(muts, codersdk.AIProviderKeyMutation{APIKey: &v}) + } + patch.APIKeys = &muts + } + return patch +} + +func (m AIProviderResourceModel) sdkSettings(config AIProviderResourceModel, includeCredentials bool, diags *diag.Diagnostics) codersdk.AIProviderSettings { + bedrock := m.bedrock() + if bedrock == nil { + return codersdk.AIProviderSettings{} + } + cfgBedrock := config.bedrock() + cfgRegion := bedrock.Region + if cfgBedrock != nil { + cfgRegion = cfgBedrock.Region + } + settings := codersdk.AIProviderBedrockSettings{ + Region: bedrockRegion(m.BaseURL.ValueString(), cfgRegion, bedrock.Region), + Model: bedrock.Model.ValueString(), + SmallFastModel: bedrock.SmallFastModel.ValueString(), + } + if includeCredentials { + if cfgBedrock == nil || cfgBedrock.AccessKeyWO.IsNull() || cfgBedrock.AccessKeyWO.IsUnknown() || cfgBedrock.AccessKeySecretWO.IsNull() || cfgBedrock.AccessKeySecretWO.IsUnknown() { + diags.AddAttributeError(path.Root("settings").AtName("bedrock"), "Missing Bedrock Credentials", "Bedrock credential version changed, so both `access_key_wo` and `access_key_secret_wo` must be configured. Use empty strings for both to clear stored credentials.") + return codersdk.AIProviderSettings{} + } + accessKey := cfgBedrock.AccessKeyWO.ValueString() + accessKeySecret := cfgBedrock.AccessKeySecretWO.ValueString() + settings.AccessKey = &accessKey + settings.AccessKeySecret = &accessKeySecret + } + return codersdk.AIProviderSettings{Bedrock: &settings} +} + +func (m AIProviderResourceModel) stateFromProvider(provider codersdk.AIProvider) AIProviderResourceModel { + out := AIProviderResourceModel{ + ID: UUIDValue(provider.ID), + Type: types.StringValue(string(provider.Type)), + Name: types.StringValue(provider.Name), + DisplayName: types.StringValue(provider.DisplayName), + Enabled: types.BoolValue(provider.Enabled), + BaseURL: types.StringValue(provider.BaseURL), + CreatedAt: types.Int64Value(provider.CreatedAt.Unix()), + UpdatedAt: types.Int64Value(provider.UpdatedAt.Unix()), + // Write-only and version values are never returned by the API; + // preserve the configured/state values. + APIKeyWO: types.StringNull(), + APIKeyWOVersion: m.APIKeyWOVersion, + APIKeyMasked: types.StringNull(), + } + if len(provider.APIKeys) > 0 { + out.APIKeyMasked = types.StringValue(provider.APIKeys[0].Masked) + } + if provider.Settings.Bedrock != nil { + out.Settings = &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue(provider.Settings.Bedrock.Region), + Model: types.StringValue(provider.Settings.Bedrock.Model), + SmallFastModel: types.StringValue(provider.Settings.Bedrock.SmallFastModel), + AccessKeyWO: types.StringNull(), + AccessKeySecretWO: types.StringNull(), + }} + if b := m.bedrock(); b != nil { + out.Settings.Bedrock.CredentialsWOVersion = b.CredentialsWOVersion + } else { + out.Settings.Bedrock.CredentialsWOVersion = types.Int64Null() + } + } + return out +} + +func (m AIProviderResourceModel) bedrock() *AIProviderBedrockSettingsModel { + if m.Settings == nil { + return nil + } + return m.Settings.Bedrock +} + +func bedrockRegion(baseURL string, configured, planned types.String) string { + if !configured.IsNull() && !configured.IsUnknown() { + return configured.ValueString() + } + if region := parseBedrockRegionFromBaseURL(baseURL); region != "" { + return region + } + if !planned.IsNull() && !planned.IsUnknown() { + return planned.ValueString() + } + return "" +} + +func parseBedrockRegionFromBaseURL(baseURL string) string { + match := bedrockCanonicalBaseURLRegex.FindStringSubmatch(strings.TrimSpace(baseURL)) + if len(match) != 2 { + return "" + } + return strings.ToLower(match[1]) +} + +func validateAIProviderBaseURL(addError func(path.Path, string, string), attrPath path.Path, raw string) { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + addError(attrPath, "Invalid Base URL", "`base_url` must be an absolute URL, for example `https://api.example.com`.") + return + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + addError(attrPath, "Invalid Base URL", fmt.Sprintf("`base_url` scheme must be `http` or `https`, got `%s`.", parsed.Scheme)) + } +} + +func addValidationErrors(diags *diag.Diagnostics, validations []codersdk.ValidationError) { + for _, validation := range validations { + diags.AddError("Invalid AI Provider Request", fmt.Sprintf("%s: %s", validation.Field, validation.Detail)) + } +} + +func bedrockCredentialsConfigured(b *AIProviderBedrockSettingsModel) bool { + return b != nil && + !b.AccessKeyWO.IsNull() && !b.AccessKeyWO.IsUnknown() && + !b.AccessKeySecretWO.IsNull() && !b.AccessKeySecretWO.IsUnknown() +} + +func credentialsVersionChanged(a, b *AIProviderBedrockSettingsModel) bool { + if a == nil || b == nil { + return a != nil && b == nil && !a.CredentialsWOVersion.IsNull() + } + return !a.CredentialsWOVersion.Equal(b.CredentialsWOVersion) +} diff --git a/internal/provider/ai_provider_resource_test.go b/internal/provider/ai_provider_resource_test.go new file mode 100644 index 00000000..5208eafa --- /dev/null +++ b/internal/provider/ai_provider_resource_test.go @@ -0,0 +1,442 @@ +package provider + +import ( + "bytes" + "os" + "regexp" + "testing" + "text/template" + + aibridgeutils "github.com/coder/coder/v2/aibridge/utils" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "github.com/stretchr/testify/require" +) + +func testAIProviderTerraformVersionChecks() []tfversion.TerraformVersionCheck { + return []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_11_0), + } +} + +func TestAccAIProviderResource(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "ai_provider_acc", integration.UseLicense) + + cfg1 := testAccAIProviderResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + OpenAIKey: "sk-test-primary-000000", + OpenAIKeyVersion: 1, + } + cfg2 := cfg1 + cfg2.OpenAIKey = "sk-test-primary-111111" + cfg2.OpenAIKeyVersion = 2 + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + TerraformVersionChecks: testAIProviderTerraformVersionChecks(), + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "name", "openai-acc"), + resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "api_key_masked", aibridgeutils.MaskSecret(cfg1.OpenAIKey)), + resource.TestCheckNoResourceAttr("coderd_experimental_ai_provider.openai", "api_key_wo"), + resource.TestCheckResourceAttr("coderd_experimental_ai_provider.bedrock", "settings.bedrock.region", "us-east-1"), + ), + }, + { + ResourceName: "coderd_experimental_ai_provider.openai", + ImportState: true, + ImportStateId: "openai-acc", + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"api_key_wo", "api_key_wo_version"}, + }, + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "api_key_wo_version", "2"), + resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "api_key_masked", aibridgeutils.MaskSecret(cfg2.OpenAIKey)), + ), + }, + }, + }) +} + +type testAccAIProviderResourceConfig struct { + URL string + Token string + OpenAIKey string + OpenAIKeyVersion int +} + +func (c testAccAIProviderResourceConfig) String(t *testing.T) string { + t.Helper() + const tpl = ` +provider "coderd" { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_experimental_ai_provider" "openai" { + type = "openai" + name = "openai-acc" + display_name = "OpenAI Acceptance" + enabled = true + base_url = "https://api.openai.com" + + api_key_wo = "{{.OpenAIKey}}" + api_key_wo_version = {{.OpenAIKeyVersion}} +} + +resource "coderd_experimental_ai_provider" "bedrock" { + type = "bedrock" + name = "aws-bedrock-acc" + display_name = "AWS Bedrock Acceptance" + enabled = true + base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + + settings = { + bedrock = { + region = "us-east-1" + model = "anthropic.claude-3-5-sonnet-20241022-v2:0" + small_fast_model = "anthropic.claude-3-5-haiku-20241022-v1:0" + } + } +} +` + var out bytes.Buffer + require.NoError(t, template.Must(template.New("aiProviderResource").Parse(tpl)).Execute(&out, c)) + return out.String() +} + +func TestAIProviderResourceSchemaValidation(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + body string + wantError string + }{ + "api key requires version": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "https://api.openai.com" + api_key_wo = "sk-test" +} +`, + wantError: `api_key_wo_version`, + }, + "api key cannot be empty": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "https://api.openai.com" + api_key_wo = "" + api_key_wo_version = 1 +} +`, + wantError: `string length must be at least 1`, + }, + "bedrock known config requires region or credentials": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = "https://example.com" + + settings = { + bedrock = {} + } +} +`, + wantError: `Missing Bedrock Settings`, + }, + "bedrock access key requires secret": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + + settings = { + bedrock = { + access_key_wo = "AKIATEST" + credentials_wo_version = 1 + } + } +} +`, + wantError: `access_key_secret_wo`, + }, + "bedrock secret requires version": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + + settings = { + bedrock = { + access_key_wo = "AKIATEST" + access_key_secret_wo = "secret" + } + } +} +`, + wantError: `credentials_wo_version`, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + TerraformVersionChecks: testAIProviderTerraformVersionChecks(), + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAIProviderValidationConfig(tc.body), + ExpectError: regexp.MustCompile(tc.wantError), + }, + }, + }) + }) + } +} + +func testAIProviderValidationConfig(body string) string { + return testAIProviderValidationConfigWithURL("http://127.0.0.1", body) +} + +func testAIProviderValidationConfigWithURL(url, body string) string { + return `provider "coderd" { + url = "` + url + `" + token = "test-token" +} + +` + body +} + +func TestAIProviderResourceValidationDefersUnknownBedrockConfig(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + body string + variables config.Variables + }{ + "base url": { + body: `variable "base_url" { + type = string +} + +resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = var.base_url + + settings = { + bedrock = {} + } +} +`, + variables: config.Variables{ + "base_url": config.StringVariable("https://bedrock-runtime.us-east-1.amazonaws.com"), + }, + }, + "credentials": { + body: `variable "access_key" { + type = string +} + +variable "secret" { + type = string +} + +resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = "https://example.com" + + settings = { + bedrock = { + access_key_wo = var.access_key + access_key_secret_wo = var.secret + credentials_wo_version = 1 + } + } +} +`, + variables: config.Variables{ + "access_key": config.StringVariable("AKIATEST"), + "secret": config.StringVariable("secret"), + }, + }, + "region": { + body: `variable "region" { + type = string +} + +resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = "https://example.com" + + settings = { + bedrock = { + region = var.region + } + } +} +`, + variables: config.Variables{ + "region": config.StringVariable("us-east-1"), + }, + }, + "settings object": { + body: `variable "settings" { + type = object({ + bedrock = object({ + region = optional(string) + }) + }) +} + +resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = "https://example.com" + + settings = var.settings +} +`, + variables: config.Variables{ + "settings": config.ObjectVariable(map[string]config.Variable{ + "bedrock": config.ObjectVariable(map[string]config.Variable{ + "region": config.StringVariable("us-east-1"), + }), + }), + }, + }, + "bedrock object": { + body: `variable "bedrock" { + type = object({ + region = optional(string) + }) +} + +resource "coderd_experimental_ai_provider" "test" { + type = "bedrock" + name = "bedrock-test" + base_url = "https://example.com" + + settings = { + bedrock = var.bedrock + } +} +`, + variables: config.Variables{ + "bedrock": config.ObjectVariable(map[string]config.Variable{ + "region": config.StringVariable("us-east-1"), + }), + }, + }, + } { + t.Run(name, func(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() + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + TerraformVersionChecks: testAIProviderTerraformVersionChecks(), + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Required variables are unknown during ValidateResourceConfig, + // even though ConfigVariables supplies concrete plan values. + Config: testAIProviderValidationConfigWithURL(srv.URL, tc.body), + ConfigVariables: tc.variables, + PlanOnly: true, + // PlanOnly expects an empty plan unless this is set. + ExpectNonEmptyPlan: true, + }, + }, + }) + }) + } +} + +func TestAIProviderCreateRequestBedrockWithoutCredentials(t *testing.T) { + t.Parallel() + + plan := AIProviderResourceModel{ + Type: types.StringValue(string(codersdk.AIProviderTypeBedrock)), + Name: types.StringValue("aws-bedrock"), + DisplayName: types.StringUnknown(), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://bedrock-runtime.us-east-1.amazonaws.com"), + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringUnknown(), + Model: types.StringValue("anthropic.claude-3-5-sonnet-20241022-v2:0"), + SmallFastModel: types.StringValue("anthropic.claude-3-5-haiku-20241022-v1:0"), + }}, + } + config := plan + config.DisplayName = types.StringNull() + config.Settings.Bedrock.Region = types.StringNull() + + var diags diag.Diagnostics + req := plan.createRequest(config, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Empty(t, req.APIKeys) + require.NotNil(t, req.Settings.Bedrock) + require.Equal(t, "us-east-1", req.Settings.Bedrock.Region) + require.Nil(t, req.Settings.Bedrock.AccessKey) + require.Nil(t, req.Settings.Bedrock.AccessKeySecret) +} + +func TestAIProviderUpdateRequestAPIKeyRotation(t *testing.T) { + t.Parallel() + + state := AIProviderResourceModel{ + DisplayName: types.StringValue("OpenAI"), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://api.openai.com"), + APIKeyWOVersion: types.Int64Value(1), + } + + // Version unchanged: keys are left untouched (no api_keys in the patch). + unchanged := state + var diags diag.Diagnostics + patch := unchanged.updateRequest(state, unchanged, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Nil(t, patch.APIKeys) + + // Version bumped: the new plaintext is sent as a single insert mutation, + // which replaces the old key (its ID is absent from the list). + plan := state + plan.APIKeyWOVersion = types.Int64Value(2) + config := plan + config.APIKeyWO = types.StringValue("sk-rotated") + + diags = diag.Diagnostics{} + patch = plan.updateRequest(state, config, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, patch.APIKeys) + require.Len(t, *patch.APIKeys, 1) + require.Nil(t, (*patch.APIKeys)[0].ID) + require.Equal(t, "sk-rotated", *(*patch.APIKeys)[0].APIKey) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0b29a15f..65d0293e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -233,6 +233,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewProvisionerKeyResource, NewOrganizationSyncSettingsResource, NewOrganizationGroupSyncResource, + NewAIProviderResource, } } diff --git a/main.go b/main.go index beaf8d17..9f93ee6f 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ import ( // Run the docs generation tool, check its repository for more information on how it works and how docs // can be customized. -//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs +//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-name coderd --rendered-provider-name terraform-provider-coderd var ( // these will be set by the goreleaser configuration From 41824ffebecb06724709b24d051e6d5f88a3ea55 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 24 Jun 2026 05:48:53 +0000 Subject: [PATCH 2/6] review --- internal/provider/ai_provider_resource.go | 38 ++++++++++ .../provider/ai_provider_resource_test.go | 71 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/internal/provider/ai_provider_resource.go b/internal/provider/ai_provider_resource.go index 7ef6c7c4..f6bf1e2e 100644 --- a/internal/provider/ai_provider_resource.go +++ b/internal/provider/ai_provider_resource.go @@ -388,6 +388,10 @@ func (r *AIProviderResource) Update(ctx context.Context, req resource.UpdateRequ if resp.Diagnostics.HasError() { return } + plan.validateEffectiveUpdateState(state, config, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } if patch.IsEmpty() { // Nothing tracked changed; refresh from the server. The Coder // API rejects empty patches, so there is nothing to send. @@ -493,6 +497,40 @@ func (m AIProviderResourceModel) updateRequest(state, config AIProviderResourceM return patch } +func (m AIProviderResourceModel) validateEffectiveUpdateState(state, config AIProviderResourceModel, diags *diag.Diagnostics) { + if codersdk.AIProviderType(m.Type.ValueString()) != codersdk.AIProviderTypeBedrock { + return + } + + bedrock := m.bedrock() + if bedrock == nil { + diags.AddAttributeError(path.Root("settings"), "Missing Bedrock Settings", "`type = \"bedrock\"` requires `settings.bedrock`; it cannot be removed from an existing Bedrock provider.") + return + } + + if !credentialsVersionChanged(bedrock, state.bedrock()) { + return + } + cfgBedrock := config.bedrock() + if cfgBedrock == nil { + return + } + settings := codersdk.AIProviderBedrockSettings{ + Region: bedrockRegion(m.BaseURL.ValueString(), cfgBedrock.Region, bedrock.Region), + } + if !cfgBedrock.AccessKeyWO.IsNull() && !cfgBedrock.AccessKeyWO.IsUnknown() { + accessKey := cfgBedrock.AccessKeyWO.ValueString() + settings.AccessKey = &accessKey + } + if !cfgBedrock.AccessKeySecretWO.IsNull() && !cfgBedrock.AccessKeySecretWO.IsUnknown() { + accessKeySecret := cfgBedrock.AccessKeySecretWO.ValueString() + settings.AccessKeySecret = &accessKeySecret + } + if !settings.IsConfigured() { + diags.AddAttributeError(path.Root("settings").AtName("bedrock"), "Missing Bedrock Settings", "`type = \"bedrock\"` requires Bedrock settings sufficient for the Coder API: set `region` or write-only AWS credentials.") + } +} + func (m AIProviderResourceModel) sdkSettings(config AIProviderResourceModel, includeCredentials bool, diags *diag.Diagnostics) codersdk.AIProviderSettings { bedrock := m.bedrock() if bedrock == nil { diff --git a/internal/provider/ai_provider_resource_test.go b/internal/provider/ai_provider_resource_test.go index 5208eafa..611decc8 100644 --- a/internal/provider/ai_provider_resource_test.go +++ b/internal/provider/ai_provider_resource_test.go @@ -408,6 +408,77 @@ func TestAIProviderCreateRequestBedrockWithoutCredentials(t *testing.T) { require.Nil(t, req.Settings.Bedrock.AccessKeySecret) } +func TestAIProviderUpdateRejectsDroppingBedrockSettings(t *testing.T) { + t.Parallel() + + state := AIProviderResourceModel{ + Type: types.StringValue(string(codersdk.AIProviderTypeBedrock)), + DisplayName: types.StringValue("AWS Bedrock"), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://bedrock-runtime.us-east-1.amazonaws.com"), + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + }}, + } + plan := state + plan.Settings = nil + + var diags diag.Diagnostics + patch := plan.updateRequest(state, plan, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, patch.Settings) + require.Nil(t, patch.Settings.Bedrock) + + plan.validateEffectiveUpdateState(state, plan, &diags) + require.True(t, diags.HasError()) + require.Contains(t, diags.Errors()[0].Summary(), "Missing Bedrock Settings") +} + +func TestAIProviderUpdateRejectsClearingOnlyBedrockCredentials(t *testing.T) { + t.Parallel() + + state := AIProviderResourceModel{ + Type: types.StringValue(string(codersdk.AIProviderTypeBedrock)), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://example.com"), + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringNull(), + CredentialsWOVersion: types.Int64Value(1), + }}, + } + plan := AIProviderResourceModel{ + Type: state.Type, + Enabled: state.Enabled, + BaseURL: state.BaseURL, + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringNull(), + CredentialsWOVersion: types.Int64Value(2), + }}, + } + config := AIProviderResourceModel{ + Type: plan.Type, + Enabled: plan.Enabled, + BaseURL: plan.BaseURL, + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringNull(), + AccessKeyWO: types.StringValue(""), + AccessKeySecretWO: types.StringValue(""), + CredentialsWOVersion: types.Int64Value(2), + }}, + } + + var diags diag.Diagnostics + patch := plan.updateRequest(state, config, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, patch.Settings) + require.NotNil(t, patch.Settings.Bedrock) + require.False(t, patch.Settings.Bedrock.IsConfigured()) + + plan.validateEffectiveUpdateState(state, config, &diags) + require.True(t, diags.HasError()) + require.Contains(t, diags.Errors()[0].Summary(), "Missing Bedrock Settings") +} + func TestAIProviderUpdateRequestAPIKeyRotation(t *testing.T) { t.Parallel() From ee4f14cabba2507cf3ea700ea2c37e6def0fad9e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 24 Jun 2026 06:53:13 +0000 Subject: [PATCH 3/6] review --- docs/resources/experimental_ai_provider.md | 4 +- internal/provider/ai_provider_resource.go | 51 +++-- .../provider/ai_provider_resource_test.go | 179 +++++++++++++++++- 3 files changed, 212 insertions(+), 22 deletions(-) diff --git a/docs/resources/experimental_ai_provider.md b/docs/resources/experimental_ai_provider.md index 6b2f2503..8578f1db 100644 --- a/docs/resources/experimental_ai_provider.md +++ b/docs/resources/experimental_ai_provider.md @@ -65,7 +65,7 @@ resource "coderd_experimental_ai_provider" "openai" { > **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later. -- `api_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Plaintext API key for the provider. Not valid for `bedrock` or `copilot`. Bump `api_key_wo_version` to rotate it. +- `api_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Plaintext API key for the provider. Not valid for `bedrock` or `copilot`, or when `settings.bedrock` is set. Bump `api_key_wo_version` to rotate it. - `api_key_wo_version` (Number) Version for the write-only API key. Required when `api_key_wo` is set; bump it whenever `api_key_wo` changes to rotate the stored key. - `display_name` (String) Display name shown in Coder. If omitted, Coder returns the provider name. - `enabled` (Boolean) Whether this AI provider is enabled. Defaults to true. @@ -91,7 +91,7 @@ Optional: Optional: - `access_key_secret_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) AWS secret access key for Bedrock. -- `access_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) AWS access key ID for Bedrock. See [Coder's Amazon Bedrock provider docs](https://coder.com/docs/@v2.34.3/ai-coder/ai-gateway/providers#amazon-bedrock). +- `access_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) AWS access key ID for Bedrock. See [Coder's Amazon Bedrock provider docs](https://coder.com/docs/ai-coder/ai-gateway/providers#amazon-bedrock). - `credentials_wo_version` (Number) Version for Bedrock write-only credentials. Bump this value to send, rotate, or clear credentials. - `model` (String) Primary Bedrock model identifier. - `region` (String) AWS region for Bedrock. If omitted, derived from the canonical Bedrock `base_url` attribute. diff --git a/internal/provider/ai_provider_resource.go b/internal/provider/ai_provider_resource.go index f6bf1e2e..7a467017 100644 --- a/internal/provider/ai_provider_resource.go +++ b/internal/provider/ai_provider_resource.go @@ -133,7 +133,7 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ Required: true, }, "api_key_wo": schema.StringAttribute{ - MarkdownDescription: "Plaintext API key for the provider. Not valid for `bedrock` or `copilot`. Bump `api_key_wo_version` to rotate it.", + MarkdownDescription: "Plaintext API key for the provider. Not valid for `bedrock` or `copilot`, or when `settings.bedrock` is set. Bump `api_key_wo_version` to rotate it.", Optional: true, Sensitive: true, WriteOnly: true, @@ -162,9 +162,6 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ MarkdownDescription: "AWS region for Bedrock. If omitted, derived from the canonical Bedrock `base_url` attribute.", Optional: true, Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, }, "model": schema.StringAttribute{ MarkdownDescription: "Primary Bedrock model identifier.", @@ -183,7 +180,7 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, "access_key_wo": schema.StringAttribute{ - MarkdownDescription: "AWS access key ID for Bedrock. See [Coder's Amazon Bedrock provider docs](https://coder.com/docs/@v2.34.3/ai-coder/ai-gateway/providers#amazon-bedrock).", + MarkdownDescription: "AWS access key ID for Bedrock. See [Coder's Amazon Bedrock provider docs](https://coder.com/docs/ai-coder/ai-gateway/providers#amazon-bedrock).", Optional: true, Sensitive: true, WriteOnly: true, @@ -281,14 +278,24 @@ func (r *AIProviderResource) ValidateConfig(ctx context.Context, req resource.Va } providerType := codersdk.AIProviderType(data.Type.ValueString()) - if !data.APIKeyWO.IsNull() && !data.APIKeyWO.IsUnknown() && (providerType == codersdk.AIProviderTypeBedrock || providerType == codersdk.AIProviderTypeCopilot) { - resp.Diagnostics.AddAttributeError(path.Root("api_key_wo"), "Invalid Attribute Combination", fmt.Sprintf("`api_key_wo` must not be configured when `type` is `%s`.", providerType)) + if !data.APIKeyWO.IsNull() && !data.APIKeyWO.IsUnknown() { + switch { + case providerType == codersdk.AIProviderTypeBedrock || providerType == codersdk.AIProviderTypeCopilot: + resp.Diagnostics.AddAttributeError(path.Root("api_key_wo"), "Invalid Attribute Combination", fmt.Sprintf("`api_key_wo` must not be configured when `type` is `%s`.", providerType)) + case data.bedrock() != nil: + // The server rejects api_keys whenever settings.bedrock is set. + resp.Diagnostics.AddAttributeError(path.Root("api_key_wo"), "Invalid Attribute Combination", "`api_key_wo` must not be configured when `settings.bedrock` is set; Bedrock-backed providers authenticate via `settings.bedrock`.") + } } bedrock := data.bedrock() if bedrock == nil { - if providerType == codersdk.AIProviderTypeBedrock { + switch { + case providerType == codersdk.AIProviderTypeBedrock: resp.Diagnostics.AddAttributeError(path.Root("settings"), "Missing Bedrock Settings", "`type = \"bedrock\"` requires `settings.bedrock` with at least `region` or write-only AWS credentials.") + case data.Settings != nil: + // An empty settings = {} produces a null-vs-empty diff; reject it. + resp.Diagnostics.AddAttributeError(path.Root("settings"), "Invalid Settings", "`settings` must include a `bedrock` block or be omitted.") } return } @@ -484,15 +491,15 @@ func (m AIProviderResourceModel) updateRequest(state, config AIProviderResourceM patch.Settings = &settings } - // Only touch keys when the version changes. The whole key set is one - // key: send the new plaintext to rotate, or an empty list to clear. - if !m.APIKeyWOVersion.Equal(state.APIKeyWOVersion) { - muts := []codersdk.AIProviderKeyMutation{} - if !config.APIKeyWO.IsNull() && !config.APIKeyWO.IsUnknown() { + // Rotate the API key only when its version changes to a concrete value. A + // null version preserves the stored key rather than clearing it. + if !m.APIKeyWOVersion.IsNull() && !m.APIKeyWOVersion.Equal(state.APIKeyWOVersion) { + if config.APIKeyWO.IsNull() || config.APIKeyWO.IsUnknown() { + diags.AddAttributeError(path.Root("api_key_wo"), "Missing API Key", "`api_key_wo` must be configured when `api_key_wo_version` changes.") + } else { v := config.APIKeyWO.ValueString() - muts = append(muts, codersdk.AIProviderKeyMutation{APIKey: &v}) + patch.APIKeys = &[]codersdk.AIProviderKeyMutation{{APIKey: &v}} } - patch.APIKeys = &muts } return patch } @@ -646,9 +653,15 @@ func bedrockCredentialsConfigured(b *AIProviderBedrockSettingsModel) bool { !b.AccessKeySecretWO.IsNull() && !b.AccessKeySecretWO.IsUnknown() } -func credentialsVersionChanged(a, b *AIProviderBedrockSettingsModel) bool { - if a == nil || b == nil { - return a != nil && b == nil && !a.CredentialsWOVersion.IsNull() +// credentialsVersionChanged reports whether the planned credential version +// requires resending credentials. A null planned version preserves stored +// credentials rather than rotating them. +func credentialsVersionChanged(plan, state *AIProviderBedrockSettingsModel) bool { + if plan == nil || plan.CredentialsWOVersion.IsNull() { + return false + } + if state == nil { + return true } - return !a.CredentialsWOVersion.Equal(b.CredentialsWOVersion) + return !plan.CredentialsWOVersion.Equal(state.CredentialsWOVersion) } diff --git a/internal/provider/ai_provider_resource_test.go b/internal/provider/ai_provider_resource_test.go index 611decc8..bb8c5567 100644 --- a/internal/provider/ai_provider_resource_test.go +++ b/internal/provider/ai_provider_resource_test.go @@ -195,6 +195,69 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { `, wantError: `credentials_wo_version`, }, + "api key rejected for copilot": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "copilot" + name = "copilot-test" + base_url = "https://api.githubcopilot.com" + api_key_wo = "sk-test" + api_key_wo_version = 1 +} +`, + wantError: "must not be configured when `type` is `copilot`", + }, + "settings.bedrock rejected for openai": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "https://api.openai.com" + + settings = { + bedrock = { + region = "us-east-1" + } + } +} +`, + wantError: "only valid when `type` is `anthropic` or `bedrock`", + }, + "invalid base url": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "not-a-url" +} +`, + wantError: `Invalid Base URL`, + }, + "api key rejected for anthropic with bedrock settings": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "anthropic" + name = "anthropic-test" + base_url = "https://api.anthropic.com" + api_key_wo = "sk-test" + api_key_wo_version = 1 + + settings = { + bedrock = { + region = "us-east-1" + } + } +} +`, + wantError: "settings.bedrock", + }, + "empty settings rejected for non-bedrock": { + body: `resource "coderd_experimental_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "https://api.openai.com" + + settings = {} +} +`, + wantError: `Invalid Settings`, + }, } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -394,9 +457,13 @@ func TestAIProviderCreateRequestBedrockWithoutCredentials(t *testing.T) { SmallFastModel: types.StringValue("anthropic.claude-3-5-haiku-20241022-v1:0"), }}, } + // Copy the nested Bedrock value so mutating config doesn't alias plan's + // pointer; plan keeps its unknown region while config supplies a null one. config := plan config.DisplayName = types.StringNull() - config.Settings.Bedrock.Region = types.StringNull() + configBedrock := *plan.Settings.Bedrock + configBedrock.Region = types.StringNull() + config.Settings = &AIProviderSettingsModel{Bedrock: &configBedrock} var diags diag.Diagnostics req := plan.createRequest(config, &diags) @@ -510,4 +577,114 @@ func TestAIProviderUpdateRequestAPIKeyRotation(t *testing.T) { require.Len(t, *patch.APIKeys, 1) require.Nil(t, (*patch.APIKeys)[0].ID) require.Equal(t, "sk-rotated", *(*patch.APIKeys)[0].APIKey) + + // Version removed (planned null): the stored key is preserved, not cleared. + removed := state + removed.APIKeyWOVersion = types.Int64Null() + diags = diag.Diagnostics{} + patch = removed.updateRequest(state, removed, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Nil(t, patch.APIKeys) + + // Version bumped without a configured key is rejected rather than silently + // clearing the server's keys. + bumpedNoKey := state + bumpedNoKey.APIKeyWOVersion = types.Int64Value(2) + diags = diag.Diagnostics{} + patch = bumpedNoKey.updateRequest(state, bumpedNoKey, &diags) + require.True(t, diags.HasError()) + require.Contains(t, diags.Errors()[0].Summary(), "Missing API Key") + require.Nil(t, patch.APIKeys) +} + +func TestAIProviderUpdateRejectsBedrockCredentialBumpWithoutCredentials(t *testing.T) { + t.Parallel() + + state := AIProviderResourceModel{ + Type: types.StringValue(string(codersdk.AIProviderTypeBedrock)), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://bedrock-runtime.us-east-1.amazonaws.com"), + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + CredentialsWOVersion: types.Int64Value(1), + }}, + } + // config bumps the credential version but omits the write-only credentials. + plan := AIProviderResourceModel{ + Type: state.Type, + Enabled: state.Enabled, + BaseURL: state.BaseURL, + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + CredentialsWOVersion: types.Int64Value(2), + }}, + } + config := plan + + var diags diag.Diagnostics + _ = plan.updateRequest(state, config, &diags) + require.True(t, diags.HasError()) + require.Contains(t, diags.Errors()[0].Summary(), "Missing Bedrock Credentials") +} + +func TestAIProviderUpdatePreservesBedrockCredentialsWhenVersionRemoved(t *testing.T) { + t.Parallel() + + state := AIProviderResourceModel{ + Type: types.StringValue(string(codersdk.AIProviderTypeBedrock)), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://bedrock-runtime.us-east-1.amazonaws.com"), + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + CredentialsWOVersion: types.Int64Value(1), + }}, + } + // The operator removes credentials_wo_version with credentials absent. This + // must mean "stop managing / preserve", not "demand credentials forever". + plan := AIProviderResourceModel{ + Type: state.Type, + Enabled: state.Enabled, + BaseURL: state.BaseURL, + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + CredentialsWOVersion: types.Int64Null(), + }}, + } + config := plan + + var diags diag.Diagnostics + patch := plan.updateRequest(state, config, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, patch.Settings) + require.NotNil(t, patch.Settings.Bedrock) + // Credentials are preserved server-side: no credential pointers are sent. + require.Nil(t, patch.Settings.Bedrock.AccessKey) + require.Nil(t, patch.Settings.Bedrock.AccessKeySecret) + + plan.validateEffectiveUpdateState(state, config, &diags) + require.False(t, diags.HasError(), diags.Errors()) +} + +func TestParseBedrockRegionFromBaseURL(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + baseURL string + want string + }{ + "canonical": {"https://bedrock-runtime.us-east-1.amazonaws.com", "us-east-1"}, + "trailing slash": {"https://bedrock-runtime.eu-west-1.amazonaws.com/", "eu-west-1"}, + "mixed case host": {"https://Bedrock-Runtime.US-WEST-2.amazonaws.com", "us-west-2"}, + "surrounding space": {" https://bedrock-runtime.ap-south-1.amazonaws.com ", "ap-south-1"}, + "trailing path": {"https://bedrock-runtime.us-east-1.amazonaws.com/foo", ""}, + "query string": {"https://bedrock-runtime.us-east-1.amazonaws.com?x=1", ""}, + "port": {"https://bedrock-runtime.us-east-1.amazonaws.com:443", ""}, + "non-bedrock host": {"https://api.openai.com", ""}, + "empty": {"", ""}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, parseBedrockRegionFromBaseURL(tc.baseURL)) + }) + } } From 0ed85d379346a6ee56ed9ecdf488523b1624b836 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 24 Jun 2026 07:56:48 +0000 Subject: [PATCH 4/6] review --- internal/provider/ai_provider_resource.go | 51 +++++++ .../provider/ai_provider_resource_test.go | 132 ++++++++++++------ 2 files changed, 144 insertions(+), 39 deletions(-) diff --git a/internal/provider/ai_provider_resource.go b/internal/provider/ai_provider_resource.go index 7a467017..eb7ef464 100644 --- a/internal/provider/ai_provider_resource.go +++ b/internal/provider/ai_provider_resource.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -27,6 +28,8 @@ var ( _ resource.Resource = &AIProviderResource{} _ resource.ResourceWithImportState = &AIProviderResource{} _ resource.ResourceWithValidateConfig = &AIProviderResource{} + + _ planmodifier.String = bedrockRegionPlanModifier{} ) func NewAIProviderResource() resource.Resource { @@ -162,6 +165,9 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ MarkdownDescription: "AWS region for Bedrock. If omitted, derived from the canonical Bedrock `base_url` attribute.", Optional: true, Computed: true, + PlanModifiers: []planmodifier.String{ + bedrockRegionPlanModifier{}, + }, }, "model": schema.StringAttribute{ MarkdownDescription: "Primary Bedrock model identifier.", @@ -214,6 +220,9 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ "created_at": schema.Int64Attribute{ MarkdownDescription: "Creation timestamp as Unix seconds.", Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, }, "updated_at": schema.Int64Attribute{ MarkdownDescription: "Last update timestamp as Unix seconds.", @@ -582,6 +591,8 @@ func (m AIProviderResourceModel) stateFromProvider(provider codersdk.AIProvider) APIKeyWOVersion: m.APIKeyWOVersion, APIKeyMasked: types.StringNull(), } + // This resource manages a single key and replaces all keys on rotation, so + // len(APIKeys) is always 0 or 1; index 0 is the key we manage. if len(provider.APIKeys) > 0 { out.APIKeyMasked = types.StringValue(provider.APIKeys[0].Masked) } @@ -630,6 +641,46 @@ func parseBedrockRegionFromBaseURL(baseURL string) string { return strings.ToLower(match[1]) } +// bedrockRegionPlanModifier derives the planned Bedrock region from base_url +// when region is not explicitly configured. +type bedrockRegionPlanModifier struct{} + +func (bedrockRegionPlanModifier) Description(_ context.Context) string { + return "Derives the Bedrock region from base_url when region is not explicitly set." +} + +func (m bedrockRegionPlanModifier) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +func (bedrockRegionPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // User set region explicitly: keep their value. + if !req.ConfigValue.IsNull() && !req.ConfigValue.IsUnknown() { + return + } + + var baseURL types.String + resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("base_url"), &baseURL)...) + if resp.Diagnostics.HasError() { + return + } + + // base_url not yet known: region resolves after apply. + if baseURL.IsUnknown() { + resp.PlanValue = types.StringUnknown() + return + } + // Canonical Bedrock URL: derive the region so plan matches apply. + if region := parseBedrockRegionFromBaseURL(baseURL.ValueString()); region != "" { + resp.PlanValue = types.StringValue(region) + return + } + // Otherwise the region is stable across this change: preserve prior state. + if !req.StateValue.IsNull() { + resp.PlanValue = req.StateValue + } +} + func validateAIProviderBaseURL(addError func(path.Path, string, string), attrPath path.Path, raw string) { parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { diff --git a/internal/provider/ai_provider_resource_test.go b/internal/provider/ai_provider_resource_test.go index bb8c5567..2fe05e77 100644 --- a/internal/provider/ai_provider_resource_test.go +++ b/internal/provider/ai_provider_resource_test.go @@ -37,10 +37,14 @@ func TestAccAIProviderResource(t *testing.T) { Token: client.SessionToken(), OpenAIKey: "sk-test-primary-000000", OpenAIKeyVersion: 1, + BedrockBaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com", } cfg2 := cfg1 cfg2.OpenAIKey = "sk-test-primary-111111" cfg2.OpenAIKeyVersion = 2 + // Changing base_url to a different region must re-derive settings.bedrock.region. + cfg3 := cfg2 + cfg3.BedrockBaseURL = "https://bedrock-runtime.us-west-2.amazonaws.com" resource.Test(t, resource.TestCase{ IsUnitTest: true, @@ -71,6 +75,12 @@ func TestAccAIProviderResource(t *testing.T) { resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "api_key_masked", aibridgeutils.MaskSecret(cfg2.OpenAIKey)), ), }, + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_experimental_ai_provider.bedrock", "settings.bedrock.region", "us-west-2"), + ), + }, }, }) } @@ -80,6 +90,7 @@ type testAccAIProviderResourceConfig struct { Token string OpenAIKey string OpenAIKeyVersion int + BedrockBaseURL string } func (c testAccAIProviderResourceConfig) String(t *testing.T) string { @@ -106,11 +117,11 @@ resource "coderd_experimental_ai_provider" "bedrock" { name = "aws-bedrock-acc" display_name = "AWS Bedrock Acceptance" enabled = true - base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + base_url = "{{.BedrockBaseURL}}" settings = { bedrock = { - region = "us-east-1" + # region omitted on purpose: it is derived from base_url. model = "anthropic.claude-3-5-sonnet-20241022-v2:0" small_fast_model = "anthropic.claude-3-5-haiku-20241022-v1:0" } @@ -556,45 +567,47 @@ func TestAIProviderUpdateRequestAPIKeyRotation(t *testing.T) { APIKeyWOVersion: types.Int64Value(1), } - // Version unchanged: keys are left untouched (no api_keys in the patch). - unchanged := state - var diags diag.Diagnostics - patch := unchanged.updateRequest(state, unchanged, &diags) - require.False(t, diags.HasError(), diags.Errors()) - require.Nil(t, patch.APIKeys) + t.Run("version unchanged", func(t *testing.T) { + unchanged := state + var diags diag.Diagnostics + patch := unchanged.updateRequest(state, unchanged, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Nil(t, patch.APIKeys) + }) - // Version bumped: the new plaintext is sent as a single insert mutation, - // which replaces the old key (its ID is absent from the list). - plan := state - plan.APIKeyWOVersion = types.Int64Value(2) - config := plan - config.APIKeyWO = types.StringValue("sk-rotated") + t.Run("version bumped", func(t *testing.T) { + plan := state + plan.APIKeyWOVersion = types.Int64Value(2) + config := plan + config.APIKeyWO = types.StringValue("sk-rotated") + + var diags diag.Diagnostics + patch := plan.updateRequest(state, config, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, patch.APIKeys) + require.Len(t, *patch.APIKeys, 1) + require.Nil(t, (*patch.APIKeys)[0].ID) + require.Equal(t, "sk-rotated", *(*patch.APIKeys)[0].APIKey) + }) - diags = diag.Diagnostics{} - patch = plan.updateRequest(state, config, &diags) - require.False(t, diags.HasError(), diags.Errors()) - require.NotNil(t, patch.APIKeys) - require.Len(t, *patch.APIKeys, 1) - require.Nil(t, (*patch.APIKeys)[0].ID) - require.Equal(t, "sk-rotated", *(*patch.APIKeys)[0].APIKey) - - // Version removed (planned null): the stored key is preserved, not cleared. - removed := state - removed.APIKeyWOVersion = types.Int64Null() - diags = diag.Diagnostics{} - patch = removed.updateRequest(state, removed, &diags) - require.False(t, diags.HasError(), diags.Errors()) - require.Nil(t, patch.APIKeys) - - // Version bumped without a configured key is rejected rather than silently - // clearing the server's keys. - bumpedNoKey := state - bumpedNoKey.APIKeyWOVersion = types.Int64Value(2) - diags = diag.Diagnostics{} - patch = bumpedNoKey.updateRequest(state, bumpedNoKey, &diags) - require.True(t, diags.HasError()) - require.Contains(t, diags.Errors()[0].Summary(), "Missing API Key") - require.Nil(t, patch.APIKeys) + t.Run("version removed", func(t *testing.T) { + removed := state + removed.APIKeyWOVersion = types.Int64Null() + var diags diag.Diagnostics + patch := removed.updateRequest(state, removed, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Nil(t, patch.APIKeys) + }) + + t.Run("version bumped without key", func(t *testing.T) { + bumpedNoKey := state + bumpedNoKey.APIKeyWOVersion = types.Int64Value(2) + var diags diag.Diagnostics + patch := bumpedNoKey.updateRequest(state, bumpedNoKey, &diags) + require.True(t, diags.HasError()) + require.Contains(t, diags.Errors()[0].Summary(), "Missing API Key") + require.Nil(t, patch.APIKeys) + }) } func TestAIProviderUpdateRejectsBedrockCredentialBumpWithoutCredentials(t *testing.T) { @@ -627,6 +640,47 @@ func TestAIProviderUpdateRejectsBedrockCredentialBumpWithoutCredentials(t *testi require.Contains(t, diags.Errors()[0].Summary(), "Missing Bedrock Credentials") } +func TestAIProviderUpdateRotatesBedrockCredentials(t *testing.T) { + t.Parallel() + + state := AIProviderResourceModel{ + Type: types.StringValue(string(codersdk.AIProviderTypeBedrock)), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://bedrock-runtime.us-east-1.amazonaws.com"), + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + CredentialsWOVersion: types.Int64Value(1), + }}, + } + plan := AIProviderResourceModel{ + Type: state.Type, + Enabled: state.Enabled, + BaseURL: state.BaseURL, + Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + CredentialsWOVersion: types.Int64Value(2), + }}, + } + // Distinct non-empty values so a swapped key/secret assignment fails. + config := plan + config.Settings = &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ + Region: types.StringValue("us-east-1"), + AccessKeyWO: types.StringValue("AKIANEWKEY"), + AccessKeySecretWO: types.StringValue("newsecretvalue"), + CredentialsWOVersion: types.Int64Value(2), + }} + + var diags diag.Diagnostics + patch := plan.updateRequest(state, config, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, patch.Settings) + require.NotNil(t, patch.Settings.Bedrock) + require.NotNil(t, patch.Settings.Bedrock.AccessKey) + require.NotNil(t, patch.Settings.Bedrock.AccessKeySecret) + require.Equal(t, "AKIANEWKEY", *patch.Settings.Bedrock.AccessKey) + require.Equal(t, "newsecretvalue", *patch.Settings.Bedrock.AccessKeySecret) +} + func TestAIProviderUpdatePreservesBedrockCredentialsWhenVersionRemoved(t *testing.T) { t.Parallel() From 18615e5a149bc23afca5b1baf57c9c26f102da1b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 24 Jun 2026 13:21:01 +0000 Subject: [PATCH 5/6] rename --- ...rimental_ai_provider.md => ai_provider.md} | 27 ++++---- .../import.sh | 4 +- .../resource.tf | 6 +- internal/provider/ai_provider_resource.go | 28 +++++++-- .../provider/ai_provider_resource_test.go | 62 +++++++++---------- 5 files changed, 75 insertions(+), 52 deletions(-) rename docs/resources/{experimental_ai_provider.md => ai_provider.md} (82%) rename examples/resources/{coderd_experimental_ai_provider => coderd_ai_provider}/import.sh (78%) rename examples/resources/{coderd_experimental_ai_provider => coderd_ai_provider}/resource.tf (85%) diff --git a/docs/resources/experimental_ai_provider.md b/docs/resources/ai_provider.md similarity index 82% rename from docs/resources/experimental_ai_provider.md rename to docs/resources/ai_provider.md index 8578f1db..b604fcd8 100644 --- a/docs/resources/experimental_ai_provider.md +++ b/docs/resources/ai_provider.md @@ -1,25 +1,28 @@ --- # generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "coderd_experimental_ai_provider Resource - terraform-provider-coderd" +page_title: "coderd_ai_provider Resource - terraform-provider-coderd" subcategory: "" description: |- - Experimental Coder AI provider configuration. - _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. + ~> This resource is experimental. Changes are expected, and it is not recommended for production use. + -> _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 AI Provider for use with Coder's AI Gateway & Coder Agents. For type = "bedrock", omit settings.bedrock.access_key_wo and settings.bedrock.access_key_secret_wo to use the AWS SDK default credential chain as resolved by the Coder server process (IAM role, IRSA, environment variables, shared config, SSO, IMDS, and more). Set both together to use static IAM-user credentials. --- -# coderd_experimental_ai_provider (Resource) +# coderd_ai_provider (Resource) -Experimental Coder AI provider configuration. +~> This resource is experimental. Changes are expected, and it is not recommended for production use. -`_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. +-> `_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 AI Provider for use with Coder's AI Gateway & Coder Agents. For `type = "bedrock"`, omit `settings.bedrock.access_key_wo` and `settings.bedrock.access_key_secret_wo` to use the AWS SDK default credential chain as resolved by the Coder server process (IAM role, IRSA, environment variables, shared config, SSO, IMDS, and more). Set both together to use static IAM-user credentials. ## Example Usage ```terraform -resource "coderd_experimental_ai_provider" "bedrock" { +resource "coderd_ai_provider" "bedrock" { type = "bedrock" name = "aws-bedrock" display_name = "AWS Bedrock" @@ -40,12 +43,12 @@ resource "coderd_experimental_ai_provider" "bedrock" { } } -resource "coderd_experimental_ai_provider" "openai" { +resource "coderd_ai_provider" "openai" { type = "openai" name = "openai" display_name = "OpenAI" enabled = true - base_url = "https://api.openai.com" + base_url = "https://api.openai.com/v1" api_key_wo = var.openai_api_key api_key_wo_version = 1 @@ -67,7 +70,7 @@ resource "coderd_experimental_ai_provider" "openai" { - `api_key_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Plaintext API key for the provider. Not valid for `bedrock` or `copilot`, or when `settings.bedrock` is set. Bump `api_key_wo_version` to rotate it. - `api_key_wo_version` (Number) Version for the write-only API key. Required when `api_key_wo` is set; bump it whenever `api_key_wo` changes to rotate the stored key. -- `display_name` (String) Display name shown in Coder. If omitted, Coder returns the provider name. +- `display_name` (String) Display name shown in Coder. If omitted, defaults to the provider name. - `enabled` (Boolean) Whether this AI provider is enabled. Defaults to true. - `settings` (Attributes) Type-specific provider settings. (see [below for nested schema](#nestedatt--settings)) @@ -107,13 +110,13 @@ The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/c # The ID supplied can be either an AI provider UUID or name. # Existing remote API keys are preserved. Omit api_key_wo and api_key_wo_version # to leave them unmanaged, or configure both to replace them on a later apply. -$ terraform import coderd_experimental_ai_provider.example openai +$ terraform import coderd_ai_provider.example openai ``` 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_experimental_ai_provider.example + to = coderd_ai_provider.example id = "openai" } ``` diff --git a/examples/resources/coderd_experimental_ai_provider/import.sh b/examples/resources/coderd_ai_provider/import.sh similarity index 78% rename from examples/resources/coderd_experimental_ai_provider/import.sh rename to examples/resources/coderd_ai_provider/import.sh index 7aee8aba..de8d1d81 100644 --- a/examples/resources/coderd_experimental_ai_provider/import.sh +++ b/examples/resources/coderd_ai_provider/import.sh @@ -1,12 +1,12 @@ # The ID supplied can be either an AI provider UUID or name. # Existing remote API keys are preserved. Omit api_key_wo and api_key_wo_version # to leave them unmanaged, or configure both to replace them on a later apply. -$ terraform import coderd_experimental_ai_provider.example openai +$ terraform import coderd_ai_provider.example openai ``` 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_experimental_ai_provider.example + to = coderd_ai_provider.example id = "openai" } diff --git a/examples/resources/coderd_experimental_ai_provider/resource.tf b/examples/resources/coderd_ai_provider/resource.tf similarity index 85% rename from examples/resources/coderd_experimental_ai_provider/resource.tf rename to examples/resources/coderd_ai_provider/resource.tf index 02acff0d..d4faceef 100644 --- a/examples/resources/coderd_experimental_ai_provider/resource.tf +++ b/examples/resources/coderd_ai_provider/resource.tf @@ -1,4 +1,4 @@ -resource "coderd_experimental_ai_provider" "bedrock" { +resource "coderd_ai_provider" "bedrock" { type = "bedrock" name = "aws-bedrock" display_name = "AWS Bedrock" @@ -19,12 +19,12 @@ resource "coderd_experimental_ai_provider" "bedrock" { } } -resource "coderd_experimental_ai_provider" "openai" { +resource "coderd_ai_provider" "openai" { type = "openai" name = "openai" display_name = "OpenAI" enabled = true - base_url = "https://api.openai.com" + base_url = "https://api.openai.com/v1" api_key_wo = var.openai_api_key api_key_wo_version = 1 diff --git a/internal/provider/ai_provider_resource.go b/internal/provider/ai_provider_resource.go index eb7ef464..e03df8b4 100644 --- a/internal/provider/ai_provider_resource.go +++ b/internal/provider/ai_provider_resource.go @@ -28,6 +28,7 @@ var ( _ resource.Resource = &AIProviderResource{} _ resource.ResourceWithImportState = &AIProviderResource{} _ resource.ResourceWithValidateConfig = &AIProviderResource{} + _ resource.ResourceWithModifyPlan = &AIProviderResource{} _ planmodifier.String = bedrockRegionPlanModifier{} ) @@ -69,14 +70,22 @@ type AIProviderBedrockSettingsModel struct { } func (r *AIProviderResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_experimental_ai_provider" + resp.TypeName = req.ProviderTypeName + "_ai_provider" +} + +func (r *AIProviderResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resp.Diagnostics.AddWarning( + "Experimental Resource", + "coderd_ai_provider is experimental. Changes are expected, and it is not recommended for production use.", + ) } func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: "Experimental Coder AI provider configuration.\n\n" + - "`_wo` attributes are [write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments): " + + MarkdownDescription: "~> This resource is experimental. Changes are expected, and it is not recommended for production use.\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 AI Provider for use with Coder's AI Gateway & Coder Agents.\n\n" + "For `type = \"bedrock\"`, omit `settings.bedrock.access_key_wo` and `settings.bedrock.access_key_secret_wo` to use the AWS SDK default credential chain as resolved by the Coder server process (IAM role, IRSA, environment variables, shared config, SSO, IMDS, and more). Set both together to use static IAM-user credentials.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ @@ -118,7 +127,7 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, "display_name": schema.StringAttribute{ - MarkdownDescription: "Display name shown in Coder. If omitted, Coder returns the provider name.", + MarkdownDescription: "Display name shown in Coder. If omitted, defaults to the provider name.", Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ @@ -287,6 +296,17 @@ func (r *AIProviderResource) ValidateConfig(ctx context.Context, req resource.Va } providerType := codersdk.AIProviderType(data.Type.ValueString()) + isOpenAILike := providerType == codersdk.AIProviderTypeOpenAI || providerType == codersdk.AIProviderTypeOpenAICompat + if parsed, err := url.Parse(strings.TrimSpace(baseURL)); baseURLKnown && isOpenAILike && err == nil && strings.Trim(parsed.Path, "/") == "" { + resp.Diagnostics.AddAttributeWarning( + path.Root("base_url"), + "Base URL May Be Missing API Version", + "`base_url` has no path segment. Coder sends requests to `/chat/completions` without adding a "+ + "version prefix such as `/v1`, so a bare host targets `/chat/completions` at the root. Most "+ + "OpenAI-compatible endpoints require a version path, for example `https://api.openai.com/v1`.", + ) + } + if !data.APIKeyWO.IsNull() && !data.APIKeyWO.IsUnknown() { switch { case providerType == codersdk.AIProviderTypeBedrock || providerType == codersdk.AIProviderTypeCopilot: diff --git a/internal/provider/ai_provider_resource_test.go b/internal/provider/ai_provider_resource_test.go index 2fe05e77..d04b5c90 100644 --- a/internal/provider/ai_provider_resource_test.go +++ b/internal/provider/ai_provider_resource_test.go @@ -55,14 +55,14 @@ func TestAccAIProviderResource(t *testing.T) { { Config: cfg1.String(t), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "name", "openai-acc"), - resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "api_key_masked", aibridgeutils.MaskSecret(cfg1.OpenAIKey)), - resource.TestCheckNoResourceAttr("coderd_experimental_ai_provider.openai", "api_key_wo"), - resource.TestCheckResourceAttr("coderd_experimental_ai_provider.bedrock", "settings.bedrock.region", "us-east-1"), + resource.TestCheckResourceAttr("coderd_ai_provider.openai", "name", "openai-acc"), + resource.TestCheckResourceAttr("coderd_ai_provider.openai", "api_key_masked", aibridgeutils.MaskSecret(cfg1.OpenAIKey)), + resource.TestCheckNoResourceAttr("coderd_ai_provider.openai", "api_key_wo"), + resource.TestCheckResourceAttr("coderd_ai_provider.bedrock", "settings.bedrock.region", "us-east-1"), ), }, { - ResourceName: "coderd_experimental_ai_provider.openai", + ResourceName: "coderd_ai_provider.openai", ImportState: true, ImportStateId: "openai-acc", ImportStateVerify: true, @@ -71,14 +71,14 @@ func TestAccAIProviderResource(t *testing.T) { { Config: cfg2.String(t), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "api_key_wo_version", "2"), - resource.TestCheckResourceAttr("coderd_experimental_ai_provider.openai", "api_key_masked", aibridgeutils.MaskSecret(cfg2.OpenAIKey)), + resource.TestCheckResourceAttr("coderd_ai_provider.openai", "api_key_wo_version", "2"), + resource.TestCheckResourceAttr("coderd_ai_provider.openai", "api_key_masked", aibridgeutils.MaskSecret(cfg2.OpenAIKey)), ), }, { Config: cfg3.String(t), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_experimental_ai_provider.bedrock", "settings.bedrock.region", "us-west-2"), + resource.TestCheckResourceAttr("coderd_ai_provider.bedrock", "settings.bedrock.region", "us-west-2"), ), }, }, @@ -101,18 +101,18 @@ provider "coderd" { token = "{{.Token}}" } -resource "coderd_experimental_ai_provider" "openai" { +resource "coderd_ai_provider" "openai" { type = "openai" name = "openai-acc" display_name = "OpenAI Acceptance" enabled = true - base_url = "https://api.openai.com" + base_url = "https://api.openai.com/v1" api_key_wo = "{{.OpenAIKey}}" api_key_wo_version = {{.OpenAIKeyVersion}} } -resource "coderd_experimental_ai_provider" "bedrock" { +resource "coderd_ai_provider" "bedrock" { type = "bedrock" name = "aws-bedrock-acc" display_name = "AWS Bedrock Acceptance" @@ -141,20 +141,20 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError string }{ "api key requires version": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "openai" name = "openai-test" - base_url = "https://api.openai.com" + base_url = "https://api.openai.com/v1" api_key_wo = "sk-test" } `, wantError: `api_key_wo_version`, }, "api key cannot be empty": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "openai" name = "openai-test" - base_url = "https://api.openai.com" + base_url = "https://api.openai.com/v1" api_key_wo = "" api_key_wo_version = 1 } @@ -162,7 +162,7 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: `string length must be at least 1`, }, "bedrock known config requires region or credentials": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = "https://example.com" @@ -175,7 +175,7 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: `Missing Bedrock Settings`, }, "bedrock access key requires secret": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" @@ -191,7 +191,7 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: `access_key_secret_wo`, }, "bedrock secret requires version": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" @@ -207,7 +207,7 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: `credentials_wo_version`, }, "api key rejected for copilot": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "copilot" name = "copilot-test" base_url = "https://api.githubcopilot.com" @@ -218,10 +218,10 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: "must not be configured when `type` is `copilot`", }, "settings.bedrock rejected for openai": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "openai" name = "openai-test" - base_url = "https://api.openai.com" + base_url = "https://api.openai.com/v1" settings = { bedrock = { @@ -233,7 +233,7 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: "only valid when `type` is `anthropic` or `bedrock`", }, "invalid base url": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "openai" name = "openai-test" base_url = "not-a-url" @@ -242,7 +242,7 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: `Invalid Base URL`, }, "api key rejected for anthropic with bedrock settings": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "anthropic" name = "anthropic-test" base_url = "https://api.anthropic.com" @@ -259,10 +259,10 @@ func TestAIProviderResourceSchemaValidation(t *testing.T) { wantError: "settings.bedrock", }, "empty settings rejected for non-bedrock": { - body: `resource "coderd_experimental_ai_provider" "test" { + body: `resource "coderd_ai_provider" "test" { type = "openai" name = "openai-test" - base_url = "https://api.openai.com" + base_url = "https://api.openai.com/v1" settings = {} } @@ -313,7 +313,7 @@ func TestAIProviderResourceValidationDefersUnknownBedrockConfig(t *testing.T) { type = string } -resource "coderd_experimental_ai_provider" "test" { +resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = var.base_url @@ -336,7 +336,7 @@ variable "secret" { type = string } -resource "coderd_experimental_ai_provider" "test" { +resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = "https://example.com" @@ -360,7 +360,7 @@ resource "coderd_experimental_ai_provider" "test" { type = string } -resource "coderd_experimental_ai_provider" "test" { +resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = "https://example.com" @@ -385,7 +385,7 @@ resource "coderd_experimental_ai_provider" "test" { }) } -resource "coderd_experimental_ai_provider" "test" { +resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = "https://example.com" @@ -408,7 +408,7 @@ resource "coderd_experimental_ai_provider" "test" { }) } -resource "coderd_experimental_ai_provider" "test" { +resource "coderd_ai_provider" "test" { type = "bedrock" name = "bedrock-test" base_url = "https://example.com" @@ -563,7 +563,7 @@ func TestAIProviderUpdateRequestAPIKeyRotation(t *testing.T) { state := AIProviderResourceModel{ DisplayName: types.StringValue("OpenAI"), Enabled: types.BoolValue(true), - BaseURL: types.StringValue("https://api.openai.com"), + BaseURL: types.StringValue("https://api.openai.com/v1"), APIKeyWOVersion: types.Int64Value(1), } From e201a8bb176f13beadc75be5a287b1cc5c06f2f2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Sat, 27 Jun 2026 07:03:04 +0000 Subject: [PATCH 6/6] helper func & comment --- internal/provider/ai_provider_resource.go | 60 ++++++++--------------- internal/provider/util.go | 9 ++++ 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/internal/provider/ai_provider_resource.go b/internal/provider/ai_provider_resource.go index e03df8b4..20374185 100644 --- a/internal/provider/ai_provider_resource.go +++ b/internal/provider/ai_provider_resource.go @@ -332,24 +332,16 @@ func (r *AIProviderResource) ValidateConfig(ctx context.Context, req resource.Va if providerType != codersdk.AIProviderTypeAnthropic && providerType != codersdk.AIProviderTypeBedrock { resp.Diagnostics.AddAttributeError(path.Root("settings").AtName("bedrock"), "Invalid Attribute Combination", "`settings.bedrock` is only valid when `type` is `anthropic` or `bedrock`.") } - accessSet := !bedrock.AccessKeyWO.IsNull() && !bedrock.AccessKeyWO.IsUnknown() - secretSet := !bedrock.AccessKeySecretWO.IsNull() && !bedrock.AccessKeySecretWO.IsUnknown() if providerType == codersdk.AIProviderTypeBedrock { if !baseURLKnown || bedrock.Region.IsUnknown() || bedrock.AccessKeyWO.IsUnknown() || bedrock.AccessKeySecretWO.IsUnknown() { return } sdkSettings := codersdk.AIProviderBedrockSettings{ - Region: bedrockRegion(baseURL, bedrock.Region, bedrock.Region), - Model: bedrock.Model.ValueString(), - SmallFastModel: bedrock.SmallFastModel.ValueString(), - } - if accessSet { - accessKey := bedrock.AccessKeyWO.ValueString() - sdkSettings.AccessKey = &accessKey - } - if secretSet { - accessKeySecret := bedrock.AccessKeySecretWO.ValueString() - sdkSettings.AccessKeySecret = &accessKeySecret + Region: bedrockRegion(baseURL, bedrock.Region, bedrock.Region), + Model: bedrock.Model.ValueString(), + SmallFastModel: bedrock.SmallFastModel.ValueString(), + AccessKey: stringPtrOrNil(bedrock.AccessKeyWO), + AccessKeySecret: stringPtrOrNil(bedrock.AccessKeySecretWO), } if !sdkSettings.IsConfigured() { resp.Diagnostics.AddAttributeError(path.Root("settings").AtName("bedrock"), "Missing Bedrock Settings", "`type = \"bedrock\"` requires Bedrock settings sufficient for the Coder API: set `region` or write-only AWS credentials.") @@ -526,8 +518,7 @@ func (m AIProviderResourceModel) updateRequest(state, config AIProviderResourceM if config.APIKeyWO.IsNull() || config.APIKeyWO.IsUnknown() { diags.AddAttributeError(path.Root("api_key_wo"), "Missing API Key", "`api_key_wo` must be configured when `api_key_wo_version` changes.") } else { - v := config.APIKeyWO.ValueString() - patch.APIKeys = &[]codersdk.AIProviderKeyMutation{{APIKey: &v}} + patch.APIKeys = &[]codersdk.AIProviderKeyMutation{{APIKey: stringPtrOrNil(config.APIKeyWO)}} } } return patch @@ -552,15 +543,9 @@ func (m AIProviderResourceModel) validateEffectiveUpdateState(state, config AIPr return } settings := codersdk.AIProviderBedrockSettings{ - Region: bedrockRegion(m.BaseURL.ValueString(), cfgBedrock.Region, bedrock.Region), - } - if !cfgBedrock.AccessKeyWO.IsNull() && !cfgBedrock.AccessKeyWO.IsUnknown() { - accessKey := cfgBedrock.AccessKeyWO.ValueString() - settings.AccessKey = &accessKey - } - if !cfgBedrock.AccessKeySecretWO.IsNull() && !cfgBedrock.AccessKeySecretWO.IsUnknown() { - accessKeySecret := cfgBedrock.AccessKeySecretWO.ValueString() - settings.AccessKeySecret = &accessKeySecret + Region: bedrockRegion(m.BaseURL.ValueString(), cfgBedrock.Region, bedrock.Region), + AccessKey: stringPtrOrNil(cfgBedrock.AccessKeyWO), + AccessKeySecret: stringPtrOrNil(cfgBedrock.AccessKeySecretWO), } if !settings.IsConfigured() { diags.AddAttributeError(path.Root("settings").AtName("bedrock"), "Missing Bedrock Settings", "`type = \"bedrock\"` requires Bedrock settings sufficient for the Coder API: set `region` or write-only AWS credentials.") @@ -587,29 +572,26 @@ func (m AIProviderResourceModel) sdkSettings(config AIProviderResourceModel, inc diags.AddAttributeError(path.Root("settings").AtName("bedrock"), "Missing Bedrock Credentials", "Bedrock credential version changed, so both `access_key_wo` and `access_key_secret_wo` must be configured. Use empty strings for both to clear stored credentials.") return codersdk.AIProviderSettings{} } - accessKey := cfgBedrock.AccessKeyWO.ValueString() - accessKeySecret := cfgBedrock.AccessKeySecretWO.ValueString() - settings.AccessKey = &accessKey - settings.AccessKeySecret = &accessKeySecret + settings.AccessKey = stringPtrOrNil(cfgBedrock.AccessKeyWO) + settings.AccessKeySecret = stringPtrOrNil(cfgBedrock.AccessKeySecretWO) } return codersdk.AIProviderSettings{Bedrock: &settings} } func (m AIProviderResourceModel) stateFromProvider(provider codersdk.AIProvider) AIProviderResourceModel { out := AIProviderResourceModel{ - ID: UUIDValue(provider.ID), - Type: types.StringValue(string(provider.Type)), - Name: types.StringValue(provider.Name), - DisplayName: types.StringValue(provider.DisplayName), - Enabled: types.BoolValue(provider.Enabled), - BaseURL: types.StringValue(provider.BaseURL), - CreatedAt: types.Int64Value(provider.CreatedAt.Unix()), - UpdatedAt: types.Int64Value(provider.UpdatedAt.Unix()), - // Write-only and version values are never returned by the API; - // preserve the configured/state values. + ID: UUIDValue(provider.ID), + Type: types.StringValue(string(provider.Type)), + Name: types.StringValue(provider.Name), + DisplayName: types.StringValue(provider.DisplayName), + Enabled: types.BoolValue(provider.Enabled), + BaseURL: types.StringValue(provider.BaseURL), + CreatedAt: types.Int64Value(provider.CreatedAt.Unix()), + UpdatedAt: types.Int64Value(provider.UpdatedAt.Unix()), + APIKeyMasked: types.StringNull(), + // Write-only value is not returned; version is Terraform-only. APIKeyWO: types.StringNull(), APIKeyWOVersion: m.APIKeyWOVersion, - APIKeyMasked: types.StringNull(), } // This resource manages a single key and replaces all keys on rotation, so // len(APIKeys) is always 0 or 1; index 0 is the key we manage. diff --git a/internal/provider/util.go b/internal/provider/util.go index dbc3441c..031c78fd 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -135,6 +135,15 @@ func stringValueOrNull(s string) types.String { return types.StringValue(s) } +// stringPtrOrNil returns nil for null or unknown strings. +// ValueStringPointer returns &"" for unknown, which can accidentally send a value. +func stringPtrOrNil(v types.String) *string { + if v.IsNull() || v.IsUnknown() { + return nil + } + return v.ValueStringPointer() +} + // corsPtr returns a pointer to a CORSBehavior if the value is known and not empty, // otherwise returns nil (which will use the server default). func corsPtr(v types.String) *codersdk.CORSBehavior {