diff --git a/docs/resources/ai_provider.md b/docs/resources/ai_provider.md new file mode 100644 index 00000000..b604fcd8 --- /dev/null +++ b/docs/resources/ai_provider.md @@ -0,0 +1,122 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_ai_provider Resource - terraform-provider-coderd" +subcategory: "" +description: |- + ~> 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_ai_provider (Resource) + +~> 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. + +## Example Usage + +```terraform +resource "coderd_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_ai_provider" "openai" { + type = "openai" + name = "openai" + display_name = "OpenAI" + enabled = true + base_url = "https://api.openai.com/v1" + + 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`, 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, 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)) + +### 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/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_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_ai_provider.example + id = "openai" +} +``` diff --git a/examples/resources/coderd_ai_provider/import.sh b/examples/resources/coderd_ai_provider/import.sh new file mode 100644 index 00000000..de8d1d81 --- /dev/null +++ b/examples/resources/coderd_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_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_ai_provider.example + id = "openai" +} diff --git a/examples/resources/coderd_ai_provider/resource.tf b/examples/resources/coderd_ai_provider/resource.tf new file mode 100644 index 00000000..d4faceef --- /dev/null +++ b/examples/resources/coderd_ai_provider/resource.tf @@ -0,0 +1,31 @@ +resource "coderd_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_ai_provider" "openai" { + type = "openai" + name = "openai" + display_name = "OpenAI" + enabled = true + 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 new file mode 100644 index 00000000..20374185 --- /dev/null +++ b/internal/provider/ai_provider_resource.go @@ -0,0 +1,720 @@ +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/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" + "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{} + _ resource.ResourceWithModifyPlan = &AIProviderResource{} + + _ planmodifier.String = bedrockRegionPlanModifier{} +) + +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 + "_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: "~> 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{ + 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, defaults to 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`, or when `settings.bedrock` is set. 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{ + bedrockRegionPlanModifier{}, + }, + }, + "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/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, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "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()) + + 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: + 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 { + 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 + } + + 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`.") + } + 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(), + 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.") + } + } +} + +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 + } + 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. + 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 + } + + // 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 { + patch.APIKeys = &[]codersdk.AIProviderKeyMutation{{APIKey: stringPtrOrNil(config.APIKeyWO)}} + } + } + 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), + 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.") + } +} + +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{} + } + 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()), + APIKeyMasked: types.StringNull(), + // Write-only value is not returned; version is Terraform-only. + APIKeyWO: types.StringNull(), + APIKeyWOVersion: m.APIKeyWOVersion, + } + // 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) + } + 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]) +} + +// 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 == "" { + 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() +} + +// 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 !plan.CredentialsWOVersion.Equal(state.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..d04b5c90 --- /dev/null +++ b/internal/provider/ai_provider_resource_test.go @@ -0,0 +1,744 @@ +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, + 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, + PreCheck: func() { testAccPreCheck(t) }, + TerraformVersionChecks: testAIProviderTerraformVersionChecks(), + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + 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_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_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_ai_provider.bedrock", "settings.bedrock.region", "us-west-2"), + ), + }, + }, + }) +} + +type testAccAIProviderResourceConfig struct { + URL string + Token string + OpenAIKey string + OpenAIKeyVersion int + BedrockBaseURL string +} + +func (c testAccAIProviderResourceConfig) String(t *testing.T) string { + t.Helper() + const tpl = ` +provider "coderd" { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_ai_provider" "openai" { + type = "openai" + name = "openai-acc" + display_name = "OpenAI Acceptance" + enabled = true + base_url = "https://api.openai.com/v1" + + api_key_wo = "{{.OpenAIKey}}" + api_key_wo_version = {{.OpenAIKeyVersion}} +} + +resource "coderd_ai_provider" "bedrock" { + type = "bedrock" + name = "aws-bedrock-acc" + display_name = "AWS Bedrock Acceptance" + enabled = true + base_url = "{{.BedrockBaseURL}}" + + settings = { + bedrock = { + # 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" + } + } +} +` + 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_ai_provider" "test" { + type = "openai" + name = "openai-test" + 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_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "https://api.openai.com/v1" + 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_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_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_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`, + }, + "api key rejected for copilot": { + body: `resource "coderd_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_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "https://api.openai.com/v1" + + settings = { + bedrock = { + region = "us-east-1" + } + } +} +`, + wantError: "only valid when `type` is `anthropic` or `bedrock`", + }, + "invalid base url": { + body: `resource "coderd_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_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_ai_provider" "test" { + type = "openai" + name = "openai-test" + base_url = "https://api.openai.com/v1" + + settings = {} +} +`, + wantError: `Invalid Settings`, + }, + } { + 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_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_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_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_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_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"), + }}, + } + // 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() + configBedrock := *plan.Settings.Bedrock + configBedrock.Region = types.StringNull() + config.Settings = &AIProviderSettingsModel{Bedrock: &configBedrock} + + 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 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() + + state := AIProviderResourceModel{ + DisplayName: types.StringValue("OpenAI"), + Enabled: types.BoolValue(true), + BaseURL: types.StringValue("https://api.openai.com/v1"), + APIKeyWOVersion: types.Int64Value(1), + } + + 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) + }) + + 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) + }) + + 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) { + 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 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() + + 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)) + }) + } +} 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/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 { 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