From 93f2334645b35f0f70e6148c74b5f4466a2a54b4 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Thu, 9 Apr 2026 20:20:04 +0000 Subject: [PATCH] feat: add coder_secret data source for user secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `coder_secret` data source that allows template authors to declare required user secrets and access their values during workspace builds. Schema: - `env` (optional) — environment variable name the secret injects - `file` (optional) — file path the secret injects - `help_message` (required) — guidance shown when the secret is missing - `value` (computed, sensitive) — resolved from provisioner env vars Exactly one of `env` or `file` must be set. On start transitions, a missing secret fails the build with the help_message. On stop/delete, missing secrets return empty to allow teardown. Env var convention: - Env secrets: CODER_SECRET_ENV_{env_name} - File secrets: CODER_SECRET_FILE_{hex(file_path)} --- docs/data-sources/secret.md | 48 ++ .../data-sources/coder_secret/data-source.tf | 15 + provider/provider.go | 1 + provider/secret.go | 194 +++++++ provider/secret_test.go | 503 ++++++++++++++++++ 5 files changed, 761 insertions(+) create mode 100644 docs/data-sources/secret.md create mode 100644 examples/data-sources/coder_secret/data-source.tf create mode 100644 provider/secret.go create mode 100644 provider/secret_test.go diff --git a/docs/data-sources/secret.md b/docs/data-sources/secret.md new file mode 100644 index 00000000..36ee85c1 --- /dev/null +++ b/docs/data-sources/secret.md @@ -0,0 +1,48 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coder_secret Data Source - terraform-provider-coder" +subcategory: "" +description: |- + Use this data source to declare that a workspace requires a user secret. Each coder_secret block declares a single secret requirement, matched by either an environment variable name (env) or a file path (file). The resolved value is available at build time via data.coder_secret..value. +--- + +# coder_secret (Data Source) + +Use this data source to declare that a workspace requires a user secret. Each `coder_secret` block declares a single secret requirement, matched by either an environment variable name (`env`) or a file path (`file`). The resolved value is available at build time via `data.coder_secret..value`. + +## Example Usage + +```terraform +data "coder_secret" "my_token" { + env = "MY_TOKEN" + help_message = "Personal access token injected as the environment variable MY_TOKEN" +} + +data "coder_secret" "my_cert" { + file = "~/my-cert.pem" + help_message = "Certificate chain injected as the file ~/my-cert.pem" +} + +# Use the secret value in an agent startup script. +resource "coder_script" "setup" { + agent_id = coder_agent.main.id + script = "echo ${data.coder_secret.my_token.value}" +} +``` + + +## Schema + +### Required + +- `help_message` (String) Guidance shown to users when this secret requirement is not satisfied. Displayed on the create workspace page and in build failure logs. + +### Optional + +- `env` (String) The environment variable name that this secret must inject (e.g. "MY_TOKEN"). Must be POSIX-compliant: start with a letter or underscore, followed by letters, digits, or underscores. Exactly one of `env` or `file` must be set. +- `file` (String) The file path that this secret must inject (e.g. "~/my-token"). Must start with `~/` or `/`. Exactly one of `env` or `file` must be set. + +### Read-Only + +- `id` (String) The ID of this resource. +- `value` (String, Sensitive) The resolved secret value, populated from the user's stored secrets during workspace builds. Treated as missing if empty. diff --git a/examples/data-sources/coder_secret/data-source.tf b/examples/data-sources/coder_secret/data-source.tf new file mode 100644 index 00000000..d8304fe2 --- /dev/null +++ b/examples/data-sources/coder_secret/data-source.tf @@ -0,0 +1,15 @@ +data "coder_secret" "my_token" { + env = "MY_TOKEN" + help_message = "Personal access token injected as the environment variable MY_TOKEN" +} + +data "coder_secret" "my_cert" { + file = "~/my-cert.pem" + help_message = "Certificate chain injected as the file ~/my-cert.pem" +} + +# Use the secret value in an agent startup script. +resource "coder_script" "setup" { + agent_id = coder_agent.main.id + script = "echo ${data.coder_secret.my_token.value}" +} diff --git a/provider/provider.go b/provider/provider.go index 7e4451b8..9346984c 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -65,6 +65,7 @@ func New() *schema.Provider { "coder_workspace_owner": workspaceOwnerDataSource(), "coder_workspace_preset": workspacePresetDataSource(), "coder_task": taskDatasource(), + "coder_secret": secretDataSource(), }, ResourcesMap: map[string]*schema.Resource{ "coder_agent": agentResource(), diff --git a/provider/secret.go b/provider/secret.go new file mode 100644 index 00000000..b40b76b1 --- /dev/null +++ b/provider/secret.go @@ -0,0 +1,194 @@ +package provider + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "regexp" + "strings" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/coder/terraform-provider-coder/v2/provider/helpers" +) + +// posixEnvNameRegex matches a POSIX-compliant environment variable name: +// starts with a letter or underscore, followed by letters, digits, or +// underscores. This mirrors the rule enforced by coderd when secrets are +// created, so enforcing it in the provider catches typos at terraform +// validate/plan time rather than at build time. +var posixEnvNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +// validateSecretEnv rejects env names that can never match a stored secret. +// Empty values pass through: the env/file mutex check in ReadContext handles +// that case and produces a clearer error. +func validateSecretEnv(val any, _ cty.Path) diag.Diagnostics { + s, ok := val.(string) + if !ok { + return diag.Errorf("expected string, got %T", val) + } + if s == "" { + return nil + } + if !posixEnvNameRegex.MatchString(s) { + return diag.Errorf( + "`env` must be a POSIX-compliant identifier matching %q; got %q", + posixEnvNameRegex.String(), s) + } + return nil +} + +// validateSecretFile rejects file paths that are not absolute or home-relative. +// This mirrors the rule enforced by coderd when secrets are created/updated +// (paths must start with `~/` or `/`), so enforcing it in the provider catches +// mistakes at terraform validate/plan time rather than at build time. +func validateSecretFile(val any, _ cty.Path) diag.Diagnostics { + s, ok := val.(string) + if !ok { + return diag.Errorf("expected string, got %T", val) + } + if s == "" { + return nil + } + if !strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "~/") { + return diag.Errorf( + "`file` must start with `/` or `~/`; got %q", s) + } + return nil +} + +// secretDataSource returns a schema for a user secret data source. +func secretDataSource() *schema.Resource { + const valueKey = "value" + + return &schema.Resource{ + SchemaVersion: 1, + + Description: "Use this data source to declare that a workspace requires a user secret. " + + "Each `coder_secret` block declares a single secret requirement, matched by either " + + "an environment variable name (`env`) or a file path (`file`). The resolved value " + + "is available at build time via `data.coder_secret..value`.", + ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { + env := rd.Get("env").(string) + file := rd.Get("file").(string) + + if env == "" && file == "" { + return diag.Errorf("exactly one of `env` or `file` must be set") + } + if env != "" && file != "" { + return diag.Errorf("exactly one of `env` or `file` must be set") + } + + // Build a stable ID from whichever field is set. + if env != "" { + rd.SetId(fmt.Sprintf("env:%s", env)) + } else { + rd.SetId(fmt.Sprintf("file:%s", file)) + } + + // Look up the secret value from the environment variable + // set by the provisioner at build time. + var value string + if env != "" { + value = helpers.OptionalEnv(SecretEnvEnvironmentVariable(env)) + } else { + value = helpers.OptionalEnv(SecretFileEnvironmentVariable(file)) + } + + if value != "" { + // Happy path where secret is resolved. + _ = rd.Set(valueKey, value) + return nil + } + + // Note that an value is treated as missing. The provider cannot + // distinguish "user has not stored the secret" from "user stored + // an empty value", because both surface as an unset or empty + // CODER_SECRET_* env var. This means a user must have a non-empty + // secret value to satisfy a requirement. + + // Only enforce missing secrets when we are certain this is a + // workspace start build. We check both conditions: + // 1. CODER_WORKSPACE_BUILD_ID is set (real build, not local + // terraform plan) + // 2. CODER_WORKSPACE_TRANSITION is "start" + // In all other cases (stop, delete, local dev, ambiguous state) + // we return an empty value so the operation can proceed. This + // prevents a missing or deleted secret from making a workspace + // unstoppable or undeletable. + buildID := os.Getenv("CODER_WORKSPACE_BUILD_ID") + transition := os.Getenv("CODER_WORKSPACE_TRANSITION") + workspaceStartBuild := buildID != "" && transition == "start" + if !workspaceStartBuild { + _ = rd.Set(valueKey, value) + return nil + } + + var requirement string + if env != "" { + requirement = fmt.Sprintf("environment variable %q", env) + } else { + requirement = fmt.Sprintf("file %q", file) + } + + var detail strings.Builder + _, _ = fmt.Fprintf(&detail, "Required: %s\n\n", requirement) + if helpMessage := rd.Get("help_message").(string); helpMessage != "" { + _, _ = fmt.Fprintf(&detail, "Help message: %s\n\n", helpMessage) + } + _, _ = fmt.Fprintf(&detail, "To resolve: ensure a secret exposes the %s.\n", requirement) + + return diag.Diagnostics{{ + Severity: diag.Error, + Summary: fmt.Sprintf("Missing required secret: %s", requirement), + Detail: detail.String(), + }} + }, + Schema: map[string]*schema.Schema{ + "env": { + Type: schema.TypeString, + Description: "The environment variable name that this secret must inject (e.g. \"MY_TOKEN\"). Must be POSIX-compliant: start with a letter or underscore, followed by letters, digits, or underscores. Exactly one of `env` or `file` must be set.", + Optional: true, + ForceNew: true, + ValidateDiagFunc: validateSecretEnv, + }, + "file": { + Type: schema.TypeString, + Description: "The file path that this secret must inject (e.g. \"~/my-token\"). Must start with `~/` or `/`. Exactly one of `env` or `file` must be set.", + Optional: true, + ForceNew: true, + ValidateDiagFunc: validateSecretFile, + }, + "help_message": { + Type: schema.TypeString, + Description: "Guidance shown to users when this secret requirement is not satisfied. Displayed on the create workspace page and in build failure logs.", + Required: true, + }, + "value": { + Type: schema.TypeString, + Description: "The resolved secret value, populated from the user's stored secrets during workspace builds. Treated as missing if empty.", + Computed: true, + Sensitive: true, + }, + }, + } +} + +// SecretEnvEnvironmentVariable returns the environment variable used +// to pass a user secret matched by env_name to Terraform during +// workspace builds. The env name is used directly and assumed to be +// POSIX-compliant. +func SecretEnvEnvironmentVariable(envName string) string { + return fmt.Sprintf("CODER_SECRET_ENV_%s", envName) +} + +// SecretFileEnvironmentVariable returns the environment variable used +// to pass a user secret matched by file_path to Terraform during +// workspace builds. The file path is hex-encoded because it contains +// characters invalid in environment variable names. +func SecretFileEnvironmentVariable(filePath string) string { + return fmt.Sprintf("CODER_SECRET_FILE_%s", hex.EncodeToString([]byte(filePath))) +} diff --git a/provider/secret_test.go b/provider/secret_test.go new file mode 100644 index 00000000..68169dd4 --- /dev/null +++ b/provider/secret_test.go @@ -0,0 +1,503 @@ +package provider_test + +import ( + "encoding/hex" + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/require" + + "github.com/coder/terraform-provider-coder/v2/provider" +) + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretByEnv(t *testing.T) { + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "github_token" { + env = "GITHUB_TOKEN" + help_message = "Add a GitHub PAT as a secret with env=GITHUB_TOKEN" + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + res := state.Modules[0].Resources["data.coder_secret.github_token"] + require.NotNil(t, res) + + attribs := res.Primary.Attributes + require.Equal(t, "env:GITHUB_TOKEN", attribs["id"]) + require.Equal(t, "GITHUB_TOKEN", attribs["env"]) + require.Equal(t, "", attribs["value"]) + + return nil + }, + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretByFile(t *testing.T) { + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "aws_creds" { + file = "~/.aws/credentials" + help_message = "Add your AWS credentials file as a secret" + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + res := state.Modules[0].Resources["data.coder_secret.aws_creds"] + require.NotNil(t, res) + + attribs := res.Primary.Attributes + require.Equal(t, "file:~/.aws/credentials", attribs["id"]) + require.Equal(t, "~/.aws/credentials", attribs["file"]) + require.Equal(t, "", attribs["value"]) + + return nil + }, + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretWithEnvValue(t *testing.T) { + t.Setenv(provider.SecretEnvEnvironmentVariable("MY_TOKEN"), "secret-token-value") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "my_token" { + env = "MY_TOKEN" + help_message = "Set the MY_TOKEN secret" + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + res := state.Modules[0].Resources["data.coder_secret.my_token"] + require.NotNil(t, res) + + attribs := res.Primary.Attributes + require.Equal(t, "secret-token-value", attribs["value"]) + + return nil + }, + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretWithFileValue(t *testing.T) { + t.Setenv(provider.SecretFileEnvironmentVariable("~/.ssh/id_rsa"), "private-key-contents") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "ssh_key" { + file = "~/.ssh/id_rsa" + help_message = "Add your SSH private key" + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + res := state.Modules[0].Resources["data.coder_secret.ssh_key"] + require.NotNil(t, res) + + attribs := res.Primary.Attributes + require.Equal(t, "private-key-contents", attribs["value"]) + + return nil + }, + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretMissingOnStart(t *testing.T) { + // Default transition is "start", and no env var is set for the + // secret, so the data source should fail. + t.Setenv("CODER_WORKSPACE_TRANSITION", "start") + t.Setenv("CODER_WORKSPACE_BUILD_ID", "test-build-id") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "missing" { + env = "DOES_NOT_EXIST" + help_message = "Please add the DOES_NOT_EXIST secret" + } + `, + // Assert the full labeled-section format so refactors that + // drop the summary, the "Required:" paragraph, the echoed + // help_message, or the "To resolve:" action are caught. + // The last line uses \s+ instead of a literal space because + // Terraform soft-wraps long diagnostic lines at ~76 cols. + ExpectError: regexp.MustCompile( + `Missing required secret: environment variable "DOES_NOT_EXIST"[\s\S]*` + + `Required: environment variable "DOES_NOT_EXIST"[\s\S]*` + + `Help message: Please add the DOES_NOT_EXIST secret[\s\S]*` + + `To resolve: ensure a secret exposes the environment\s+variable\s+"DOES_NOT_EXIST"`, + ), + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretMissingOnStartFile(t *testing.T) { + // Missing file-path secret on start should fail with a file-flavored + // diagnostic. Mirrors TestSecretMissingOnStart but covers the `file` + // branch of the requirement builder. + t.Setenv("CODER_WORKSPACE_TRANSITION", "start") + t.Setenv("CODER_WORKSPACE_BUILD_ID", "test-build-id") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "missing" { + file = "~/.missing/secret" + help_message = "Please add the ~/.missing/secret secret" + } + `, + ExpectError: regexp.MustCompile( + `Missing required secret: file "~/.missing/secret"[\s\S]*` + + `Required: file "~/.missing/secret"[\s\S]*` + + `Help message: Please add the ~/.missing/secret secret[\s\S]*` + + `To resolve: ensure a secret exposes the file "~/.missing/secret"`, + ), + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretMissingOnStartEmptyHelp(t *testing.T) { + // When help_message is empty the diagnostic should omit the + // "Help message:" paragraph entirely rather than render a blank one. + // help_message is schema-required but HCL validates presence, not + // non-emptiness, so `help_message = ""` is legal and must be handled. + t.Setenv("CODER_WORKSPACE_TRANSITION", "start") + t.Setenv("CODER_WORKSPACE_BUILD_ID", "test-build-id") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "missing" { + env = "DOES_NOT_EXIST" + help_message = "" + } + `, + // Require that "To resolve:" immediately follows "Required:" + // with only blank lines between — no "Help message:" line. + // Go's regexp lacks lookaheads, so this adjacency check is + // how we assert absence. + ExpectError: regexp.MustCompile( + `Required: environment variable "DOES_NOT_EXIST"\s*\n\s*\n\s*To resolve:`, + ), + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretMissingOnLocalPlan(t *testing.T) { + // A local `terraform plan` without a workspace build id must not + // hard-fail on a missing secret. Only real workspace start builds + // (transition == "start" AND CODER_WORKSPACE_BUILD_ID set) should. + t.Setenv("CODER_WORKSPACE_TRANSITION", "start") + // Explicitly clear BUILD_ID in case the surrounding environment + // has it set. + t.Setenv("CODER_WORKSPACE_BUILD_ID", "") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "missing" { + env = "DOES_NOT_EXIST" + help_message = "irrelevant" + } + `, + Check: func(state *terraform.State) error { + res := state.Modules[0].Resources["data.coder_secret.missing"] + require.NotNil(t, res) + require.Equal(t, "", res.Primary.Attributes["value"]) + return nil + }, + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretMissingOnStop(t *testing.T) { + // On stop transitions, missing secrets should not error. + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "missing" { + env = "DOES_NOT_EXIST" + help_message = "Please add the DOES_NOT_EXIST secret" + } + `, + Check: func(state *terraform.State) error { + res := state.Modules[0].Resources["data.coder_secret.missing"] + require.NotNil(t, res) + require.Equal(t, "", res.Primary.Attributes["value"]) + return nil + }, + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretBothEnvAndFile(t *testing.T) { + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "both" { + env = "MY_SECRET" + file = "~/.my-secret" + help_message = "Pick one" + } + `, + ExpectError: regexp.MustCompile("exactly one of `env` or `file` must be set"), + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretNeitherEnvNorFile(t *testing.T) { + // Both `env` and `file` are optional in schema but the ReadContext + // enforces that exactly one must be set. Covers the `env == "" && + // file == ""` branch. + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + data "coder_secret" "neither" { + help_message = "Pick one" + } + `, + ExpectError: regexp.MustCompile("exactly one of `env` or `file` must be set"), + }}, + }) +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretEnvInvalid(t *testing.T) { + // Schema-level validation rejects non-POSIX env names at plan time, + // before ReadContext runs. Each subtest pins one specific rejected + // shape (leading digit, hyphen, space, dot) to guard against the + // regex drifting in a way that silently accepts one of these. + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + cases := []struct { + name string + value string + }{ + {name: "LeadingDigit", value: "1TOKEN"}, + {name: "Hyphen", value: "MY-TOKEN"}, + {name: "Space", value: "MY TOKEN"}, + {name: "Dot", value: "MY.TOKEN"}, + {name: "LeadingHyphen", value: "-TOKEN"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(` + provider "coder" { + } + data "coder_secret" "invalid" { + env = %q + help_message = "ignored" + } + `, tc.value), + // "POSIX-compliant" is the only substring guaranteed to + // appear on the same rendered line as the error + // headline. Terraform soft-wraps diagnostics at ~76 + // cols, which can split the regex source and the value. + ExpectError: regexp.MustCompile("POSIX-compliant"), + }}, + }) + }) + } +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretEnvValid(t *testing.T) { + // POSIX-valid env names must pass the validator. The happy path is + // already covered by TestSecretByEnv; these cases exercise the + // less-common shapes the regex permits (leading underscore, digits + // after the first character, all-lowercase) to guard against the + // validator being tightened by accident. + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + cases := []struct { + name string + value string + }{ + {name: "LeadingUnderscore", value: "_TOKEN"}, + {name: "TrailingDigit", value: "TOKEN1"}, + {name: "AllLowercase", value: "my_token"}, + {name: "MixedCase", value: "MyToken"}, + {name: "SingleLetter", value: "X"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(` + provider "coder" { + } + data "coder_secret" "valid" { + env = %q + help_message = "ignored" + } + `, tc.value), + }}, + }) + }) + } +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretFileInvalid(t *testing.T) { + // Schema-level validation rejects non-absolute file paths at plan + // time. The provisioner writes secrets using the path verbatim + // (after `~/` expansion), so a relative path would land somewhere + // surprising in the agent filesystem. + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + cases := []struct { + name string + value string + }{ + {name: "Relative", value: "creds.txt"}, + {name: "RelativeDir", value: "config/creds"}, + {name: "DotRelative", value: "./creds"}, + {name: "ParentRelative", value: "../creds"}, + {name: "BareTilde", value: "~creds"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(` + provider "coder" { + } + data "coder_secret" "invalid" { + file = %q + help_message = "ignored" + } + `, tc.value), + // Soft-wrapping by Terraform can split across lines, so + // match the stable prefix only. + ExpectError: regexp.MustCompile("`file` must start with"), + }}, + }) + }) + } +} + +// nolint:paralleltest // t.Setenv is incompatible with t.Parallel. +func TestSecretFileValid(t *testing.T) { + // Absolute and home-relative paths must pass the validator. Covers + // shapes beyond the `~/.aws/credentials` case in TestSecretByFile. + t.Setenv("CODER_WORKSPACE_TRANSITION", "stop") + cases := []struct { + name string + value string + }{ + {name: "HomeDotfile", value: "~/.netrc"}, + {name: "HomeNested", value: "~/config/app/secret"}, + {name: "AbsoluteRoot", value: "/etc/creds"}, + {name: "AbsoluteNested", value: "/var/lib/secrets/token"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(` + provider "coder" { + } + data "coder_secret" "valid" { + file = %q + help_message = "ignored" + } + `, tc.value), + }}, + }) + }) + } +} + +func TestSecretEnvironmentVariables(t *testing.T) { + t.Parallel() + + t.Run("EnvSecret", func(t *testing.T) { + t.Parallel() + result := provider.SecretEnvEnvironmentVariable("GITHUB_TOKEN") + require.Equal(t, "CODER_SECRET_ENV_GITHUB_TOKEN", result) + }) + + t.Run("FileSecret", func(t *testing.T) { + t.Parallel() + filePath := "~/.aws/credentials" + result := provider.SecretFileEnvironmentVariable(filePath) + expected := fmt.Sprintf("CODER_SECRET_FILE_%s", hex.EncodeToString([]byte(filePath))) + require.Equal(t, expected, result) + }) +}