mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: remove coder_secret Terraform integration (#25512)
Removes the coder_secret Terraform integration: the data.coder_secret consumption path through provisionerdserver → provisioner.proto → provisioner/terraform, the dynamic-parameter secret-requirement validation, and the workspace-update / resolve-autostart surfaces that depended on it. This is being done due to a product/feature direction change (see PLAT-243). User-secret CRUD (DB, REST, CLI, UI, telemetry, audit) and the agent-manifest secret-injection path are untouched. The provisionerd API is bumped from v1.17 to v1.18 rather than rolled back: v1.17 shipped in v2.33.x, so user_secrets field numbers are reserved and the changelog documents both versions. Generated with assistance from Coder Agents.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -12,27 +13,14 @@ import (
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/apiversion"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/files"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/preview"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
// RenderResult is the structured output of Renderer.Render. The outer
|
||||
// pointer is always non-nil; inner fields may be nil.
|
||||
// SecretRequirements is nil when no coder_secret blocks are declared,
|
||||
// when fetch was forbidden, or when fetch failed. Output may be nil
|
||||
// when underlying rendering fails (matches preview.Preview's existing
|
||||
// convention).
|
||||
type RenderResult struct {
|
||||
Output *preview.Output
|
||||
SecretRequirements []codersdk.SecretRequirementStatus
|
||||
}
|
||||
|
||||
// Renderer is able to execute and evaluate terraform with the given inputs.
|
||||
// It may use the database to fetch additional state, such as a user's groups,
|
||||
// roles, etc. Therefore, it requires an authenticated `ctx`.
|
||||
@@ -40,40 +28,17 @@ type RenderResult struct {
|
||||
// 'Close()' **must** be called once the renderer is no longer needed.
|
||||
// Forgetting to do so will result in a memory leak.
|
||||
type Renderer interface {
|
||||
Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...RenderOption) (*RenderResult, hcl.Diagnostics)
|
||||
Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
|
||||
Close()
|
||||
}
|
||||
|
||||
var ErrTemplateVersionNotReady = xerrors.New("template version job not finished")
|
||||
|
||||
// RenderOption configures optional behavior for Renderer.Render.
|
||||
type RenderOption func(*renderOptions)
|
||||
|
||||
type renderOptions struct {
|
||||
includeSecretRequirements bool
|
||||
}
|
||||
|
||||
// IncludeSecretRequirements returns structured secret-requirement statuses and
|
||||
// diagnostics for the rendered template.
|
||||
func IncludeSecretRequirements() RenderOption {
|
||||
return func(o *renderOptions) {
|
||||
o.includeSecretRequirements = true
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic extra codes for secret-requirement validation.
|
||||
const (
|
||||
DiagCodeMissingSecret = "missing_secret"
|
||||
DiagCodeOwnerSecretsFetchFailed = "owner_secrets_fetch_failed"
|
||||
DiagCodeSecretValidationForbidden = "secret_validation_forbidden"
|
||||
)
|
||||
|
||||
// loader is used to load the necessary coder objects for rendering a template
|
||||
// version's parameters. The output is a Renderer, which is the object that uses
|
||||
// the cached objects to render the template version's parameters.
|
||||
type loader struct {
|
||||
templateVersionID uuid.UUID
|
||||
logger slog.Logger
|
||||
|
||||
// cache of objects
|
||||
templateVersion *database.TemplateVersion
|
||||
@@ -125,13 +90,6 @@ func WithTerraformValues(values database.TemplateVersionTerraformValue) func(r *
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets the logger used by the renderer.
|
||||
func WithLogger(logger slog.Logger) func(r *loader) {
|
||||
return func(r *loader) {
|
||||
r.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
func (r *loader) loadData(ctx context.Context, db database.Store) error {
|
||||
if r.templateVersion == nil {
|
||||
tv, err := db.GetTemplateVersionByID(ctx, r.templateVersionID)
|
||||
@@ -245,14 +203,12 @@ func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *
|
||||
|
||||
closeFiles = false // Caller will have to call close
|
||||
return &dynamicRenderer{
|
||||
data: r,
|
||||
templateFS: templateFS,
|
||||
db: db,
|
||||
logger: r.logger,
|
||||
ownerErrors: make(map[uuid.UUID]error),
|
||||
ownerSecretErrors: make(map[uuid.UUID]error),
|
||||
close: cache.Close,
|
||||
tfvarValues: tfVarValues,
|
||||
data: r,
|
||||
templateFS: templateFS,
|
||||
db: db,
|
||||
ownerErrors: make(map[uuid.UUID]error),
|
||||
close: cache.Close,
|
||||
tfvarValues: tfVarValues,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -260,26 +216,16 @@ type dynamicRenderer struct {
|
||||
db database.Store
|
||||
data *loader
|
||||
templateFS fs.FS
|
||||
logger slog.Logger
|
||||
|
||||
ownerErrors map[uuid.UUID]error
|
||||
currentOwner *previewtypes.WorkspaceOwner
|
||||
|
||||
// ownerSecretErrors caches NotAuthorized denials per owner.
|
||||
ownerSecretErrors map[uuid.UUID]error
|
||||
|
||||
tfvarValues map[string]cty.Value
|
||||
tfvarValues map[string]cty.Value
|
||||
|
||||
once sync.Once
|
||||
close func()
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...RenderOption) (*RenderResult, hcl.Diagnostics) {
|
||||
options := renderOptions{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
// Always start with the cached error, if we have one.
|
||||
ownerErr := r.ownerErrors[ownerID]
|
||||
if ownerErr == nil {
|
||||
@@ -288,7 +234,7 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values
|
||||
|
||||
if ownerErr != nil || r.currentOwner == nil {
|
||||
r.ownerErrors[ownerID] = ownerErr
|
||||
return &RenderResult{}, hcl.Diagnostics{
|
||||
return nil, hcl.Diagnostics{
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch workspace owner",
|
||||
@@ -305,122 +251,13 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values
|
||||
ParameterValues: values,
|
||||
Owner: *r.currentOwner,
|
||||
TFVars: r.tfvarValues,
|
||||
// Leave Logger nil so preview discards parser logs. Returning
|
||||
// those logs to callers would be useful, but they may be large.
|
||||
// Do not emit parser logs to coderd output logs.
|
||||
// TODO: Returning this logs in the output would benefit the caller.
|
||||
// Unsure how large the logs can be, so for now we just discard them.
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
|
||||
output, diags := preview.Preview(ctx, input, r.templateFS)
|
||||
if output == nil {
|
||||
return &RenderResult{}, diags
|
||||
}
|
||||
|
||||
var secretRequirements []codersdk.SecretRequirementStatus
|
||||
if options.includeSecretRequirements && len(output.SecretRequirements) > 0 {
|
||||
var secretDiags hcl.Diagnostics
|
||||
secretRequirements, secretDiags = r.checkSecretRequirements(ctx, ownerID, output.SecretRequirements)
|
||||
diags = diags.Extend(secretDiags)
|
||||
}
|
||||
|
||||
return &RenderResult{
|
||||
Output: output,
|
||||
SecretRequirements: secretRequirements,
|
||||
}, diags
|
||||
}
|
||||
|
||||
// checkSecretRequirements returns structured requirement statuses. Callers
|
||||
// without user_secret:read on the owner get a single
|
||||
// secret_validation_forbidden warning instead, to avoid leaking the target's
|
||||
// secret names via structured status presence.
|
||||
func (r *dynamicRenderer) checkSecretRequirements(ctx context.Context, ownerID uuid.UUID, reqs []previewtypes.SecretRequirement) ([]codersdk.SecretRequirementStatus, hcl.Diagnostics) {
|
||||
secrets, err := r.getOwnerSecrets(ctx, ownerID)
|
||||
if err != nil {
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
// Warning keeps the Create Workspace button enabled.
|
||||
return nil, hcl.Diagnostics{{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Cannot validate secret requirements",
|
||||
Detail: "You are not permitted to read secret metadata for this user. The workspace may fail to build if required secrets are not set.",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeSecretValidationForbidden,
|
||||
},
|
||||
}}
|
||||
}
|
||||
r.logger.Warn(ctx, "failed to fetch owner secrets for secret-requirement validation",
|
||||
slog.F("owner_id", ownerID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return nil, hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch owner secrets",
|
||||
Detail: "Could not validate template secret requirements. Please try again.",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
envSet := make(map[string]struct{}, len(secrets))
|
||||
fileSet := make(map[string]struct{}, len(secrets))
|
||||
for _, s := range secrets {
|
||||
if s.EnvName != "" {
|
||||
envSet[s.EnvName] = struct{}{}
|
||||
}
|
||||
if s.FilePath != "" {
|
||||
fileSet[s.FilePath] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
statuses := make([]codersdk.SecretRequirementStatus, 0, len(reqs))
|
||||
type secretRequirementDedupKey struct {
|
||||
env string
|
||||
file string
|
||||
}
|
||||
seen := make(map[secretRequirementDedupKey]int, len(reqs))
|
||||
for _, req := range reqs {
|
||||
kind := secretRequirementKind(req.Env, req.File)
|
||||
if kind == "" {
|
||||
// Defensive: SecretFromBlock should reject invalid inputs upstream.
|
||||
continue
|
||||
}
|
||||
|
||||
var env string
|
||||
var file string
|
||||
satisfied := false
|
||||
switch kind {
|
||||
case secretRequirementKindEnv:
|
||||
env = req.Env
|
||||
_, satisfied = envSet[req.Env]
|
||||
case secretRequirementKindFile:
|
||||
file = req.File
|
||||
_, satisfied = fileSet[req.File]
|
||||
}
|
||||
|
||||
// Dedup by Env/File. On collision, keep the
|
||||
// lexicographically smallest non-empty HelpMessage. This is
|
||||
// deterministic across runs; preview's SortSecretRequirements
|
||||
// sorts on (Env, File) and does not guarantee a stable order
|
||||
// when multiple coder_secret blocks declare the same value, so
|
||||
// we cannot rely on "first source wins."
|
||||
key := secretRequirementDedupKey{
|
||||
env: env,
|
||||
file: file,
|
||||
}
|
||||
if i, ok := seen[key]; ok {
|
||||
statuses[i].Satisfied = statuses[i].Satisfied || satisfied
|
||||
if req.HelpMessage != "" && (statuses[i].HelpMessage == "" || req.HelpMessage < statuses[i].HelpMessage) {
|
||||
statuses[i].HelpMessage = req.HelpMessage
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[key] = len(statuses)
|
||||
statuses = append(statuses, codersdk.SecretRequirementStatus{
|
||||
Env: env,
|
||||
File: file,
|
||||
HelpMessage: req.HelpMessage,
|
||||
Satisfied: satisfied,
|
||||
})
|
||||
}
|
||||
return statuses, nil
|
||||
return preview.Preview(ctx, input, r.templateFS)
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error {
|
||||
@@ -437,23 +274,6 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOwnerSecrets fetches the owner's secrets under the caller's auth
|
||||
// context. Only NotAuthorized denials are cached; successes re-fetch so
|
||||
// newly-created secrets are picked up on the next render.
|
||||
func (r *dynamicRenderer) getOwnerSecrets(ctx context.Context, ownerID uuid.UUID) ([]database.ListUserSecretsRow, error) {
|
||||
if err, cached := r.ownerSecretErrors[ownerID]; cached {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.db.ListUserSecrets(ctx, ownerID)
|
||||
if err != nil {
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
r.ownerSecretErrors[ownerID] = err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) Close() {
|
||||
r.once.Do(r.close)
|
||||
}
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
package dynamicparameters
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
// newTestRenderer builds a dynamicRenderer backed by the given testdata
|
||||
// fixture. The caller must seed an org and member row.
|
||||
func newTestRenderer(t *testing.T, db database.Store, orgID uuid.UUID, fixture string) *dynamicRenderer {
|
||||
t.Helper()
|
||||
return &dynamicRenderer{
|
||||
db: db,
|
||||
templateFS: os.DirFS(filepath.Join("testdata", fixture)),
|
||||
ownerErrors: make(map[uuid.UUID]error),
|
||||
ownerSecretErrors: make(map[uuid.UUID]error),
|
||||
data: &loader{
|
||||
templateVersion: &database.TemplateVersion{
|
||||
OrganizationID: orgID,
|
||||
},
|
||||
terraformValues: &database.TemplateVersionTerraformValue{},
|
||||
},
|
||||
close: func() {},
|
||||
}
|
||||
}
|
||||
|
||||
// seedOwner creates a user and org member so WorkspaceOwner resolves.
|
||||
func seedOwner(t *testing.T, db database.Store, orgID uuid.UUID) database.User {
|
||||
t.Helper()
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: orgID,
|
||||
UserID: u.ID,
|
||||
})
|
||||
return u
|
||||
}
|
||||
|
||||
func TestDynamicRender_MissingSecretRequirement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_required")
|
||||
defer renderer.Close()
|
||||
|
||||
// Owner has no secrets; the GITHUB_TOKEN requirement is unmet.
|
||||
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
|
||||
require.NotNil(t, out)
|
||||
require.NotNil(t, out.Output)
|
||||
requireNoMissingSecret(t, diags)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
|
||||
Satisfied: false,
|
||||
}}, out.SecretRequirements)
|
||||
|
||||
// The same renderer must pick up a newly-created secret on the
|
||||
// next render, without a reload.
|
||||
_ = dbgen.UserSecret(t, db, database.UserSecret{
|
||||
UserID: owner.ID,
|
||||
Name: "github_token",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
})
|
||||
|
||||
out, diags2 := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
|
||||
requireNoMissingSecret(t, diags2)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
|
||||
Satisfied: true,
|
||||
}}, out.SecretRequirements)
|
||||
}
|
||||
|
||||
func TestDynamicRender_ConditionalSecretRequirement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_conditional")
|
||||
defer renderer.Close()
|
||||
|
||||
// Block inactive: no validation.
|
||||
out, diags := renderer.Render(ctx, owner.ID, map[string]string{"use_github": "false"}, IncludeSecretRequirements())
|
||||
requireNoMissingSecret(t, diags)
|
||||
require.Nil(t, out.SecretRequirements)
|
||||
|
||||
// Block active: requirement surfaces.
|
||||
out, diags = renderer.Render(ctx, owner.ID, map[string]string{"use_github": "true"}, IncludeSecretRequirements())
|
||||
requireNoMissingSecret(t, diags)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT",
|
||||
Satisfied: false,
|
||||
}}, out.SecretRequirements)
|
||||
}
|
||||
|
||||
func TestDynamicRender_SingleSecretSatisfiesEnvAndFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
// One row must satisfy both an env and a file requirement: the
|
||||
// check builds independent envSet and fileSet maps.
|
||||
_ = dbgen.UserSecret(t, db, database.UserSecret{
|
||||
UserID: owner.ID,
|
||||
Name: "combined",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
FilePath: "~/.ssh/id_rsa",
|
||||
})
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_env_and_file")
|
||||
defer renderer.Close()
|
||||
|
||||
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
|
||||
requireNoMissingSecret(t, diags)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{
|
||||
{
|
||||
File: "~/.ssh/id_rsa",
|
||||
HelpMessage: "needs file",
|
||||
Satisfied: true,
|
||||
},
|
||||
{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "needs env",
|
||||
Satisfied: true,
|
||||
},
|
||||
}, out.SecretRequirements)
|
||||
}
|
||||
|
||||
func TestDynamicRender_PartialEnvAndFileSatisfaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
// Env-only secret against an env+file requirement: only the file
|
||||
// requirement should fail.
|
||||
_ = dbgen.UserSecret(t, db, database.UserSecret{
|
||||
UserID: owner.ID,
|
||||
Name: "env_only",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
})
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_env_and_file")
|
||||
defer renderer.Close()
|
||||
|
||||
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
|
||||
requireNoMissingSecret(t, diags)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{
|
||||
{
|
||||
File: "~/.ssh/id_rsa",
|
||||
HelpMessage: "needs file",
|
||||
Satisfied: false,
|
||||
},
|
||||
{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "needs env",
|
||||
Satisfied: true,
|
||||
},
|
||||
}, out.SecretRequirements)
|
||||
}
|
||||
|
||||
func TestDynamicRender_OwnerSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
|
||||
// Owner A satisfies the requirement; owner B does not.
|
||||
ownerA := seedOwner(t, db, org.ID)
|
||||
ownerB := seedOwner(t, db, org.ID)
|
||||
_ = dbgen.UserSecret(t, db, database.UserSecret{
|
||||
UserID: ownerA.ID,
|
||||
Name: "gh",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
})
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_required")
|
||||
defer renderer.Close()
|
||||
|
||||
out, diags := renderer.Render(ctx, ownerA.ID, nil, IncludeSecretRequirements())
|
||||
requireNoMissingSecret(t, diags)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
|
||||
Satisfied: true,
|
||||
}}, out.SecretRequirements)
|
||||
|
||||
// The cache must not serve owner A's rows to owner B.
|
||||
out, diags = renderer.Render(ctx, ownerB.ID, nil, IncludeSecretRequirements())
|
||||
requireNoMissingSecret(t, diags)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
|
||||
Satisfied: false,
|
||||
}}, out.SecretRequirements)
|
||||
}
|
||||
|
||||
func TestDynamicRender_DeduplicatesSecretRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_required")
|
||||
defer renderer.Close()
|
||||
|
||||
reqs := []previewtypes.SecretRequirement{
|
||||
{Env: "GITHUB_TOKEN", HelpMessage: "z help"},
|
||||
{Env: "GITHUB_TOKEN", HelpMessage: "a help"},
|
||||
}
|
||||
statuses, diags := renderer.checkSecretRequirements(ctx, owner.ID, reqs)
|
||||
require.Empty(t, diags)
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "a help",
|
||||
Satisfied: false,
|
||||
}}, statuses)
|
||||
}
|
||||
|
||||
// countingStore counts ListUserSecrets calls per user.
|
||||
type countingStore struct {
|
||||
database.Store
|
||||
mu sync.Mutex
|
||||
calls map[uuid.UUID]int
|
||||
}
|
||||
|
||||
func (c *countingStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) {
|
||||
c.mu.Lock()
|
||||
if c.calls == nil {
|
||||
c.calls = map[uuid.UUID]int{}
|
||||
}
|
||||
c.calls[userID]++
|
||||
c.mu.Unlock()
|
||||
return c.Store.ListUserSecrets(ctx, userID)
|
||||
}
|
||||
|
||||
func (c *countingStore) callsFor(id uuid.UUID) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.calls[id]
|
||||
}
|
||||
|
||||
// TestDynamicRender_NotAuthorizedIsCached pins that NotAuthorized
|
||||
// denials hit ListUserSecrets at most once per owner.
|
||||
func TestDynamicRender_NotAuthorizedIsCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inner, _ := dbtestutil.NewDB(t)
|
||||
db := &countingStore{Store: secretAuthDenyingStore{Store: inner}}
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_required")
|
||||
defer renderer.Close()
|
||||
|
||||
for range 3 {
|
||||
_, _ = renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
|
||||
}
|
||||
require.Equal(t, 1, db.callsFor(owner.ID),
|
||||
"NotAuthorized must be cached across renders")
|
||||
}
|
||||
|
||||
// secretAuthDenyingStore makes ListUserSecrets return NotAuthorized,
|
||||
// simulating a non-owner caller.
|
||||
type secretAuthDenyingStore struct {
|
||||
database.Store
|
||||
}
|
||||
|
||||
func (secretAuthDenyingStore) ListUserSecrets(_ context.Context, _ uuid.UUID) ([]database.ListUserSecretsRow, error) {
|
||||
return nil, dbauthz.NotAuthorizedError{}
|
||||
}
|
||||
|
||||
type secretFetchFailingStore struct {
|
||||
database.Store
|
||||
}
|
||||
|
||||
func (secretFetchFailingStore) ListUserSecrets(_ context.Context, _ uuid.UUID) ([]database.ListUserSecretsRow, error) {
|
||||
return nil, xerrors.New("fetch failed")
|
||||
}
|
||||
|
||||
func TestDynamicRender_SecretFetchFailedHasNilRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inner, _ := dbtestutil.NewDB(t)
|
||||
db := secretFetchFailingStore{Store: inner}
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_required")
|
||||
defer renderer.Close()
|
||||
|
||||
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
|
||||
require.Nil(t, out.SecretRequirements)
|
||||
requireNoMissingSecret(t, diags)
|
||||
|
||||
var sawErr bool
|
||||
for _, d := range diags {
|
||||
extra, ok := d.Extra.(previewtypes.DiagnosticExtra)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if extra.Code == DiagCodeOwnerSecretsFetchFailed {
|
||||
require.Equal(t, hcl.DiagError, d.Severity)
|
||||
sawErr = true
|
||||
}
|
||||
}
|
||||
require.True(t, sawErr, "expected owner_secrets_fetch_failed error")
|
||||
}
|
||||
|
||||
// TestDynamicRender_NonOwnerCannotLeakSecretRequirements guards against
|
||||
// a non-owner enumerating secret names via missing_secret diagnostics.
|
||||
func TestDynamicRender_NonOwnerCannotLeakSecretRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inner, _ := dbtestutil.NewDB(t)
|
||||
db := secretAuthDenyingStore{Store: inner}
|
||||
ctx := t.Context()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := seedOwner(t, db, org.ID)
|
||||
|
||||
// Secret matches the requirement; a non-owner must still never
|
||||
// see it.
|
||||
_ = dbgen.UserSecret(t, db, database.UserSecret{
|
||||
UserID: owner.ID,
|
||||
Name: "gh",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
})
|
||||
|
||||
renderer := newTestRenderer(t, db, org.ID, "secret_required")
|
||||
defer renderer.Close()
|
||||
|
||||
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
|
||||
require.Nil(t, out.SecretRequirements)
|
||||
|
||||
// No missing_secret diagnostic for a non-owner, regardless of
|
||||
// whether the target satisfies the requirement.
|
||||
requireNoMissingSecret(t, diags)
|
||||
|
||||
// Surface a warning so the admin knows validation didn't run.
|
||||
var sawWarn bool
|
||||
for _, d := range diags {
|
||||
extra, ok := d.Extra.(previewtypes.DiagnosticExtra)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if extra.Code == DiagCodeSecretValidationForbidden {
|
||||
require.Equal(t, hcl.DiagWarning, d.Severity,
|
||||
"secret_validation_forbidden must be a warning")
|
||||
sawWarn = true
|
||||
}
|
||||
}
|
||||
require.True(t, sawWarn, "expected secret_validation_forbidden warning")
|
||||
}
|
||||
|
||||
func requireNoMissingSecret(t *testing.T, diags hcl.Diagnostics) {
|
||||
t.Helper()
|
||||
for _, d := range diags {
|
||||
if extra, ok := d.Extra.(previewtypes.DiagnosticExtra); ok && extra.Code == DiagCodeMissingSecret {
|
||||
t.Fatalf("unexpected missing_secret diagnostic: %s", d.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
dynamicparameters "github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||
preview "github.com/coder/preview"
|
||||
uuid "github.com/google/uuid"
|
||||
hcl "github.com/hashicorp/hcl/v2"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
@@ -56,21 +56,16 @@ func (mr *MockRendererMockRecorder) Close() *gomock.Call {
|
||||
}
|
||||
|
||||
// Render mocks base method.
|
||||
func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...dynamicparameters.RenderOption) (*dynamicparameters.RenderResult, hcl.Diagnostics) {
|
||||
func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
m.ctrl.T.Helper()
|
||||
varargs := []any{ctx, ownerID, values}
|
||||
for _, a := range opts {
|
||||
varargs = append(varargs, a)
|
||||
}
|
||||
ret := m.ctrl.Call(m, "Render", varargs...)
|
||||
ret0, _ := ret[0].(*dynamicparameters.RenderResult)
|
||||
ret := m.ctrl.Call(m, "Render", ctx, ownerID, values)
|
||||
ret0, _ := ret[0].(*preview.Output)
|
||||
ret1, _ := ret[1].(hcl.Diagnostics)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Render indicates an expected call of Render.
|
||||
func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any, opts ...any) *gomock.Call {
|
||||
func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
varargs := append([]any{ctx, ownerID, values}, opts...)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), varargs...)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), ctx, ownerID, values)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package dynamicparameters
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
@@ -11,7 +10,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -24,33 +22,11 @@ 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,
|
||||
@@ -60,12 +36,7 @@ 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
|
||||
})
|
||||
@@ -99,7 +70,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.
|
||||
result, diags := renderer.Render(ctx, ownerID, previousValuesMap)
|
||||
output, 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
|
||||
@@ -107,7 +78,6 @@ 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.
|
||||
@@ -128,33 +98,10 @@ 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.
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
|
||||
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.
|
||||
@@ -281,37 +228,3 @@ 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()
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package dynamicparameters
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestFormatMissingSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reqs []codersdk.SecretRequirementStatus
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Env",
|
||||
reqs: []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT",
|
||||
}},
|
||||
want: "env GITHUB_TOKEN: Add a GitHub PAT",
|
||||
},
|
||||
{
|
||||
name: "File",
|
||||
reqs: []codersdk.SecretRequirementStatus{{
|
||||
File: "~/.ssh/id_rsa",
|
||||
}},
|
||||
want: "file ~/.ssh/id_rsa",
|
||||
},
|
||||
{
|
||||
name: "Multiple",
|
||||
reqs: []codersdk.SecretRequirementStatus{
|
||||
{
|
||||
Env: "GITHUB_TOKEN",
|
||||
},
|
||||
{
|
||||
File: "~/.ssh/id_rsa",
|
||||
HelpMessage: "Add an SSH key",
|
||||
},
|
||||
},
|
||||
want: "env GITHUB_TOKEN\nfile ~/.ssh/id_rsa: Add an SSH key",
|
||||
},
|
||||
{
|
||||
name: "MalformedEmpty",
|
||||
reqs: []codersdk.SecretRequirementStatus{{}},
|
||||
want: "malformed secret requirement",
|
||||
},
|
||||
{
|
||||
name: "MalformedBothEnvAndFile",
|
||||
reqs: []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
File: "~/.ssh/id_rsa",
|
||||
}},
|
||||
want: "malformed secret requirement",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.want, formatMissingSecrets(tt.reqs))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
@@ -33,37 +32,23 @@ func TestResolveParameters(t *testing.T) {
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes().
|
||||
Return(renderResult(
|
||||
previewtypes.Parameter{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: "immutable",
|
||||
Type: previewtypes.ParameterTypeString,
|
||||
FormType: provider.ParameterFormTypeInput,
|
||||
Mutable: false,
|
||||
DefaultValue: previewtypes.StringLiteral("foo"),
|
||||
Required: true,
|
||||
Return(&preview.Output{
|
||||
Parameters: []previewtypes.Parameter{
|
||||
{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: "immutable",
|
||||
Type: previewtypes.ParameterTypeString,
|
||||
FormType: provider.ParameterFormTypeInput,
|
||||
Mutable: false,
|
||||
DefaultValue: previewtypes.StringLiteral("foo"),
|
||||
Required: true,
|
||||
},
|
||||
Value: previewtypes.StringLiteral("foo"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
Value: previewtypes.StringLiteral("foo"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
), nil)
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes().
|
||||
Return(renderResult(
|
||||
previewtypes.Parameter{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: "immutable",
|
||||
Type: previewtypes.ParameterTypeString,
|
||||
FormType: provider.ParameterFormTypeInput,
|
||||
Mutable: false,
|
||||
DefaultValue: previewtypes.StringLiteral("foo"),
|
||||
Required: true,
|
||||
},
|
||||
Value: previewtypes.StringLiteral("foo"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
), nil)
|
||||
}, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
values, err := dynamicparameters.ResolveParameters(ctx, uuid.New(), render, false,
|
||||
[]database.WorkspaceBuildParameter{}, // No previous values
|
||||
@@ -96,25 +81,29 @@ func TestResolveParameters(t *testing.T) {
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
// Return the mutable param first
|
||||
Return(renderResult(
|
||||
previewtypes.Parameter{
|
||||
ParameterData: mutable,
|
||||
Value: previewtypes.StringLiteral("foo"),
|
||||
Diagnostics: nil,
|
||||
Return(&preview.Output{
|
||||
Parameters: []previewtypes.Parameter{
|
||||
{
|
||||
ParameterData: mutable,
|
||||
Value: previewtypes.StringLiteral("foo"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
},
|
||||
), nil)
|
||||
}, nil)
|
||||
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
// Then the immutable param
|
||||
Return(renderResult(
|
||||
previewtypes.Parameter{
|
||||
ParameterData: immutable,
|
||||
// The user set the value to bar
|
||||
Value: previewtypes.StringLiteral("bar"),
|
||||
Diagnostics: nil,
|
||||
Return(&preview.Output{
|
||||
Parameters: []previewtypes.Parameter{
|
||||
{
|
||||
ParameterData: immutable,
|
||||
// The user set the value to bar
|
||||
Value: previewtypes.StringLiteral("bar"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
},
|
||||
), nil)
|
||||
}, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
_, err := dynamicparameters.ResolveParameters(ctx, uuid.New(), render, false,
|
||||
@@ -170,39 +159,23 @@ func TestResolveParameters(t *testing.T) {
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes().
|
||||
Return(renderResult(
|
||||
previewtypes.Parameter{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: "param",
|
||||
Type: previewtypes.ParameterTypeNumber,
|
||||
FormType: provider.ParameterFormTypeInput,
|
||||
Mutable: true,
|
||||
Validations: []*previewtypes.ParameterValidation{
|
||||
{Monotonic: ptr.Ref(tc.monotonic)},
|
||||
Return(&preview.Output{
|
||||
Parameters: []previewtypes.Parameter{
|
||||
{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: "param",
|
||||
Type: previewtypes.ParameterTypeNumber,
|
||||
FormType: provider.ParameterFormTypeInput,
|
||||
Mutable: true,
|
||||
Validations: []*previewtypes.ParameterValidation{
|
||||
{Monotonic: ptr.Ref(tc.monotonic)},
|
||||
},
|
||||
},
|
||||
Value: previewtypes.StringLiteral(tc.cur),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
Value: previewtypes.StringLiteral(tc.cur),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
), nil)
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes().
|
||||
Return(renderResult(
|
||||
previewtypes.Parameter{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: "param",
|
||||
Type: previewtypes.ParameterTypeNumber,
|
||||
FormType: provider.ParameterFormTypeInput,
|
||||
Mutable: true,
|
||||
Validations: []*previewtypes.ParameterValidation{
|
||||
{Monotonic: ptr.Ref(tc.monotonic)},
|
||||
},
|
||||
},
|
||||
Value: previewtypes.StringLiteral(tc.cur),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
), nil)
|
||||
}, nil)
|
||||
|
||||
var previousValues []database.WorkspaceBuildParameter
|
||||
if tc.prev != "" {
|
||||
@@ -232,172 +205,4 @@ func TestResolveParameters(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BaselineRenderDoesNotRequestSecretRequirementsWhenDeactivatingRequirement", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
render := rendermock.NewMockRenderer(ctrl)
|
||||
ownerID := uuid.New()
|
||||
|
||||
gomock.InOrder(
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
|
||||
Return(renderResult(stringParameter("use_github", "true")), nil),
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "false"}, gomock.Any()).
|
||||
Return(renderResult(stringParameter("use_github", "false")), nil),
|
||||
)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
values, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
|
||||
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
|
||||
[]codersdk.WorkspaceBuildParameter{{Name: "use_github", Value: "false"}},
|
||||
[]database.TemplateVersionPresetParameter{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{"use_github": "false"}, values)
|
||||
})
|
||||
|
||||
t.Run("SkipSecretRequirementsAllowsFinalMissingSecrets", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
render := rendermock.NewMockRenderer(ctrl)
|
||||
ownerID := uuid.New()
|
||||
|
||||
gomock.InOrder(
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
|
||||
Return(renderResult(stringParameter("use_github", "true")), nil),
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
|
||||
Return(renderResultWithSecretRequirements(
|
||||
[]codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT",
|
||||
Satisfied: false,
|
||||
}},
|
||||
stringParameter("use_github", "true"),
|
||||
), nil),
|
||||
)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
values, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
|
||||
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
|
||||
nil,
|
||||
nil,
|
||||
dynamicparameters.SkipSecretRequirements(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{"use_github": "true"}, values)
|
||||
})
|
||||
|
||||
t.Run("FinalMissingSecretsBlockByDefault", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
render := rendermock.NewMockRenderer(ctrl)
|
||||
ownerID := uuid.New()
|
||||
|
||||
gomock.InOrder(
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
|
||||
Return(renderResult(stringParameter("use_github", "true")), nil),
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}, gomock.Any()).
|
||||
Return(renderResultWithSecretRequirements(
|
||||
[]codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT",
|
||||
Satisfied: false,
|
||||
}},
|
||||
stringParameter("use_github", "true"),
|
||||
), nil),
|
||||
)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
_, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
|
||||
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.Error(t, err)
|
||||
resp, ok := httperror.IsResponder(err)
|
||||
require.True(t, ok)
|
||||
_, respErr := resp.Response()
|
||||
require.Contains(t, respErr.Detail, "Missing required secrets")
|
||||
require.Contains(t, respErr.Detail, "env GITHUB_TOKEN: Add a GitHub PAT")
|
||||
})
|
||||
|
||||
t.Run("FinalRenderErrorSuppressesMissingSecretSynthesis", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
render := rendermock.NewMockRenderer(ctrl)
|
||||
ownerID := uuid.New()
|
||||
|
||||
gomock.InOrder(
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
|
||||
Return(renderResult(stringParameter("use_github", "true")), nil),
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}, gomock.Any()).
|
||||
Return(renderResultWithSecretRequirements(
|
||||
[]codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT",
|
||||
Satisfied: false,
|
||||
}},
|
||||
stringParameter("use_github", "true"),
|
||||
), hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Render failed",
|
||||
Detail: "Template parameter expression failed.",
|
||||
}}),
|
||||
)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
_, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
|
||||
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.Error(t, err)
|
||||
resp, ok := httperror.IsResponder(err)
|
||||
require.True(t, ok)
|
||||
_, respErr := resp.Response()
|
||||
require.Contains(t, respErr.Detail, "Render failed")
|
||||
require.NotContains(t, respErr.Detail, "Missing required secrets")
|
||||
})
|
||||
}
|
||||
|
||||
func stringParameter(name string, value string) previewtypes.Parameter {
|
||||
return previewtypes.Parameter{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: name,
|
||||
Type: previewtypes.ParameterTypeString,
|
||||
FormType: provider.ParameterFormTypeInput,
|
||||
Mutable: true,
|
||||
DefaultValue: previewtypes.StringLiteral(value),
|
||||
},
|
||||
Value: previewtypes.StringLiteral(value),
|
||||
}
|
||||
}
|
||||
|
||||
func renderResult(params ...previewtypes.Parameter) *dynamicparameters.RenderResult {
|
||||
return &dynamicparameters.RenderResult{
|
||||
Output: &preview.Output{
|
||||
Parameters: params,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func renderResultWithSecretRequirements(reqs []codersdk.SecretRequirementStatus, params ...previewtypes.Parameter) *dynamicparameters.RenderResult {
|
||||
return &dynamicparameters.RenderResult{
|
||||
Output: &preview.Output{
|
||||
Parameters: params,
|
||||
},
|
||||
SecretRequirements: reqs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package dynamicparameters
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/files"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
// EvaluateSecretMismatch reports whether the given template version
|
||||
// declares coder_secret requirements that the workspace owner's secrets
|
||||
// do not satisfy. Returns false (no mismatch) when the renderer cannot
|
||||
// authoritatively evaluate the requirements; the reason is logged at the
|
||||
// appropriate level so operators can distinguish a forbidden caller
|
||||
// (expected for template admins) from a genuine renderer or DB failure.
|
||||
// Returns ErrTemplateVersionNotReady when the version's provisioner job
|
||||
// has not yet completed; callers should treat that as "unknown" and
|
||||
// leave SecretMismatch false.
|
||||
func EvaluateSecretMismatch(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
db database.Store,
|
||||
cache files.FileAcquirer,
|
||||
version database.TemplateVersion,
|
||||
ownerID uuid.UUID,
|
||||
buildParams []database.WorkspaceBuildParameter,
|
||||
) (bool, error) {
|
||||
paramValues := slice.ToMapFunc(buildParams, func(p database.WorkspaceBuildParameter) (string, string) {
|
||||
return p.Name, p.Value
|
||||
})
|
||||
renderer, err := Prepare(ctx, db, cache, version.ID,
|
||||
WithTemplateVersion(version),
|
||||
WithLogger(logger))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer renderer.Close()
|
||||
|
||||
result, diags := renderer.Render(ctx, ownerID, paramValues, IncludeSecretRequirements())
|
||||
|
||||
// Three distinct "unknown" cases. Returning false from any of them
|
||||
// matches the resolve-autostart handler's semantics, but they have
|
||||
// very different operator implications, so we log accordingly. The
|
||||
// renderer already logs its own diagnostics through the same logger,
|
||||
// so we omit them here to avoid duplication.
|
||||
if result.Output == nil {
|
||||
logger.Warn(ctx,
|
||||
"secret requirement evaluation produced no preview output; treating as unknown",
|
||||
slog.F("template_version_id", version.ID),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
switch secretValidationBlockerCode(diags) {
|
||||
case DiagCodeOwnerSecretsFetchFailed:
|
||||
logger.Warn(ctx,
|
||||
"failed to fetch owner secrets during requirement evaluation; treating as unknown",
|
||||
slog.F("template_version_id", version.ID),
|
||||
)
|
||||
return false, nil
|
||||
case DiagCodeSecretValidationForbidden:
|
||||
// Expected when a caller without user_secret:read on the owner
|
||||
// hits the renderer, e.g. a template admin viewing another user's
|
||||
// workspace. Debug-level keeps production volume sane while
|
||||
// preserving visibility under trace logging.
|
||||
logger.Debug(ctx,
|
||||
"secret requirement evaluation forbidden for caller; treating as unknown",
|
||||
slog.F("template_version_id", version.ID),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(result.SecretRequirements,
|
||||
func(s codersdk.SecretRequirementStatus) bool { return !s.Satisfied }), nil
|
||||
}
|
||||
|
||||
// secretValidationBlockerCode returns the first diagnostic code among the
|
||||
// codes that indicate secret-requirement evaluation could not be
|
||||
// performed. Returns the empty string if no such diagnostic is present.
|
||||
//
|
||||
// ExtractDiagnosticExtra walks the wrapped-extra chain so we still
|
||||
// detect our marker when another extra has been chained on top by
|
||||
// preview's SetDiagnosticExtra.
|
||||
func secretValidationBlockerCode(diags hcl.Diagnostics) string {
|
||||
for _, d := range diags {
|
||||
extra := previewtypes.ExtractDiagnosticExtra(d)
|
||||
switch extra.Code {
|
||||
case DiagCodeOwnerSecretsFetchFailed, DiagCodeSecretValidationForbidden:
|
||||
return extra.Code
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package dynamicparameters
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
func TestSecretValidationBlockerCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in hcl.Diagnostics
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
in: hcl.Diagnostics{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "MissingSecretIsNotBlocking",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Missing required secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeMissingSecret,
|
||||
},
|
||||
}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Forbidden",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Cannot validate secret requirements",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeSecretValidationForbidden,
|
||||
},
|
||||
}},
|
||||
want: DiagCodeSecretValidationForbidden,
|
||||
},
|
||||
{
|
||||
name: "FetchFailed",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch owner secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
}},
|
||||
want: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
{
|
||||
name: "DiagnosticWithNoExtraIsIgnored",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Some other error",
|
||||
}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "MixedKeepsLookingUntilMatch",
|
||||
in: hcl.Diagnostics{
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Missing required secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeMissingSecret,
|
||||
},
|
||||
},
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch owner secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
{
|
||||
// SetDiagnosticExtra wraps any pre-existing extra into
|
||||
// previewtypes.DiagnosticExtra.Wrapped. ExtractDiagnosticExtra
|
||||
// walks that chain. A naive type assertion would miss it.
|
||||
name: "WrappedExtraIsDetected",
|
||||
in: func() hcl.Diagnostics {
|
||||
d := &hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Cannot validate secret requirements",
|
||||
Extra: "some other extra",
|
||||
}
|
||||
previewtypes.SetDiagnosticExtra(d, previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeSecretValidationForbidden,
|
||||
})
|
||||
return hcl.Diagnostics{d}
|
||||
}(),
|
||||
want: DiagCodeSecretValidationForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tc.want, secretValidationBlockerCode(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string, _ ...RenderOption) (*RenderResult, hcl.Diagnostics) {
|
||||
func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
params := r.staticParams
|
||||
for i := range params {
|
||||
param := ¶ms[i]
|
||||
@@ -52,10 +52,8 @@ func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]
|
||||
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
|
||||
}
|
||||
|
||||
return &RenderResult{
|
||||
Output: &preview.Output{
|
||||
Parameters: params,
|
||||
},
|
||||
return &preview.Output{
|
||||
Parameters: params,
|
||||
}, hcl.Diagnostics{
|
||||
{
|
||||
// Only a warning because the form does still work.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "use_github" {
|
||||
name = "use_github"
|
||||
type = "bool"
|
||||
default = "false"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
data "coder_secret" "gh" {
|
||||
count = data.coder_parameter.use_github.value == "true" ? 1 : 0
|
||||
env = "GITHUB_TOKEN"
|
||||
help_message = "Add a GitHub PAT"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_secret" "env_req" {
|
||||
env = "GITHUB_TOKEN"
|
||||
help_message = "needs env"
|
||||
}
|
||||
|
||||
data "coder_secret" "file_req" {
|
||||
file = "~/.ssh/id_rsa"
|
||||
help_message = "needs file"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_secret" "gh" {
|
||||
env = "GITHUB_TOKEN"
|
||||
help_message = "Add a GitHub PAT with env=GITHUB_TOKEN"
|
||||
}
|
||||
Reference in New Issue
Block a user