Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/resources/ai_provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ Optional:
- `role_arn` (String) ARN of an AWS IAM role to assume via STS before calling Bedrock. The base identity (the AWS SDK default credential chain or the static credentials) signs the AssumeRole call, and the temporary credentials sign Bedrock requests. Omit to call Bedrock with the base identity directly. Requires Coder v2.35.0 or later.
- `small_fast_model` (String) Small/fast Bedrock model identifier used for background tasks.

Read-Only:

- `external_id` (String) STS external ID the server generates and sends on the AssumeRole call when `role_arn` is set. Reference it in the assumed role's trust policy `sts:ExternalId` condition. Null until `role_arn` is first configured; stable afterwards. Requires Coder v2.36.0 or later.

## Import

Import is supported using the following syntax:
Expand Down
45 changes: 45 additions & 0 deletions internal/provider/ai_provider_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type AIProviderBedrockSettingsModel struct {
Model types.String `tfsdk:"model"`
SmallFastModel types.String `tfsdk:"small_fast_model"`
RoleARN types.String `tfsdk:"role_arn"`
ExternalID types.String `tfsdk:"external_id"`
AccessKeyWO types.String `tfsdk:"access_key_wo"`
AccessKeySecretWO types.String `tfsdk:"access_key_secret_wo"`
CredentialsWOVersion types.Int64 `tfsdk:"credentials_wo_version"`
Expand Down Expand Up @@ -199,6 +200,13 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ
MarkdownDescription: "ARN of an AWS IAM role to assume via STS before calling Bedrock. The base identity (the AWS SDK default credential chain or the static credentials) signs the AssumeRole call, and the temporary credentials sign Bedrock requests. Omit to call Bedrock with the base identity directly. Requires Coder v2.35.0 or later.",
Optional: true,
},
"external_id": schema.StringAttribute{
MarkdownDescription: "STS external ID the server generates and sends on the AssumeRole call when `role_arn` is set. Reference it in the assumed role's trust policy `sts:ExternalId` condition. Null until `role_arn` is first configured; stable afterwards. Requires Coder v2.36.0 or later.",
Computed: true,
PlanModifiers: []planmodifier.String{
bedrockExternalIDPlanModifier{},
},
},
"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,
Expand Down Expand Up @@ -612,6 +620,7 @@ func (m AIProviderResourceModel) stateFromProvider(provider codersdk.AIProvider)
Model: types.StringValue(provider.Settings.Bedrock.Model),
SmallFastModel: types.StringValue(provider.Settings.Bedrock.SmallFastModel),
RoleARN: stringValueOrNull(provider.Settings.Bedrock.RoleARN),
ExternalID: stringValueOrNull(provider.Settings.Bedrock.ExternalID),
AccessKeyWO: types.StringNull(),
AccessKeySecretWO: types.StringNull(),
}}
Expand Down Expand Up @@ -692,6 +701,42 @@ func (bedrockRegionPlanModifier) PlanModifyString(ctx context.Context, req planm
}
}

// bedrockExternalIDPlanModifier plans the server-generated STS external ID:
// created when role_arn is first set, then kept even if role_arn is cleared.
type bedrockExternalIDPlanModifier struct{}

func (bedrockExternalIDPlanModifier) Description(_ context.Context) string {
return "Preserves the server-generated external ID; plans a new one only when role_arn is first set."
}

func (m bedrockExternalIDPlanModifier) MarkdownDescription(ctx context.Context) string {
return m.Description(ctx)
}

func (bedrockExternalIDPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
// Once stored, the ID never changes.
if !req.StateValue.IsNull() {
resp.PlanValue = req.StateValue
return
}

var roleARN types.String
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("settings").AtName("bedrock").AtName("role_arn"), &roleARN)...)
if resp.Diagnostics.HasError() {
return
}

// Unknown or newly set role_arn: stays unknown, the server decides on apply.
if roleARN.IsUnknown() {
return
}
if !roleARN.IsNull() && roleARN.ValueString() != "" {
return
Comment thread
ethanndickson marked this conversation as resolved.
}
// No role and none stored: no ID.
resp.PlanValue = types.StringNull()
}

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 == "" {
Expand Down
74 changes: 74 additions & 0 deletions internal/provider/ai_provider_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package provider

import (
"bytes"
"context"
"os"
"regexp"
"testing"
Expand All @@ -11,7 +12,12 @@ import (
"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/path"
fwresource "github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/tfversion"
Expand Down Expand Up @@ -467,6 +473,7 @@ func TestAIProviderCreateRequestBedrockWithoutCredentials(t *testing.T) {
Model: types.StringValue("anthropic.claude-3-5-sonnet-20241022-v2:0"),
SmallFastModel: types.StringValue("anthropic.claude-3-5-haiku-20241022-v1:0"),
RoleARN: types.StringValue("arn:aws:iam::123456789012:role/bedrock-access"),
ExternalID: types.StringValue("stored-external-id"),
}},
}
// Copy the nested Bedrock value so mutating config doesn't alias plan's
Expand All @@ -484,6 +491,7 @@ func TestAIProviderCreateRequestBedrockWithoutCredentials(t *testing.T) {
require.NotNil(t, req.Settings.Bedrock)
require.Equal(t, "us-east-1", req.Settings.Bedrock.Region)
require.Equal(t, "arn:aws:iam::123456789012:role/bedrock-access", req.Settings.Bedrock.RoleARN)
require.Empty(t, req.Settings.Bedrock.ExternalID, "external_id is server-generated and must not be sent")
require.Nil(t, req.Settings.Bedrock.AccessKey)
require.Nil(t, req.Settings.Bedrock.AccessKeySecret)
}
Expand Down Expand Up @@ -660,6 +668,7 @@ func TestAIProviderUpdateRotatesBedrockCredentials(t *testing.T) {
BaseURL: state.BaseURL,
Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{
Region: types.StringValue("us-east-1"),
ExternalID: types.StringValue("stored-external-id"),
CredentialsWOVersion: types.Int64Value(2),
}},
}
Expand All @@ -681,6 +690,7 @@ func TestAIProviderUpdateRotatesBedrockCredentials(t *testing.T) {
require.NotNil(t, patch.Settings.Bedrock.AccessKeySecret)
require.Equal(t, "AKIANEWKEY", *patch.Settings.Bedrock.AccessKey)
require.Equal(t, "newsecretvalue", *patch.Settings.Bedrock.AccessKeySecret)
require.Empty(t, patch.Settings.Bedrock.ExternalID, "external_id is server-generated and must not be sent")
}

func TestAIProviderUpdatePreservesBedrockCredentialsWhenVersionRemoved(t *testing.T) {
Expand Down Expand Up @@ -721,6 +731,70 @@ func TestAIProviderUpdatePreservesBedrockCredentialsWhenVersionRemoved(t *testin
require.False(t, diags.HasError(), diags.Errors())
}

// aiProviderPlanWithRoleARN builds a plan with only settings.bedrock.role_arn set.
func aiProviderPlanWithRoleARN(t *testing.T, roleARN tftypes.Value) tfsdk.Plan {
t.Helper()
ctx := context.Background()
schemaResp := &fwresource.SchemaResponse{}
(&AIProviderResource{}).Schema(ctx, fwresource.SchemaRequest{}, schemaResp)
require.Empty(t, schemaResp.Diagnostics)

objType, ok := schemaResp.Schema.Type().TerraformType(ctx).(tftypes.Object)
require.True(t, ok)
settingsType, ok := objType.AttributeTypes["settings"].(tftypes.Object)
require.True(t, ok)
bedrockType, ok := settingsType.AttributeTypes["bedrock"].(tftypes.Object)
require.True(t, ok)

bedrockVals := map[string]tftypes.Value{}
for name, attrType := range bedrockType.AttributeTypes {
bedrockVals[name] = tftypes.NewValue(attrType, nil)
}
bedrockVals["role_arn"] = roleARN
rootVals := map[string]tftypes.Value{}
for name, attrType := range objType.AttributeTypes {
rootVals[name] = tftypes.NewValue(attrType, nil)
}
rootVals["settings"] = tftypes.NewValue(settingsType, map[string]tftypes.Value{
"bedrock": tftypes.NewValue(bedrockType, bedrockVals),
})
return tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(objType, rootVals)}
}

func TestBedrockExternalIDPlanModifier(t *testing.T) {
t.Parallel()

for name, tc := range map[string]struct {
state types.String
roleARN tftypes.Value
planSeed types.String
want types.String
}{
"preserves stored value": {types.StringValue("stored-external-id"), tftypes.NewValue(tftypes.String, "arn:aws:iam::123456789012:role/other"), types.StringUnknown(), types.StringValue("stored-external-id")},
"preserved after role_arn clear": {types.StringValue("stored-external-id"), tftypes.NewValue(tftypes.String, nil), types.StringUnknown(), types.StringValue("stored-external-id")},
"unknown when role_arn set": {types.StringNull(), tftypes.NewValue(tftypes.String, "arn:aws:iam::123456789012:role/bedrock"), types.StringUnknown(), types.StringUnknown()},
"unknown when role_arn unknown": {types.StringNull(), tftypes.NewValue(tftypes.String, tftypes.UnknownValue), types.StringUnknown(), types.StringUnknown()},
"null when role_arn null": {types.StringNull(), tftypes.NewValue(tftypes.String, nil), types.StringUnknown(), types.StringNull()},
"null when role_arn empty": {types.StringNull(), tftypes.NewValue(tftypes.String, ""), types.StringUnknown(), types.StringNull()},
"stable null on no-op plan (no external_id support)": {types.StringNull(), tftypes.NewValue(tftypes.String, "arn:aws:iam::123456789012:role/bedrock"), types.StringNull(), types.StringNull()},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
req := planmodifier.StringRequest{
Path: path.Root("settings").AtName("bedrock").AtName("external_id"),
Plan: aiProviderPlanWithRoleARN(t, tc.roleARN),
ConfigValue: types.StringNull(),
StateValue: tc.state,
PlanValue: tc.planSeed,
}
resp := &planmodifier.StringResponse{PlanValue: req.PlanValue}
bedrockExternalIDPlanModifier{}.PlanModifyString(context.Background(), req, resp)
require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics.Errors())
require.Equal(t, tc.want, resp.PlanValue)
})
}
}

func TestParseBedrockRegionFromBaseURL(t *testing.T) {
t.Parallel()

Expand Down