diff --git a/docs/resources/chat_system_prompt.md b/docs/resources/chat_system_prompt.md new file mode 100644 index 0000000..ffa3464 --- /dev/null +++ b/docs/resources/chat_system_prompt.md @@ -0,0 +1,72 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_chat_system_prompt Resource - terraform-provider-coderd" +subcategory: "" +description: |- + ~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. + The deployment-wide chat system prompt for Coder Agents (Settings → Instructions in the dashboard). + This is a deployment-wide singleton. Declare it once; duplicate resources silently overwrite each other. + Coder sanitizes the stored prompt (strips invisible Unicode characters, normalizes line endings, collapses runs of blank lines, and trims surrounding whitespace), and this resource compares values the same way, so a trailing newline from file(...) does not cause drift after apply. On the first plan after an import, a configured value that differs from the live one only by sanitization shows a single in-place normalization update and then converges; use trimspace(file(...)) to avoid even that. + ~> Warning + If a system prompt was configured out of band, terraform import this resource before the first apply. Otherwise Terraform overwrites the live value; a plan-time warning is emitted when this is about to happen. + ~> Warning + terraform destroy resets the prompt to empty and include_default_system_prompt to true, the defaults of a never-configured deployment. The API has no delete operation for this setting. + ~> Warning + This resource requires Coder version 2.32.0 https://github.com/coder/coder/releases/tag/v2.32.0 or later, and a token with site-wide owner permissions. +--- + +# coderd_chat_system_prompt (Resource) + +~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. + +The deployment-wide chat system prompt for Coder Agents (`Settings → Instructions` in the dashboard). + +This is a deployment-wide singleton. Declare it once; duplicate resources silently overwrite each other. + +Coder sanitizes the stored prompt (strips invisible Unicode characters, normalizes line endings, collapses runs of blank lines, and trims surrounding whitespace), and this resource compares values the same way, so a trailing newline from `file(...)` does not cause drift after apply. On the first plan after an import, a configured value that differs from the live one only by sanitization shows a single in-place normalization update and then converges; use `trimspace(file(...))` to avoid even that. + +~> **Warning** +If a system prompt was configured out of band, `terraform import` this resource before the first apply. Otherwise Terraform overwrites the live value; a plan-time warning is emitted when this is about to happen. + +~> **Warning** +`terraform destroy` resets the prompt to empty and `include_default_system_prompt` to `true`, the defaults of a never-configured deployment. The API has no delete operation for this setting. + +~> **Warning** +This resource requires Coder version [2.32.0](https://github.com/coder/coder/releases/tag/v2.32.0) or later, and a token with site-wide `owner` permissions. + +## Example Usage + +```terraform +// Keep the prompt itself in a Markdown file next to the Terraform +// configuration so it can be reviewed like any other prose. +resource "coderd_chat_system_prompt" "this" { + system_prompt = file("${path.module}/system-prompt.md") + + // Append to Coder's built-in system prompt (the default) rather + // than replacing it. + include_default_system_prompt = true +} +``` + + +## Schema + +### Required + +- `system_prompt` (String) The custom system prompt text, typically `file("${path.module}/system-prompt.md")`. Limited to 128 KiB after sanitization. + +### Optional + +- `include_default_system_prompt` (Boolean) Whether the custom prompt is appended to Coder's built-in system prompt (`true`, the default) or replaces it entirely (`false`). + +## 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 chat system prompt is a deployment-wide singleton, so the import ID is +# required by the CLI syntax but otherwise unused. +terraform import coderd_chat_system_prompt.this chat_system_prompt +``` diff --git a/examples/resources/coderd_chat_system_prompt/import.sh b/examples/resources/coderd_chat_system_prompt/import.sh new file mode 100644 index 0000000..0276997 --- /dev/null +++ b/examples/resources/coderd_chat_system_prompt/import.sh @@ -0,0 +1,3 @@ +# The chat system prompt is a deployment-wide singleton, so the import ID is +# required by the CLI syntax but otherwise unused. +terraform import coderd_chat_system_prompt.this chat_system_prompt diff --git a/examples/resources/coderd_chat_system_prompt/resource.tf b/examples/resources/coderd_chat_system_prompt/resource.tf new file mode 100644 index 0000000..a272f06 --- /dev/null +++ b/examples/resources/coderd_chat_system_prompt/resource.tf @@ -0,0 +1,9 @@ +// Keep the prompt itself in a Markdown file next to the Terraform +// configuration so it can be reviewed like any other prose. +resource "coderd_chat_system_prompt" "this" { + system_prompt = file("${path.module}/system-prompt.md") + + // Append to Coder's built-in system prompt (the default) rather + // than replacing it. + include_default_system_prompt = true +} diff --git a/internal/provider/chat_system_prompt_resource.go b/internal/provider/chat_system_prompt_resource.go new file mode 100644 index 0000000..b93be91 --- /dev/null +++ b/internal/provider/chat_system_prompt_resource.go @@ -0,0 +1,390 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &ChatSystemPromptResource{} +var _ resource.ResourceWithImportState = &ChatSystemPromptResource{} +var _ resource.ResourceWithModifyPlan = &ChatSystemPromptResource{} + +// First release with the chat system prompt endpoint (coder/coder#22857). +const chatSystemPromptMinVersion = "2.32.0" + +// Mirrors coderd/exp_chats.go. +const maxChatSystemPromptBytes = 131072 + +var ( + pathSystemPrompt = path.Root("system_prompt") + pathIncludeDefaultSystemPrompt = path.Root("include_default_system_prompt") +) + +type ChatSystemPromptResource struct { + *CoderdProviderData +} + +type ChatSystemPromptResourceModel struct { + SystemPrompt chatSystemPromptTextValue `tfsdk:"system_prompt"` + IncludeDefaultSystemPrompt types.Bool `tfsdk:"include_default_system_prompt"` +} + +func NewChatSystemPromptResource() resource.Resource { + return &ChatSystemPromptResource{} +} + +func (r *ChatSystemPromptResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_chat_system_prompt" +} + +func (r *ChatSystemPromptResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: `~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. + +The deployment-wide chat system prompt for Coder Agents (` + "`Settings → Instructions`" + ` in the dashboard). + +This is a deployment-wide singleton. Declare it once; duplicate resources silently overwrite each other. + +Coder sanitizes the stored prompt (strips invisible Unicode characters, normalizes line endings, collapses runs of blank lines, and trims surrounding whitespace), and this resource compares values the same way, so a trailing newline from ` + "`file(...)`" + ` does not cause drift after apply. On the first plan after an import, a configured value that differs from the live one only by sanitization shows a single in-place normalization update and then converges; use ` + "`trimspace(file(...))`" + ` to avoid even that. + +~> **Warning** +If a system prompt was configured out of band, ` + "`terraform import`" + ` this resource before the first apply. Otherwise Terraform overwrites the live value; a plan-time warning is emitted when this is about to happen. + +~> **Warning** +` + "`terraform destroy`" + ` resets the prompt to empty and ` + "`include_default_system_prompt`" + ` to ` + "`true`" + `, the defaults of a never-configured deployment. The API has no delete operation for this setting. + +~> **Warning** +This resource requires Coder version [` + chatSystemPromptMinVersion + `](https://github.com/coder/coder/releases/tag/v` + chatSystemPromptMinVersion + `) or later, and a token with site-wide ` + "`owner`" + ` permissions. +`, + Attributes: map[string]schema.Attribute{ + "system_prompt": schema.StringAttribute{ + CustomType: chatSystemPromptTextType{}, + Required: true, + MarkdownDescription: "The custom system prompt text, typically `file(\"${path.module}/system-prompt.md\")`. " + + "Limited to 128 KiB after sanitization.", + Validators: []validator.String{ + chatSystemPromptLengthValidator{}, + }, + }, + "include_default_system_prompt": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + MarkdownDescription: "Whether the custom prompt is appended to Coder's built-in system prompt (`true`, the default) " + + "or replaces it entirely (`false`).", + }, + }, + } +} + +func (r *ChatSystemPromptResource) 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( + "Unable to configure provider data", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.CoderdProviderData = data +} + +func (r *ChatSystemPromptResource) experimentalClient() *codersdk.ExperimentalClient { + return codersdk.NewExperimentalClient(r.Client) +} + +func (r *ChatSystemPromptResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data ChatSystemPromptResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + prompt, err := r.experimentalClient().GetChatSystemPrompt(ctx) + if err != nil { + resp.Diagnostics.Append(chatSystemPromptDiag("read", err)...) + return + } + + data.SystemPrompt = newChatSystemPromptTextValue(prompt.SystemPrompt) + data.IncludeDefaultSystemPrompt = types.BoolValue(prompt.IncludeDefaultSystemPrompt) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ChatSystemPromptResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data ChatSystemPromptResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Trace(ctx, "creating chat system prompt") + + resp.Diagnostics.Append(r.put(ctx, "create", &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Trace(ctx, "successfully created chat system prompt") + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ChatSystemPromptResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data ChatSystemPromptResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Trace(ctx, "updating chat system prompt") + + resp.Diagnostics.Append(r.put(ctx, "update", &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Trace(ctx, "successfully updated chat system prompt") + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +// Always send include-default because an omitted field preserves the remote value. +func (r *ChatSystemPromptResource) put(ctx context.Context, action string, data *ChatSystemPromptResourceModel) diag.Diagnostics { + var diags diag.Diagnostics + + includeDefault := data.IncludeDefaultSystemPrompt.ValueBool() + err := r.experimentalClient().UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: data.SystemPrompt.ValueString(), + IncludeDefaultSystemPrompt: &includeDefault, + }) + if err != nil { + diags.Append(chatSystemPromptDiag(action, err)...) + return diags + } + + return diags +} + +func (r *ChatSystemPromptResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + tflog.Trace(ctx, "deleting chat system prompt") + + // The API has no DELETE, so restore the deployment defaults. + includeDefault := true + err := r.experimentalClient().UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: &includeDefault, + }) + if err != nil { + resp.Diagnostics.Append(chatSystemPromptDiag("reset", err)...) + return + } + + tflog.Trace(ctx, "successfully deleted chat system prompt") +} + +func (r *ChatSystemPromptResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resp.Diagnostics.AddWarning( + "Experimental Resource", + "coderd_chat_system_prompt is experimental. Changes are expected, and it is not recommended for production use.", + ) + + if req.Plan.Raw.IsNull() { + return + } + // Import populates state without running Create, so only warn on true creates. + if !req.State.Raw.IsNull() { + return + } + if r.CoderdProviderData == nil { + return + } + + var data ChatSystemPromptResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + // Required values can still be unknown during planning. + if data.SystemPrompt.IsUnknown() || data.SystemPrompt.IsNull() { + return + } + + live, err := r.experimentalClient().GetChatSystemPrompt(ctx) + if err != nil { + // This lookup is advisory; CRUD reports endpoint failures. + tflog.Debug(ctx, "skipping chat system prompt plan-time check", map[string]any{ + "error": err.Error(), + }) + return + } + + if live.SystemPrompt != "" && + codersdk.SanitizePromptText(live.SystemPrompt) != codersdk.SanitizePromptText(data.SystemPrompt.ValueString()) { + resp.Diagnostics.AddAttributeWarning( + pathSystemPrompt, + "Overwriting an out-of-band value", + "This deployment already has a chat system prompt configured, and applying will overwrite it. "+ + "Terraform has no prior state for this resource, so this change is not shown as a diff.\n\n"+ + "If you meant to adopt the deployment's existing value rather than overwrite it, run "+ + "`terraform import coderd_chat_system_prompt. chat_system_prompt` first.", + ) + } + + if !data.IncludeDefaultSystemPrompt.IsUnknown() && !data.IncludeDefaultSystemPrompt.IsNull() && + data.IncludeDefaultSystemPrompt.ValueBool() != live.IncludeDefaultSystemPrompt { + resp.Diagnostics.AddAttributeWarning( + pathIncludeDefaultSystemPrompt, + "Overwriting an out-of-band value", + fmt.Sprintf("`include_default_system_prompt` is currently `%t` on this deployment, and applying will set it "+ + "to `%t`. Terraform has no prior state for this resource, so this change is not shown as a diff.\n\n"+ + "If you meant to adopt the deployment's existing value rather than overwrite it, run "+ + "`terraform import coderd_chat_system_prompt. chat_system_prompt` first.", + live.IncludeDefaultSystemPrompt, data.IncludeDefaultSystemPrompt.ValueBool()), + ) + } +} + +func (r *ChatSystemPromptResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // The singleton has no ID, but Read needs a non-null placeholder state. + resp.Diagnostics.Append(resp.State.Set(ctx, ChatSystemPromptResourceModel{ + SystemPrompt: newChatSystemPromptTextValue(""), + IncludeDefaultSystemPrompt: types.BoolValue(true), + })...) +} + +func chatSystemPromptDiag(action string, err error) diag.Diagnostics { + var diags diag.Diagnostics + + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { + diags.AddError( + "Chat System Prompt Endpoint Unavailable", + fmt.Sprintf("Unable to %s the chat system prompt: the deployment returned 404 for %s. "+ + "This endpoint requires Coder version %s or later and a token with site-wide permissions; "+ + "upgrade the deployment or use a token with the required permissions. If neither is possible, "+ + "remove `coderd_chat_system_prompt` from your configuration. Original error: %s", + action, "/api/experimental/chats/config/system-prompt", chatSystemPromptMinVersion, err), + ) + return diags + } + + diags.AddError("Client Error", fmt.Sprintf("unable to %s the chat system prompt, got error: %s", action, err)) + return diags +} + +type chatSystemPromptLengthValidator struct{} + +func (chatSystemPromptLengthValidator) Description(context.Context) string { + return fmt.Sprintf("system prompt must be at most %d bytes after sanitization", maxChatSystemPromptBytes) +} + +func (v chatSystemPromptLengthValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v chatSystemPromptLengthValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + if got := len(codersdk.SanitizePromptText(req.ConfigValue.ValueString())); got > maxChatSystemPromptBytes { + resp.Diagnostics.AddAttributeError( + req.Path, + "System Prompt Too Long", + fmt.Sprintf("The system prompt is %d bytes after sanitization; the maximum Coder accepts is %d bytes (128 KiB).", got, maxChatSystemPromptBytes), + ) + } +} + +// Compares sanitized values to absorb server-side prompt normalization. +type chatSystemPromptTextType struct { + basetypes.StringType +} + +var _ basetypes.StringTypable = chatSystemPromptTextType{} + +func (t chatSystemPromptTextType) String() string { + return "chatSystemPromptTextType" +} + +func (t chatSystemPromptTextType) Equal(o attr.Type) bool { + if o, ok := o.(chatSystemPromptTextType); ok { + return t.StringType.Equal(o.StringType) + } + return false +} + +func (t chatSystemPromptTextType) ValueType(ctx context.Context) attr.Value { + return chatSystemPromptTextValue{} +} + +func (t chatSystemPromptTextType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + return chatSystemPromptTextValue{StringValue: in}, nil +} + +func (t chatSystemPromptTextType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + stringValue, ok := attrValue.(basetypes.StringValue) + if !ok { + return nil, fmt.Errorf("unexpected type %T, expected basetypes.StringValue", attrValue) + } + return chatSystemPromptTextValue{StringValue: stringValue}, nil +} + +type chatSystemPromptTextValue struct { + basetypes.StringValue +} + +var _ basetypes.StringValuableWithSemanticEquals = chatSystemPromptTextValue{} + +func newChatSystemPromptTextValue(value string) chatSystemPromptTextValue { + return chatSystemPromptTextValue{StringValue: basetypes.NewStringValue(value)} +} + +func (v chatSystemPromptTextValue) Type(ctx context.Context) attr.Type { + return chatSystemPromptTextType{} +} + +func (v chatSystemPromptTextValue) Equal(o attr.Value) bool { + if o, ok := o.(chatSystemPromptTextValue); ok { + return v.StringValue.Equal(o.StringValue) + } + return false +} + +func (v chatSystemPromptTextValue) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + newValue, ok := newValuable.(chatSystemPromptTextValue) + if !ok { + diags.AddError( + "Semantic Equality Check Error", + fmt.Sprintf("Expected chatSystemPromptTextValue, got: %T. Please report this issue to the provider developers.", newValuable), + ) + return false, diags + } + return codersdk.SanitizePromptText(v.ValueString()) == codersdk.SanitizePromptText(newValue.ValueString()), diags +} diff --git a/internal/provider/chat_system_prompt_resource_test.go b/internal/provider/chat_system_prompt_resource_test.go new file mode 100644 index 0000000..a1af8a6 --- /dev/null +++ b/internal/provider/chat_system_prompt_resource_test.go @@ -0,0 +1,546 @@ +package provider + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "regexp" + "strings" + "sync" + "testing" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const chatSystemPromptPath = "/api/experimental/chats/config/system-prompt" + +const chatSystemPromptResourceAddr = "coderd_chat_system_prompt.test" + +type fakeChatCoderd struct { + *httptest.Server + + mu sync.Mutex + prompt string + includeDflt bool + requests []fakeRequest + putPrompts []string + getStatus int + putStatus int + sanitizeOnPut bool +} + +func newFakeChatCoderd(t *testing.T) *fakeChatCoderd { + t.Helper() + + f := &fakeChatCoderd{includeDflt: true, sanitizeOnPut: true} + f.Server = httptest.NewServer(http.HandlerFunc(f.handle)) + t.Cleanup(f.Close) + return f +} + +func (f *fakeChatCoderd) handle(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.requests = append(f.requests, fakeRequest{Method: r.Method, Path: r.URL.Path}) + f.mu.Unlock() + + switch { + case r.URL.Path == "/api/v2/users/me": + writeJSON(w, http.StatusOK, map[string]any{ + "id": "00000000-0000-0000-0000-000000000001", + "username": "admin", + "organization_ids": []string{"00000000-0000-0000-0000-000000000002"}, + }) + case r.URL.Path == "/api/v2/entitlements": + writeJSON(w, http.StatusOK, codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + }) + case r.URL.Path == chatSystemPromptPath && r.Method == http.MethodGet: + f.mu.Lock() + status, prompt, include := f.getStatus, f.prompt, f.includeDflt + f.mu.Unlock() + if status != 0 { + writeJSON(w, status, codersdk.Response{Message: errorMessage(status, "")}) + return + } + writeJSON(w, http.StatusOK, codersdk.ChatSystemPromptResponse{ + SystemPrompt: prompt, + IncludeDefaultSystemPrompt: include, + DefaultSystemPrompt: "built-in prompt", + }) + case r.URL.Path == chatSystemPromptPath && r.Method == http.MethodPut: + f.mu.Lock() + status := f.putStatus + f.mu.Unlock() + if status != 0 { + writeJSON(w, status, codersdk.Response{Message: errorMessage(status, "")}) + return + } + var req codersdk.UpdateChatSystemPromptRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, codersdk.Response{Message: "Bad Request."}) + return + } + f.mu.Lock() + f.putPrompts = append(f.putPrompts, req.SystemPrompt) + f.prompt = req.SystemPrompt + if f.sanitizeOnPut { + f.prompt = codersdk.SanitizePromptText(req.SystemPrompt) + } + if req.IncludeDefaultSystemPrompt != nil { + f.includeDflt = *req.IncludeDefaultSystemPrompt + } + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + default: + writeJSON(w, http.StatusNotFound, codersdk.Response{Message: "Not Found."}) + } +} + +func chatSystemPromptConfig(url, prompt string, includeDefault *bool) string { + include := "" + if includeDefault != nil { + include = fmt.Sprintf("\n\tinclude_default_system_prompt = %t", *includeDefault) + } + return oauth2SettingsProviderBlock(url) + fmt.Sprintf(` +resource "coderd_chat_system_prompt" "test" { + system_prompt = %q%s +} +`, prompt, include) +} + +func TestChatSystemPromptSemanticEquals(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + for _, tc := range []struct { + name string + a, b string + want bool + }{ + {"identical", "prompt", "prompt", true}, + {"trailing newline", "prompt\n", "prompt", true}, + {"crlf vs lf", "a\r\nb", "a\nb", true}, + {"different text", "prompt a", "prompt b", false}, + {"whitespace-only difference inside a line", "a b", "a b", false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, diags := newChatSystemPromptTextValue(tc.a).StringSemanticEquals(ctx, newChatSystemPromptTextValue(tc.b)) + require.False(t, diags.HasError()) + require.Equal(t, tc.want, got) + }) + } +} + +func TestChatSystemPromptLengthValidator(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + okPrompt := strings.Repeat("a", maxChatSystemPromptBytes) + "\n\n\n" + tooLong := strings.Repeat("a", maxChatSystemPromptBytes+1) + + for _, tc := range []struct { + name string + value basetypes.StringValue + wantErr bool + }{ + {"at limit after sanitization", basetypes.NewStringValue(okPrompt), false}, + {"over limit", basetypes.NewStringValue(tooLong), true}, + {"null", basetypes.NewStringNull(), false}, + {"unknown", basetypes.NewStringUnknown(), false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + resp := &validator.StringResponse{} + chatSystemPromptLengthValidator{}.ValidateString(ctx, validator.StringRequest{ + Path: pathSystemPrompt, + ConfigValue: tc.value, + }, resp) + require.Equal(t, tc.wantErr, resp.Diagnostics.HasError()) + }) + } +} + +func TestAccChatSystemPromptResource(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + f := newFakeChatCoderd(t) + includeFalse := false + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: chatSystemPromptConfig(f.URL, "You are a helpful agent.\n", nil), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + chatSystemPromptResourceAddr, + tfjsonpath.New("include_default_system_prompt"), + knownvalue.Bool(true), + ), + }, + }, + { + Config: chatSystemPromptConfig(f.URL, "You are a helpful agent.\n", nil), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + Config: chatSystemPromptConfig(f.URL, "You are a very helpful agent.\n", &includeFalse), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + chatSystemPromptResourceAddr, + tfjsonpath.New("include_default_system_prompt"), + knownvalue.Bool(false), + ), + }, + }, + }, + }) + + f.mu.Lock() + defer f.mu.Unlock() + require.Empty(t, f.prompt) + require.True(t, f.includeDflt) +} + +func TestAccChatSystemPromptImport(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + f := newFakeChatCoderd(t) + f.mu.Lock() + f.prompt = "configured in the dashboard" + f.mu.Unlock() + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: chatSystemPromptConfig(f.URL, "configured in the dashboard", nil), + ResourceName: chatSystemPromptResourceAddr, + ImportState: true, + ImportStatePersist: true, + ImportStateId: "chat_system_prompt", + }, + { + Config: chatSystemPromptConfig(f.URL, "configured in the dashboard", nil), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) + + f.mu.Lock() + defer f.mu.Unlock() + require.Equal(t, []string{""}, f.putPrompts) +} + +func TestAccChatSystemPromptEndpointUnavailable(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + f := newFakeChatCoderd(t) + f.mu.Lock() + f.getStatus = http.StatusNotFound + f.putStatus = http.StatusNotFound + f.mu.Unlock() + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: chatSystemPromptConfig(f.URL, "prompt", nil), + ExpectError: regexp.MustCompile("Chat System Prompt Endpoint Unavailable"), + }, + }, + }) +} + +func TestAccChatSystemPromptRealCoderNoDrift(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "chat_system_prompt_acc") + experimental := codersdk.NewExperimentalClient(client) + + messyPrompt := "You are a helpful agent.\r\nBe concise.\u200b\n\n\n\nAlways cite sources.\n" + + cfg := fmt.Sprintf(` +provider "coderd" { + url = %[1]q + token = %[2]q +} + +resource "coderd_chat_system_prompt" "test" { + system_prompt = %[3]q +} +`, client.URL.String(), client.SessionToken(), messyPrompt) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + chatSystemPromptResourceAddr, + tfjsonpath.New("include_default_system_prompt"), + knownvalue.Bool(true), + ), + }, + }, + { + Config: cfg, + PlanOnly: true, + }, + }, + }) + + live, err := experimental.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Empty(t, live.SystemPrompt) + require.True(t, live.IncludeDefaultSystemPrompt) +} + +func TestAccChatSystemPromptRealCoderImportNoDrift(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "chat_system_prompt_import_acc") + experimental := codersdk.NewExperimentalClient(client) + + includeDefault := true + require.NoError(t, experimental.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "configured in the dashboard", + IncludeDefaultSystemPrompt: &includeDefault, + })) + + providerBlock := fmt.Sprintf(` +provider "coderd" { + url = %[1]q + token = %[2]q +} +`, client.URL.String(), client.SessionToken()) + + cfgExact := providerBlock + ` +resource "coderd_chat_system_prompt" "test" { + system_prompt = "configured in the dashboard" +} +` + cfgTrailingNewline := providerBlock + ` +resource "coderd_chat_system_prompt" "test" { + system_prompt = "configured in the dashboard\n" +} +` + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfgExact, + ResourceName: chatSystemPromptResourceAddr, + ImportState: true, + ImportStatePersist: true, + ImportStateId: "chat_system_prompt", + }, + { + Config: cfgExact, + PlanOnly: true, + }, + { + Config: cfgTrailingNewline, + }, + { + Config: cfgTrailingNewline, + PlanOnly: true, + }, + }, + }) +} + +func TestChatSystemPromptModifyPlan(t *testing.T) { + t.Parallel() + + objType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "system_prompt": tftypes.String, + "include_default_system_prompt": tftypes.Bool, + }, + } + promptObject := func(prompt any, include any) tftypes.Value { + if prompt == nil && include == nil { + return tftypes.NewValue(objType, nil) + } + return tftypes.NewValue(objType, map[string]tftypes.Value{ + "system_prompt": tftypes.NewValue(tftypes.String, prompt), + "include_default_system_prompt": tftypes.NewValue(tftypes.Bool, include), + }) + } + nullObject := promptObject(nil, nil) + + for _, tc := range []struct { + name string + livePrompt string + liveInclude bool + lookupStatus int + plan tftypes.Value + state tftypes.Value + wantWarnings int + }{ + { + name: "WarnsWhenFirstApplyWouldOverwriteLivePrompt", + livePrompt: "configured in the dashboard", + liveInclude: true, + plan: promptObject("from terraform", true), + state: nullObject, + wantWarnings: 2, + }, + { + name: "WarnsWhenFirstApplyWouldFlipIncludeDefault", + livePrompt: "", + liveInclude: false, + plan: promptObject("from terraform", true), + state: nullObject, + wantWarnings: 2, + }, + { + name: "WarnsOnBothWhenBothDiffer", + livePrompt: "configured in the dashboard", + liveInclude: false, + plan: promptObject("from terraform", true), + state: nullObject, + wantWarnings: 3, + }, + { + name: "SilentWhenPromptMatchesModuloSanitization", + livePrompt: "from terraform", + liveInclude: true, + plan: promptObject("from terraform\n", true), + state: nullObject, + wantWarnings: 1, + }, + { + name: "SilentOnGreenfieldDeployment", + livePrompt: "", + liveInclude: true, + plan: promptObject("from terraform", true), + state: nullObject, + wantWarnings: 1, + }, + { + name: "SilentWhenPriorStateExists", + livePrompt: "configured in the dashboard", + liveInclude: false, + plan: promptObject("from terraform", true), + state: promptObject("from terraform", true), + wantWarnings: 1, + }, + { + name: "SilentOnDestroyPlan", + livePrompt: "configured in the dashboard", + liveInclude: false, + plan: nullObject, + state: promptObject("from terraform", true), + wantWarnings: 1, + }, + { + name: "SilentWhenPlannedPromptUnknown", + livePrompt: "configured in the dashboard", + liveInclude: false, + plan: promptObject(tftypes.UnknownValue, true), + state: nullObject, + wantWarnings: 1, + }, + { + name: "SilentWhenLookupFails", + livePrompt: "configured in the dashboard", + liveInclude: false, + lookupStatus: http.StatusForbidden, + plan: promptObject("from terraform", true), + state: nullObject, + wantWarnings: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + f := newFakeChatCoderd(t) + f.mu.Lock() + f.prompt = tc.livePrompt + f.includeDflt = tc.liveInclude + f.getStatus = tc.lookupStatus + f.mu.Unlock() + + serverURL, err := url.Parse(f.URL) + require.NoError(t, err) + client := codersdk.New(serverURL) + client.SetSessionToken("test-token") + + r := &ChatSystemPromptResource{ + CoderdProviderData: &CoderdProviderData{Client: client}, + } + + schemaResp := &fwresource.SchemaResponse{} + r.Schema(ctx, fwresource.SchemaRequest{}, schemaResp) + require.Empty(t, schemaResp.Diagnostics) + s := schemaResp.Schema + + resp := &fwresource.ModifyPlanResponse{Plan: tfsdk.Plan{Schema: s, Raw: tc.plan}} + r.ModifyPlan(ctx, fwresource.ModifyPlanRequest{ + Config: tfsdk.Config{Schema: s, Raw: tc.plan}, + Plan: tfsdk.Plan{Schema: s, Raw: tc.plan}, + State: tfsdk.State{Schema: s, Raw: tc.state}, + }, resp) + + assert.Empty(t, resp.Diagnostics.Errors(), "a plan-time advisory must never fail the plan") + assert.Len(t, resp.Diagnostics.Warnings(), tc.wantWarnings) + }) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 7177a46..3ff4440 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -236,6 +236,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewAIProviderResource, NewAgentsModelResource, NewDefaultAgentsModelResource, + NewChatSystemPromptResource, NewOAuth2ProviderSettingsResource, } }