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..bd859b8e --- /dev/null +++ b/examples/data-sources/coder_secret/data-source.tf @@ -0,0 +1,15 @@ +data "coder_secret" "github_token" { + env = "GITHUB_TOKEN" + help_message = "Create a GitHub personal access token and add it as a secret with env=GITHUB_TOKEN" +} + +data "coder_secret" "aws_credentials" { + file = "~/.aws/credentials" + help_message = "Add your AWS credentials file as a secret with file=~/.aws/credentials" +} + +# Use the secret value in an agent startup script. +resource "coder_script" "setup" { + agent_id = coder_agent.main.id + script = "echo ${data.coder_secret.github_token.value} | gh auth login --with-token" +} diff --git a/provider/provider.go b/provider/provider.go index 7e4451b8..134f1e22 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -64,6 +64,7 @@ func New() *schema.Provider { "coder_external_auth": externalAuthDataSource(), "coder_workspace_owner": workspaceOwnerDataSource(), "coder_workspace_preset": workspacePresetDataSource(), + "coder_secret": secretDataSource(), "coder_task": taskDatasource(), }, ResourcesMap: map[string]*schema.Resource{ diff --git a/provider/secret.go b/provider/secret.go new file mode 100644 index 00000000..92e46eea --- /dev/null +++ b/provider/secret.go @@ -0,0 +1,118 @@ +package provider + +import ( + "context" + "encoding/hex" + "fmt" + "os" + + "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" +) + +// secretDataSource returns a schema for a user secret data source. +func secretDataSource() *schema.Resource { + 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)) + } + + // 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. + if value == "" { + buildID := os.Getenv("CODER_WORKSPACE_BUILD_ID") + transition := os.Getenv("CODER_WORKSPACE_TRANSITION") + if buildID != "" && transition == "start" { + helpMessage := rd.Get("help_message").(string) + return diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Missing required secret", + Detail: helpMessage, + }} + } + } + _ = rd.Set("value", value) + + return nil + }, + Schema: map[string]*schema.Schema{ + "env": { + Type: schema.TypeString, + Description: "The environment variable name that this secret must inject (e.g. \"GITHUB_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, + }, + "file": { + Type: schema.TypeString, + Description: "The file path that this secret must inject (e.g. \"~/.aws/credentials\"). Must start with `~/` or `/`. Exactly one of `env` or `file` must be set.", + Optional: true, + ForceNew: true, + }, + "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.", + 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..7cb23c8a --- /dev/null +++ b/provider/secret_test.go @@ -0,0 +1,233 @@ +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" + } + `, + ExpectError: regexp.MustCompile("Missing required secret"), + }}, + }) +} + +// 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"), + }}, + }) +} + +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) + }) +}