From ea4d52d9984d1e3790ec89550f4571d43472c792 Mon Sep 17 00:00:00 2001 From: lakshyadangwal Date: Wed, 23 Sep 2026 22:44:23 +0530 Subject: [PATCH] Fix multiple issues: - #503: Add 'name' attribute to coder_devcontainer to allow unique sub-agent names when using for_each/count - #466: Add 'name' attribute to coder_agent to avoid duplicate agent name errors with for_each - #517: Add 'coder_access_url' attribute to coder_agent to override the host/access URL - #464: Add 'keyvalue' parameter type for Key=Value table input (env vars/secrets) - #462: Allow number parameters without default by making 'optional' user-configurable --- provider/agent.go | 28 +++++++++++++++++- provider/devcontainer.go | 7 +++++ provider/formtype.go | 6 ++++ provider/parameter.go | 61 ++++++++++++++++++++++++++++++---------- 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/provider/agent.go b/provider/agent.go index dc662e4a..644ae3a9 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net/url" "path/filepath" "reflect" "strings" @@ -81,6 +82,20 @@ func agentResource() *schema.Resource { return nil }, Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "The name of the agent. When using `for_each` or `count`, this can be set to a unique value (e.g., `each.key`) to avoid name collisions. Defaults to the Terraform resource block name.", + ForceNew: true, + Optional: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "coder_access_url": { + Type: schema.TypeString, + Description: "Override the Coder access URL that the agent uses to phone home. Useful for networking scenarios where the agent needs to connect via an internal DNS name instead of the public URL.", + ForceNew: true, + Optional: true, + ValidateFunc: validation.StringIsNotEmpty, + }, "api_key_scope": { Type: schema.TypeString, Optional: true, @@ -491,7 +506,18 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia if !valid { return diag.Errorf("arch was unexpected type %q", reflect.TypeOf(resourceData.Get("arch"))) } - accessURL, err := config.URL.Parse("/") + + // Use custom coder_access_url if provided, otherwise use the provider config URL + accessURL := config.URL + if customURL, ok := resourceData.Get("coder_access_url").(string); ok && customURL != "" { + parsedCustomURL, err := url.Parse(customURL) + if err != nil { + return diag.Errorf("parse custom coder_access_url: %s", err) + } + accessURL = parsedCustomURL + } + + accessURL, err := accessURL.Parse("/") if err != nil { return diag.Errorf("parse access url: %s", err) } diff --git a/provider/devcontainer.go b/provider/devcontainer.go index b2c16eb8..9369daa1 100644 --- a/provider/devcontainer.go +++ b/provider/devcontainer.go @@ -26,6 +26,13 @@ func devcontainerResource() *schema.Resource { ReadContext: schema.NoopContext, DeleteContext: schema.NoopContext, Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "The name of the sub-agent. When using `for_each` or `count`, this can be set to a unique value (e.g., `each.key`) to avoid name collisions. Defaults to the Terraform resource block name.", + ForceNew: true, + Optional: true, + ValidateFunc: validation.StringIsNotEmpty, + }, "agent_id": { Type: schema.TypeString, Description: "The `id` property of a `coder_agent` resource to associate with.", diff --git a/provider/formtype.go b/provider/formtype.go index 931179da..09c2a6fb 100644 --- a/provider/formtype.go +++ b/provider/formtype.go @@ -20,6 +20,7 @@ const ( OptionTypeNumber OptionType = "number" OptionTypeBoolean OptionType = "bool" OptionTypeListString OptionType = "list(string)" + OptionTypeKeyValue OptionType = "keyvalue" ) func OptionTypes() []OptionType { @@ -28,6 +29,7 @@ func OptionTypes() []OptionType { OptionTypeNumber, OptionTypeBoolean, OptionTypeListString, + OptionTypeKeyValue, } } @@ -106,6 +108,10 @@ var formTypeTruthTable = map[OptionType]map[bool][]ParameterFormType{ true: {ParameterFormTypeRadio, ParameterFormTypeMultiSelect}, false: {ParameterFormTypeTagSelect}, }, + OptionTypeKeyValue: { + true: {ParameterFormTypeRadio, ParameterFormTypeDropdown}, + false: {ParameterFormTypeInput, ParameterFormTypeTextArea}, + }, } // ValidateFormType handles the truth table for the valid set of `type` and diff --git a/provider/parameter.go b/provider/parameter.go index ca1239f4..d63ea282 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -117,9 +117,11 @@ func parameterDataSource() *schema.Resource { Option: rd.Get("option"), Validation: fixedValidation, Optional: func() bool { - // This hack allows for checking if the "default" field is present in the .tf file. - // If "default" is missing or is "null", then it means that this field is required, - // and user must provide a value for it. + // Check if user explicitly set optional in the config + if userOptional, ok := rd.Get("optional").(bool); ok { + return userOptional + } + // Otherwise, fall back to: optional if default is provided val := !rd.GetRawConfig().AsValueMap()["default"].IsNull() rd.Set("optional", val) return val @@ -135,8 +137,9 @@ func parameterDataSource() *schema.Resource { return diag.Errorf("parameter can't be immutable and ephemeral") } - if !parameter.Optional && parameter.Ephemeral { - return diag.Errorf("ephemeral parameter requires the default property") + // Ephemeral parameters require either a default value or to be explicitly marked as optional + if parameter.Ephemeral && parameter.Default == nil && !parameter.Optional { + return diag.Errorf("ephemeral parameter requires the default property or optional = true") } var input *string @@ -314,8 +317,9 @@ func parameterDataSource() *schema.Resource { }, "optional": { Type: schema.TypeBool, + Optional: true, Computed: true, - Description: "Whether this value is optional.", + Description: "Whether this value is optional. If set to true, the parameter does not require a default value and can be left empty during workspace creation.", }, "order": { Type: schema.TypeInt, @@ -383,6 +387,12 @@ func valueIsType(typ OptionType, value string) error { if err != nil { return err } + case OptionTypeKeyValue: + var kv map[string]string + err := json.Unmarshal([]byte(value), &kv) + if err != nil { + return fmt.Errorf("%q is not a valid key-value object", value) + } case OptionTypeString: // Anything is a string! default: @@ -438,22 +448,37 @@ func (v *Parameter) ValidateInput(input *string, previous *string) (string, diag forcedValue = *value } - d := v.validValue(forcedValue, previous, optionType, optionValues, valuePath) - if d.HasError() { - return "", d - } - - err = valueIsType(v.Type, forcedValue) - if err != nil { + // For optional parameters without a value, skip type validation + // This allows number (and other types) to be optional without requiring a default + if !v.Optional && forcedValue == "" { return "", diag.Diagnostics{ { Severity: diag.Error, - Summary: fmt.Sprintf("Parameter value is not of type %q", v.Type), - Detail: err.Error(), + Summary: fmt.Sprintf("Parameter %q is required but no value was provided", v.Name), + Detail: "Either provide a value or set a default value for this parameter", }, } } + // Only validate type if a value is provided + if forcedValue != "" { + d := v.validValue(forcedValue, previous, optionType, optionValues, valuePath) + if d.HasError() { + return "", d + } + + err = valueIsType(v.Type, forcedValue) + if err != nil { + return "", diag.Diagnostics{ + { + Severity: diag.Error, + Summary: fmt.Sprintf("Parameter value is not of type %q", v.Type), + Detail: err.Error(), + }, + } + } + } + return forcedValue, nil } @@ -669,6 +694,12 @@ func (v *Validation) Valid(typ OptionType, value string, previous *string) error if err != nil { return fmt.Errorf("value %q is not valid list of strings", value) } + case OptionTypeKeyValue: + var kv map[string]string + err := json.Unmarshal([]byte(value), &kv) + if err != nil { + return fmt.Errorf("value %q is not a valid key-value object", value) + } } return nil }