Skip to content

Commit af77cd5

Browse files
authored
feat: add coder_secret data source for user secrets (#501)
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)}
1 parent 806b7c5 commit af77cd5

5 files changed

Lines changed: 761 additions & 0 deletions

File tree

docs/data-sources/secret.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "coder_secret Data Source - terraform-provider-coder"
4+
subcategory: ""
5+
description: |-
6+
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.<name>.value.
7+
---
8+
9+
# coder_secret (Data Source)
10+
11+
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.<name>.value`.
12+
13+
## Example Usage
14+
15+
```terraform
16+
data "coder_secret" "my_token" {
17+
env = "MY_TOKEN"
18+
help_message = "Personal access token injected as the environment variable MY_TOKEN"
19+
}
20+
21+
data "coder_secret" "my_cert" {
22+
file = "~/my-cert.pem"
23+
help_message = "Certificate chain injected as the file ~/my-cert.pem"
24+
}
25+
26+
# Use the secret value in an agent startup script.
27+
resource "coder_script" "setup" {
28+
agent_id = coder_agent.main.id
29+
script = "echo ${data.coder_secret.my_token.value}"
30+
}
31+
```
32+
33+
<!-- schema generated by tfplugindocs -->
34+
## Schema
35+
36+
### Required
37+
38+
- `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.
39+
40+
### Optional
41+
42+
- `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.
43+
- `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.
44+
45+
### Read-Only
46+
47+
- `id` (String) The ID of this resource.
48+
- `value` (String, Sensitive) The resolved secret value, populated from the user's stored secrets during workspace builds. Treated as missing if empty.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
data "coder_secret" "my_token" {
2+
env = "MY_TOKEN"
3+
help_message = "Personal access token injected as the environment variable MY_TOKEN"
4+
}
5+
6+
data "coder_secret" "my_cert" {
7+
file = "~/my-cert.pem"
8+
help_message = "Certificate chain injected as the file ~/my-cert.pem"
9+
}
10+
11+
# Use the secret value in an agent startup script.
12+
resource "coder_script" "setup" {
13+
agent_id = coder_agent.main.id
14+
script = "echo ${data.coder_secret.my_token.value}"
15+
}

provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ func New() *schema.Provider {
6565
"coder_workspace_owner": workspaceOwnerDataSource(),
6666
"coder_workspace_preset": workspacePresetDataSource(),
6767
"coder_task": taskDatasource(),
68+
"coder_secret": secretDataSource(),
6869
},
6970
ResourcesMap: map[string]*schema.Resource{
7071
"coder_agent": agentResource(),

provider/secret.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"fmt"
7+
"os"
8+
"regexp"
9+
"strings"
10+
11+
"github.com/hashicorp/go-cty/cty"
12+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
13+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
14+
15+
"github.com/coder/terraform-provider-coder/v2/provider/helpers"
16+
)
17+
18+
// posixEnvNameRegex matches a POSIX-compliant environment variable name:
19+
// starts with a letter or underscore, followed by letters, digits, or
20+
// underscores. This mirrors the rule enforced by coderd when secrets are
21+
// created, so enforcing it in the provider catches typos at terraform
22+
// validate/plan time rather than at build time.
23+
var posixEnvNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
24+
25+
// validateSecretEnv rejects env names that can never match a stored secret.
26+
// Empty values pass through: the env/file mutex check in ReadContext handles
27+
// that case and produces a clearer error.
28+
func validateSecretEnv(val any, _ cty.Path) diag.Diagnostics {
29+
s, ok := val.(string)
30+
if !ok {
31+
return diag.Errorf("expected string, got %T", val)
32+
}
33+
if s == "" {
34+
return nil
35+
}
36+
if !posixEnvNameRegex.MatchString(s) {
37+
return diag.Errorf(
38+
"`env` must be a POSIX-compliant identifier matching %q; got %q",
39+
posixEnvNameRegex.String(), s)
40+
}
41+
return nil
42+
}
43+
44+
// validateSecretFile rejects file paths that are not absolute or home-relative.
45+
// This mirrors the rule enforced by coderd when secrets are created/updated
46+
// (paths must start with `~/` or `/`), so enforcing it in the provider catches
47+
// mistakes at terraform validate/plan time rather than at build time.
48+
func validateSecretFile(val any, _ cty.Path) diag.Diagnostics {
49+
s, ok := val.(string)
50+
if !ok {
51+
return diag.Errorf("expected string, got %T", val)
52+
}
53+
if s == "" {
54+
return nil
55+
}
56+
if !strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "~/") {
57+
return diag.Errorf(
58+
"`file` must start with `/` or `~/`; got %q", s)
59+
}
60+
return nil
61+
}
62+
63+
// secretDataSource returns a schema for a user secret data source.
64+
func secretDataSource() *schema.Resource {
65+
const valueKey = "value"
66+
67+
return &schema.Resource{
68+
SchemaVersion: 1,
69+
70+
Description: "Use this data source to declare that a workspace requires a user secret. " +
71+
"Each `coder_secret` block declares a single secret requirement, matched by either " +
72+
"an environment variable name (`env`) or a file path (`file`). The resolved value " +
73+
"is available at build time via `data.coder_secret.<name>.value`.",
74+
ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
75+
env := rd.Get("env").(string)
76+
file := rd.Get("file").(string)
77+
78+
if env == "" && file == "" {
79+
return diag.Errorf("exactly one of `env` or `file` must be set")
80+
}
81+
if env != "" && file != "" {
82+
return diag.Errorf("exactly one of `env` or `file` must be set")
83+
}
84+
85+
// Build a stable ID from whichever field is set.
86+
if env != "" {
87+
rd.SetId(fmt.Sprintf("env:%s", env))
88+
} else {
89+
rd.SetId(fmt.Sprintf("file:%s", file))
90+
}
91+
92+
// Look up the secret value from the environment variable
93+
// set by the provisioner at build time.
94+
var value string
95+
if env != "" {
96+
value = helpers.OptionalEnv(SecretEnvEnvironmentVariable(env))
97+
} else {
98+
value = helpers.OptionalEnv(SecretFileEnvironmentVariable(file))
99+
}
100+
101+
if value != "" {
102+
// Happy path where secret is resolved.
103+
_ = rd.Set(valueKey, value)
104+
return nil
105+
}
106+
107+
// Note that an value is treated as missing. The provider cannot
108+
// distinguish "user has not stored the secret" from "user stored
109+
// an empty value", because both surface as an unset or empty
110+
// CODER_SECRET_* env var. This means a user must have a non-empty
111+
// secret value to satisfy a requirement.
112+
113+
// Only enforce missing secrets when we are certain this is a
114+
// workspace start build. We check both conditions:
115+
// 1. CODER_WORKSPACE_BUILD_ID is set (real build, not local
116+
// terraform plan)
117+
// 2. CODER_WORKSPACE_TRANSITION is "start"
118+
// In all other cases (stop, delete, local dev, ambiguous state)
119+
// we return an empty value so the operation can proceed. This
120+
// prevents a missing or deleted secret from making a workspace
121+
// unstoppable or undeletable.
122+
buildID := os.Getenv("CODER_WORKSPACE_BUILD_ID")
123+
transition := os.Getenv("CODER_WORKSPACE_TRANSITION")
124+
workspaceStartBuild := buildID != "" && transition == "start"
125+
if !workspaceStartBuild {
126+
_ = rd.Set(valueKey, value)
127+
return nil
128+
}
129+
130+
var requirement string
131+
if env != "" {
132+
requirement = fmt.Sprintf("environment variable %q", env)
133+
} else {
134+
requirement = fmt.Sprintf("file %q", file)
135+
}
136+
137+
var detail strings.Builder
138+
_, _ = fmt.Fprintf(&detail, "Required: %s\n\n", requirement)
139+
if helpMessage := rd.Get("help_message").(string); helpMessage != "" {
140+
_, _ = fmt.Fprintf(&detail, "Help message: %s\n\n", helpMessage)
141+
}
142+
_, _ = fmt.Fprintf(&detail, "To resolve: ensure a secret exposes the %s.\n", requirement)
143+
144+
return diag.Diagnostics{{
145+
Severity: diag.Error,
146+
Summary: fmt.Sprintf("Missing required secret: %s", requirement),
147+
Detail: detail.String(),
148+
}}
149+
},
150+
Schema: map[string]*schema.Schema{
151+
"env": {
152+
Type: schema.TypeString,
153+
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.",
154+
Optional: true,
155+
ForceNew: true,
156+
ValidateDiagFunc: validateSecretEnv,
157+
},
158+
"file": {
159+
Type: schema.TypeString,
160+
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.",
161+
Optional: true,
162+
ForceNew: true,
163+
ValidateDiagFunc: validateSecretFile,
164+
},
165+
"help_message": {
166+
Type: schema.TypeString,
167+
Description: "Guidance shown to users when this secret requirement is not satisfied. Displayed on the create workspace page and in build failure logs.",
168+
Required: true,
169+
},
170+
"value": {
171+
Type: schema.TypeString,
172+
Description: "The resolved secret value, populated from the user's stored secrets during workspace builds. Treated as missing if empty.",
173+
Computed: true,
174+
Sensitive: true,
175+
},
176+
},
177+
}
178+
}
179+
180+
// SecretEnvEnvironmentVariable returns the environment variable used
181+
// to pass a user secret matched by env_name to Terraform during
182+
// workspace builds. The env name is used directly and assumed to be
183+
// POSIX-compliant.
184+
func SecretEnvEnvironmentVariable(envName string) string {
185+
return fmt.Sprintf("CODER_SECRET_ENV_%s", envName)
186+
}
187+
188+
// SecretFileEnvironmentVariable returns the environment variable used
189+
// to pass a user secret matched by file_path to Terraform during
190+
// workspace builds. The file path is hex-encoded because it contains
191+
// characters invalid in environment variable names.
192+
func SecretFileEnvironmentVariable(filePath string) string {
193+
return fmt.Sprintf("CODER_SECRET_FILE_%s", hex.EncodeToString([]byte(filePath)))
194+
}

0 commit comments

Comments
 (0)