feat: add secret requirement contract to dynamic parameters (#24785)

Adds structured `secret_requirements` to dynamic parameter responses and
enforces missing required secrets during workspace start.

Stop, delete, and tag rendering paths skip secret requirement
enforcement so unmet secrets do not prevent cleanup. The SDK, generated
API docs/types, and backend render/resolver/wsbuilder tests are updated
for the new contract.
This commit is contained in:
dylanhuff-at-coder
2026-04-29 16:38:26 -07:00
committed by GitHub
parent fe6c8771df
commit fb84e72319
28 changed files with 1382 additions and 100 deletions
+89 -2
View File
@@ -3,6 +3,7 @@ package dynamicparameters
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
@@ -10,6 +11,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
previewtypes "github.com/coder/preview/types"
"github.com/coder/terraform-provider-coder/v2/provider"
)
@@ -22,11 +24,33 @@ const (
sourcePreset
)
const (
secretRequirementKindEnv = "env"
secretRequirementKindFile = "file"
)
type parameterValue struct {
Value string
Source parameterValueSource
}
// ResolveOption configures optional behavior for ResolveParameters.
type ResolveOption func(*resolveOptions)
type resolveOptions struct {
skipSecretRequirements bool
}
// SkipSecretRequirements skips structured secret-requirement validation and
// enforcement. Callers must pass this for non-start transitions so an
// unsatisfied coder_secret, or an admin who can't read the owner's secrets,
// doesn't block stop or delete.
func SkipSecretRequirements() ResolveOption {
return func(o *resolveOptions) {
o.skipSecretRequirements = true
}
}
//nolint:revive // firstbuild is a control flag to turn on immutable validation
func ResolveParameters(
ctx context.Context,
@@ -36,7 +60,12 @@ func ResolveParameters(
previousValues []database.WorkspaceBuildParameter,
buildValues []codersdk.WorkspaceBuildParameter,
presetValues []database.TemplateVersionPresetParameter,
opts ...ResolveOption,
) (map[string]string, error) {
o := resolveOptions{}
for _, opt := range opts {
opt(&o)
}
previousValuesMap := slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, string) {
return p.Name, p.Value
})
@@ -70,7 +99,7 @@ func ResolveParameters(
//
// This is how the form should look to the user on their workspace settings page.
// This is the original form truth that our validations should initially be based on.
output, diags := renderer.Render(ctx, ownerID, previousValuesMap)
result, diags := renderer.Render(ctx, ownerID, previousValuesMap)
if diags.HasErrors() {
// Top level diagnostics should break the build. Previous values (and new) should
// always be valid. If there is a case where this is not true, then this has to
@@ -78,6 +107,7 @@ func ResolveParameters(
return nil, parameterValidationError(diags)
}
output := result.Output
// The user's input now needs to be validated against the parameters.
// Mutability & Ephemeral parameters depend on sequential workspace builds.
@@ -98,10 +128,33 @@ func ResolveParameters(
// This is the final set of values that will be used. Any errors at this stage
// are fatal. Additional validation for immutability has to be done manually.
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
var renderOpts []RenderOption
if !o.skipSecretRequirements {
renderOpts = append(renderOpts, IncludeSecretRequirements())
}
result, diags = renderer.Render(ctx, ownerID, values.ValuesMap(), renderOpts...)
if !o.skipSecretRequirements && !diags.HasErrors() {
var missing []codersdk.SecretRequirementStatus
for _, req := range result.SecretRequirements {
if !req.Satisfied {
missing = append(missing, req)
}
}
if len(missing) > 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing required secrets",
Detail: formatMissingSecrets(missing),
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeMissingSecret,
},
})
}
}
if diags.HasErrors() {
return nil, parameterValidationError(diags)
}
output = result.Output
// parameterNames is going to be used to remove any excess values left
// around without a parameter.
@@ -228,3 +281,37 @@ func (p parameterValueMap) ValuesMap() map[string]string {
}
return values
}
func secretRequirementKind(env, file string) string {
switch {
case env != "" && file == "":
return secretRequirementKindEnv
case file != "" && env == "":
return secretRequirementKindFile
default:
return ""
}
}
func formatMissingSecrets(reqs []codersdk.SecretRequirementStatus) string {
var b strings.Builder
for i, req := range reqs {
if i > 0 {
_, _ = b.WriteString("\n")
}
switch secretRequirementKind(req.Env, req.File) {
case secretRequirementKindEnv:
_, _ = fmt.Fprintf(&b, "%s %s", secretRequirementKindEnv, req.Env)
case secretRequirementKindFile:
_, _ = fmt.Fprintf(&b, "%s %s", secretRequirementKindFile, req.File)
default:
// checkSecretRequirements filters malformed requirements produced
// by preview before they reach the resolver.
_, _ = b.WriteString("malformed secret requirement")
}
if req.HelpMessage != "" {
_, _ = fmt.Fprintf(&b, ": %s", req.HelpMessage)
}
}
return b.String()
}