mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
committed by
GitHub
parent
fe6c8771df
commit
fb84e72319
Generated
+23
@@ -16199,6 +16199,12 @@ const docTemplate = `{
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PreviewParameter"
|
||||
}
|
||||
},
|
||||
"secret_requirements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.SecretRequirementStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19956,6 +19962,23 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.SecretRequirementStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"env": {
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"help_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"satisfied": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ServerSentEvent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+23
@@ -14663,6 +14663,12 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.PreviewParameter"
|
||||
}
|
||||
},
|
||||
"secret_requirements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.SecretRequirementStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -18288,6 +18294,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.SecretRequirementStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"env": {
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"help_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"satisfied": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ServerSentEvent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -343,6 +343,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
SetLastWorkspaceBuildJobInTx(&latestJob).
|
||||
Experiments(e.experiments).
|
||||
Reason(reason).
|
||||
Logger(log.Named("wsbuilder")).
|
||||
BuildMetrics(e.workspaceBuilderMetrics)
|
||||
log.Debug(e.ctx, "auto building workspace", slog.F("transition", nextTransition))
|
||||
if nextTransition == database.WorkspaceTransitionStart &&
|
||||
|
||||
@@ -270,6 +270,7 @@ var (
|
||||
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
rbac.ResourceUser.Type: {policy.ActionRead},
|
||||
rbac.ResourceUserSecret.Type: {policy.ActionRead},
|
||||
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
|
||||
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
|
||||
}),
|
||||
|
||||
@@ -6195,6 +6195,19 @@ func TestGetWorkspaceAgentByID_FastPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAsAutostart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := dbauthz.AsAutostart(context.Background())
|
||||
actor, ok := dbauthz.ActorFromContext(ctx)
|
||||
require.True(t, ok, "actor must be present")
|
||||
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
|
||||
err := auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceUserSecret.WithOwner(uuid.NewString()))
|
||||
require.NoError(t, err, "user secret metadata read should be allowed")
|
||||
}
|
||||
|
||||
func TestAsChatd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -13,14 +12,27 @@ 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`.
|
||||
@@ -28,17 +40,40 @@ import (
|
||||
// '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) (*preview.Output, hcl.Diagnostics)
|
||||
Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...RenderOption) (*RenderResult, 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
|
||||
@@ -90,6 +125,13 @@ 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)
|
||||
@@ -203,12 +245,14 @@ 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,
|
||||
ownerErrors: make(map[uuid.UUID]error),
|
||||
close: cache.Close,
|
||||
tfvarValues: tfVarValues,
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -216,16 +260,26 @@ type dynamicRenderer struct {
|
||||
db database.Store
|
||||
data *loader
|
||||
templateFS fs.FS
|
||||
logger slog.Logger
|
||||
|
||||
ownerErrors map[uuid.UUID]error
|
||||
currentOwner *previewtypes.WorkspaceOwner
|
||||
tfvarValues map[string]cty.Value
|
||||
|
||||
// ownerSecretErrors caches NotAuthorized denials per owner.
|
||||
ownerSecretErrors map[uuid.UUID]error
|
||||
|
||||
tfvarValues map[string]cty.Value
|
||||
|
||||
once sync.Once
|
||||
close func()
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
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)
|
||||
}
|
||||
|
||||
// Always start with the cached error, if we have one.
|
||||
ownerErr := r.ownerErrors[ownerID]
|
||||
if ownerErr == nil {
|
||||
@@ -234,7 +288,7 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values
|
||||
|
||||
if ownerErr != nil || r.currentOwner == nil {
|
||||
r.ownerErrors[ownerID] = ownerErr
|
||||
return nil, hcl.Diagnostics{
|
||||
return &RenderResult{}, hcl.Diagnostics{
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch workspace owner",
|
||||
@@ -251,13 +305,122 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values
|
||||
ParameterValues: values,
|
||||
Owner: *r.currentOwner,
|
||||
TFVars: r.tfvarValues,
|
||||
// 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),
|
||||
// Leave Logger nil so preview discards parser logs. Returning
|
||||
// those logs to callers would be useful, but they may be large.
|
||||
}
|
||||
|
||||
return preview.Preview(ctx, input, r.templateFS)
|
||||
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
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error {
|
||||
@@ -274,6 +437,23 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
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"
|
||||
|
||||
preview "github.com/coder/preview"
|
||||
dynamicparameters "github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||
uuid "github.com/google/uuid"
|
||||
hcl "github.com/hashicorp/hcl/v2"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
@@ -56,16 +56,21 @@ func (mr *MockRendererMockRecorder) Close() *gomock.Call {
|
||||
}
|
||||
|
||||
// Render mocks base method.
|
||||
func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...dynamicparameters.RenderOption) (*dynamicparameters.RenderResult, hcl.Diagnostics) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Render", ctx, ownerID, values)
|
||||
ret0, _ := ret[0].(*preview.Output)
|
||||
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)
|
||||
ret1, _ := ret[1].(hcl.Diagnostics)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Render indicates an expected call of Render.
|
||||
func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any) *gomock.Call {
|
||||
func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any, opts ...any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), ctx, ownerID, values)
|
||||
varargs := append([]any{ctx, ownerID, values}, opts...)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), varargs...)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
@@ -32,23 +33,37 @@ func TestResolveParameters(t *testing.T) {
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes().
|
||||
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,
|
||||
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)
|
||||
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)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
values, err := dynamicparameters.ResolveParameters(ctx, uuid.New(), render, false,
|
||||
[]database.WorkspaceBuildParameter{}, // No previous values
|
||||
@@ -81,29 +96,25 @@ func TestResolveParameters(t *testing.T) {
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
// Return the mutable param first
|
||||
Return(&preview.Output{
|
||||
Parameters: []previewtypes.Parameter{
|
||||
{
|
||||
ParameterData: mutable,
|
||||
Value: previewtypes.StringLiteral("foo"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
Return(renderResult(
|
||||
previewtypes.Parameter{
|
||||
ParameterData: mutable,
|
||||
Value: previewtypes.StringLiteral("foo"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
}, nil)
|
||||
), nil)
|
||||
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
// Then the immutable param
|
||||
Return(&preview.Output{
|
||||
Parameters: []previewtypes.Parameter{
|
||||
{
|
||||
ParameterData: immutable,
|
||||
// The user set the value to bar
|
||||
Value: previewtypes.StringLiteral("bar"),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
Return(renderResult(
|
||||
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,
|
||||
@@ -143,9 +154,9 @@ func TestResolveParameters(t *testing.T) {
|
||||
{name: "decreasing/decrease allowed", monotonic: "decreasing", prev: "10", cur: "5"},
|
||||
{name: "decreasing/same allowed", monotonic: "decreasing", prev: "5", cur: "5"},
|
||||
{name: "decreasing/increase rejected", monotonic: "decreasing", prev: "5", cur: "10", expectErr: "must be equal or lower than previous value"},
|
||||
// First build — not enforced
|
||||
// First build, not enforced
|
||||
{name: "increasing/first build", monotonic: "increasing", cur: "1", firstBuild: true},
|
||||
// No previous value — not enforced
|
||||
// No previous value, not enforced
|
||||
{name: "increasing/no previous", monotonic: "increasing", cur: "5"},
|
||||
}
|
||||
|
||||
@@ -159,23 +170,39 @@ func TestResolveParameters(t *testing.T) {
|
||||
render.EXPECT().
|
||||
Render(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes().
|
||||
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)},
|
||||
},
|
||||
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,
|
||||
},
|
||||
Value: previewtypes.StringLiteral(tc.cur),
|
||||
Diagnostics: nil,
|
||||
},
|
||||
}, 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)
|
||||
|
||||
var previousValues []database.WorkspaceBuildParameter
|
||||
if tc.prev != "" {
|
||||
@@ -205,4 +232,172 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) (*preview.Output, hcl.Diagnostics) {
|
||||
func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string, _ ...RenderOption) (*RenderResult, hcl.Diagnostics) {
|
||||
params := r.staticParams
|
||||
for i := range params {
|
||||
param := ¶ms[i]
|
||||
@@ -52,8 +52,10 @@ func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]
|
||||
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
|
||||
}
|
||||
|
||||
return &preview.Output{
|
||||
Parameters: params,
|
||||
return &RenderResult{
|
||||
Output: &preview.Output{
|
||||
Parameters: params,
|
||||
},
|
||||
}, hcl.Diagnostics{
|
||||
{
|
||||
// Only a warning because the form does still work.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_secret" "gh" {
|
||||
env = "GITHUB_TOKEN"
|
||||
help_message = "Add a GitHub PAT with env=GITHUB_TOKEN"
|
||||
}
|
||||
+13
-9
@@ -82,6 +82,7 @@ func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.D
|
||||
|
||||
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
|
||||
dynamicparameters.WithTemplateVersion(templateVersion),
|
||||
dynamicparameters.WithLogger(api.Logger.Named("dynamicparameters")),
|
||||
)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
@@ -116,14 +117,15 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini
|
||||
ctx := r.Context()
|
||||
|
||||
// Send an initial form state, computed without any user input.
|
||||
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
|
||||
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements())
|
||||
response := codersdk.DynamicParametersResponse{
|
||||
ID: 0,
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
}
|
||||
if result != nil {
|
||||
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
if result.Output != nil {
|
||||
response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter)
|
||||
}
|
||||
response.SecretRequirements = result.SecretRequirements
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||||
}
|
||||
@@ -150,14 +152,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
)
|
||||
|
||||
// Send an initial form state, computed without any user input.
|
||||
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
|
||||
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements())
|
||||
response := codersdk.DynamicParametersResponse{
|
||||
ID: -1, // Always start with -1.
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
}
|
||||
if result != nil {
|
||||
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
if result.Output != nil {
|
||||
response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter)
|
||||
}
|
||||
response.SecretRequirements = result.SecretRequirements
|
||||
err = stream.Send(response)
|
||||
if err != nil {
|
||||
stream.Drop()
|
||||
@@ -187,14 +190,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
|
||||
ownerID = update.OwnerID
|
||||
|
||||
result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs)
|
||||
result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs, dynamicparameters.IncludeSecretRequirements())
|
||||
response := codersdk.DynamicParametersResponse{
|
||||
ID: update.ID,
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
}
|
||||
if result != nil {
|
||||
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
if result.Output != nil {
|
||||
response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter)
|
||||
}
|
||||
response.SecretRequirements = result.SecretRequirements
|
||||
err = stream.Send(response)
|
||||
if err != nil {
|
||||
stream.Drop()
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
@@ -386,6 +387,89 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
||||
coderdtest.AssertParameter(t, "variable_values", preview.Parameters).
|
||||
Exists().Value("austin")
|
||||
})
|
||||
|
||||
t.Run("MissingSecret", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf")
|
||||
require.NoError(t, err)
|
||||
|
||||
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
||||
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
||||
mainTF: dynamicParametersTerraformSource,
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
previews := setup.stream.Chan()
|
||||
|
||||
preview := testutil.RequireReceive(ctx, t, previews)
|
||||
require.Equal(t, -1, preview.ID)
|
||||
for _, diag := range preview.Diagnostics {
|
||||
require.NotEqual(t, dynamicparameters.DiagCodeMissingSecret, diag.Extra.Code)
|
||||
}
|
||||
require.Equal(t, []codersdk.SecretRequirementStatus{{
|
||||
Env: "GITHUB_TOKEN",
|
||||
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
|
||||
Satisfied: false,
|
||||
}}, preview.SecretRequirements)
|
||||
})
|
||||
|
||||
// Regression test for PLAT-100: a workspace whose template has an
|
||||
// unsatisfied coder_secret requirement must still be stoppable and
|
||||
// deletable. Start remains blocked.
|
||||
t.Run("SecretRequirementDoesNotBlockStopOrDelete", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf")
|
||||
require.NoError(t, err)
|
||||
|
||||
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
||||
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
||||
mainTF: dynamicParametersTerraformSource,
|
||||
})
|
||||
_ = setup.stream.Close(websocket.StatusGoingAway)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Owner must satisfy the coder_secret requirement to create
|
||||
// the workspace; delete it later to provoke the bug scenario.
|
||||
_, err = setup.client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "github-token",
|
||||
Value: "ghp_test",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
|
||||
|
||||
require.NoError(t, setup.client.DeleteUserSecret(ctx, codersdk.Me, "github-token"))
|
||||
|
||||
// Start on the now-unsatisfied requirement must still fail;
|
||||
// otherwise we've over-filtered the diagnostic.
|
||||
_, err = setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
})
|
||||
require.Error(t, err, "start must still reject unsatisfied secret requirement")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Contains(t, sdkErr.Detail, "Missing required secrets")
|
||||
require.Contains(t, sdkErr.Detail, "env GITHUB_TOKEN")
|
||||
|
||||
// Stop must succeed despite the unsatisfied requirement.
|
||||
stop, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, stop.ID)
|
||||
|
||||
// Delete must succeed despite the unsatisfied requirement.
|
||||
del, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, del.ID)
|
||||
})
|
||||
}
|
||||
|
||||
type setupDynamicParamsTestParams struct {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_secret" "gh" {
|
||||
env = "GITHUB_TOKEN"
|
||||
help_message = "Add a GitHub PAT with env=GITHUB_TOKEN"
|
||||
}
|
||||
@@ -383,6 +383,7 @@ func (api *API) postWorkspaceBuildsInternal(
|
||||
DeploymentValues(api.Options.DeploymentValues).
|
||||
Experiments(api.Experiments).
|
||||
TemplateVersionPresetID(createBuild.TemplateVersionPresetID).
|
||||
Logger(api.Logger.Named("wsbuilder")).
|
||||
BuildMetrics(api.WorkspaceBuilderMetrics)
|
||||
|
||||
if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
|
||||
|
||||
@@ -794,6 +794,7 @@ func createWorkspace(
|
||||
Experiments(api.Experiments).
|
||||
DeploymentValues(api.DeploymentValues).
|
||||
RichParameterValues(req.RichParameterValues).
|
||||
Logger(api.Logger.Named("wsbuilder")).
|
||||
BuildMetrics(api.WorkspaceBuilderMetrics)
|
||||
if req.TemplateVersionID != uuid.Nil {
|
||||
builder = builder.VersionID(req.TemplateVersionID)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
@@ -59,6 +60,7 @@ type Builder struct {
|
||||
deploymentValues *codersdk.DeploymentValues
|
||||
experiments codersdk.Experiments
|
||||
usageChecker UsageChecker
|
||||
logger slog.Logger
|
||||
|
||||
richParameterValues []codersdk.WorkspaceBuildParameter
|
||||
initiator uuid.UUID
|
||||
@@ -195,6 +197,12 @@ func (b Builder) Experiments(exp codersdk.Experiments) Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
func (b Builder) Logger(log slog.Logger) Builder {
|
||||
// nolint: revive
|
||||
b.logger = log
|
||||
return b
|
||||
}
|
||||
|
||||
func (b Builder) Initiator(u uuid.UUID) Builder {
|
||||
// nolint: revive
|
||||
b.initiator = u
|
||||
@@ -759,6 +767,7 @@ func (b *Builder) getDynamicParameterRenderer() (dynamicparameters.Renderer, err
|
||||
dynamicparameters.WithProvisionerJob(*job),
|
||||
dynamicparameters.WithTerraformValues(*tfVals),
|
||||
dynamicparameters.WithTemplateVariableValues(variableValues),
|
||||
dynamicparameters.WithLogger(b.logger.Named("dynamicparameters")),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get template version renderer: %w", err)
|
||||
@@ -884,10 +893,16 @@ func (b *Builder) getDynamicParameters() (names, values []string, err error) {
|
||||
return nil, nil, BuildError{http.StatusInternalServerError, "failed to check if first build", err}
|
||||
}
|
||||
|
||||
// Don't let missing secrets block stop or delete.
|
||||
var resolveOpts []dynamicparameters.ResolveOption
|
||||
if b.trans != database.WorkspaceTransitionStart {
|
||||
resolveOpts = append(resolveOpts, dynamicparameters.SkipSecretRequirements())
|
||||
}
|
||||
buildValues, err := dynamicparameters.ResolveParameters(b.ctx, b.workspace.OwnerID, render, firstBuild,
|
||||
lastBuildParameters,
|
||||
b.richParameterValues,
|
||||
presetParameterValues)
|
||||
presetParameterValues,
|
||||
resolveOpts...)
|
||||
if err != nil {
|
||||
return nil, nil, BuildError{http.StatusBadRequest, "resolve parameters", err}
|
||||
}
|
||||
@@ -1123,13 +1138,13 @@ func (b *Builder) getDynamicProvisionerTags() (map[string]string, error) {
|
||||
vals[name] = values[i]
|
||||
}
|
||||
|
||||
output, diags := render.Render(b.ctx, b.workspace.OwnerID, vals)
|
||||
tagErr := dynamicparameters.CheckTags(output, diags)
|
||||
result, diags := render.Render(b.ctx, b.workspace.OwnerID, vals)
|
||||
tagErr := dynamicparameters.CheckTags(result.Output, diags)
|
||||
if tagErr != nil {
|
||||
return nil, BuildError{http.StatusBadRequest, "workspace tags validation failed", tagErr}
|
||||
}
|
||||
|
||||
for k, v := range output.WorkspaceTags.Tags() {
|
||||
for k, v := range result.Output.WorkspaceTags.Tags() {
|
||||
tags[k] = v
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package wsbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||
"github.com/coder/coder/v2/provisionersdk"
|
||||
"github.com/coder/preview"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
func TestBuilderDynamicProvisionerTagsDoesNotRequestSecretRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerID := uuid.New()
|
||||
names := []string{"region"}
|
||||
values := []string{"us-east"}
|
||||
|
||||
render := &tagsPathRenderer{
|
||||
result: &dynamicparameters.RenderResult{
|
||||
Output: &preview.Output{
|
||||
WorkspaceTags: previewtypes.TagBlocks{{
|
||||
Tags: previewtypes.Tags{{
|
||||
Key: previewtypes.StringLiteral("region"),
|
||||
Value: previewtypes.StringLiteral("us-east"),
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
builder := New(database.Workspace{
|
||||
ID: uuid.New(),
|
||||
OwnerID: ownerID,
|
||||
}, database.WorkspaceTransitionStart, NoopUsageChecker{})
|
||||
builder.ctx = t.Context()
|
||||
builder.parameterRender = render
|
||||
builder.parameterNames = &names
|
||||
builder.parameterValues = &values
|
||||
builder.templateVersionJob = &database.ProvisionerJob{
|
||||
Tags: database.StringMap{
|
||||
provisionersdk.TagScope: provisionersdk.ScopeUser,
|
||||
},
|
||||
}
|
||||
|
||||
tags, err := builder.getDynamicProvisionerTags()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "us-east", tags["region"])
|
||||
require.Equal(t, ownerID.String(), tags[provisionersdk.TagOwner])
|
||||
require.Empty(t, render.opts, "tags path should not request secret requirements")
|
||||
}
|
||||
|
||||
type tagsPathRenderer struct {
|
||||
result *dynamicparameters.RenderResult
|
||||
diags hcl.Diagnostics
|
||||
opts []dynamicparameters.RenderOption
|
||||
}
|
||||
|
||||
func (r *tagsPathRenderer) Render(_ context.Context, _ uuid.UUID, _ map[string]string, opts ...dynamicparameters.RenderOption) (*dynamicparameters.RenderResult, hcl.Diagnostics) {
|
||||
r.opts = opts
|
||||
return r.result, r.diags
|
||||
}
|
||||
|
||||
func (*tagsPathRenderer) Close() {}
|
||||
+11
-3
@@ -170,10 +170,18 @@ type DynamicParametersRequest struct {
|
||||
OwnerID uuid.UUID `json:"owner_id,omitempty" format:"uuid"`
|
||||
}
|
||||
|
||||
type SecretRequirementStatus struct {
|
||||
Env string `json:"env,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
HelpMessage string `json:"help_message"`
|
||||
Satisfied bool `json:"satisfied"`
|
||||
}
|
||||
|
||||
type DynamicParametersResponse struct {
|
||||
ID int `json:"id"`
|
||||
Diagnostics []FriendlyDiagnostic `json:"diagnostics"`
|
||||
Parameters []PreviewParameter `json:"parameters"`
|
||||
ID int `json:"id"`
|
||||
Diagnostics []FriendlyDiagnostic `json:"diagnostics"`
|
||||
Parameters []PreviewParameter `json:"parameters"`
|
||||
SecretRequirements []SecretRequirementStatus `json:"secret_requirements,omitempty"`
|
||||
// TODO: Workspace tags
|
||||
}
|
||||
|
||||
|
||||
Generated
+34
-5
@@ -4557,17 +4557,26 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"value": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"secret_requirements": [
|
||||
{
|
||||
"env": "string",
|
||||
"file": "string",
|
||||
"help_message": "string",
|
||||
"satisfied": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|---------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `diagnostics` | array of [codersdk.FriendlyDiagnostic](#codersdkfriendlydiagnostic) | false | | |
|
||||
| `id` | integer | false | | |
|
||||
| `parameters` | array of [codersdk.PreviewParameter](#codersdkpreviewparameter) | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------------|-------------------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `diagnostics` | array of [codersdk.FriendlyDiagnostic](#codersdkfriendlydiagnostic) | false | | |
|
||||
| `id` | integer | false | | |
|
||||
| `parameters` | array of [codersdk.PreviewParameter](#codersdkpreviewparameter) | false | | |
|
||||
| `secret_requirements` | array of [codersdk.SecretRequirementStatus](#codersdksecretrequirementstatus) | false | | |
|
||||
|
||||
## codersdk.Entitlement
|
||||
|
||||
@@ -8885,6 +8894,26 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
| `ssh_config_options` | object | false | | |
|
||||
| » `[any property]` | string | false | | |
|
||||
|
||||
## codersdk.SecretRequirementStatus
|
||||
|
||||
```json
|
||||
{
|
||||
"env": "string",
|
||||
"file": "string",
|
||||
"help_message": "string",
|
||||
"satisfied": true
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------|---------|----------|--------------|-------------|
|
||||
| `env` | string | false | | |
|
||||
| `file` | string | false | | |
|
||||
| `help_message` | string | false | | |
|
||||
| `satisfied` | boolean | false | | |
|
||||
|
||||
## codersdk.ServerSentEvent
|
||||
|
||||
```json
|
||||
|
||||
Generated
+8
@@ -2802,6 +2802,14 @@ curl -X POST http://coder-server:8080/api/v2/templateversions/{templateversion}/
|
||||
"value": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"secret_requirements": [
|
||||
{
|
||||
"env": "string",
|
||||
"file": "string",
|
||||
"help_message": "string",
|
||||
"satisfied": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -504,7 +504,7 @@ require (
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||
github.com/coder/aisdk-go v0.0.9
|
||||
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab
|
||||
github.com/coder/preview v1.0.8
|
||||
github.com/coder/preview v1.0.9
|
||||
github.com/danieljoos/wincred v1.2.3
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0
|
||||
github.com/elazarl/goproxy v1.8.0
|
||||
|
||||
@@ -338,8 +338,8 @@ github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7
|
||||
github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
|
||||
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
|
||||
github.com/coder/preview v1.0.8 h1:RqejfDTplczgSiNqsrQTH7g2qV0p5FGZHTkc/psWZfM=
|
||||
github.com/coder/preview v1.0.8/go.mod h1:BvAfITWREXP08NIOasaAJ2hi2TWFWc6Y0CSPKEPsMzk=
|
||||
github.com/coder/preview v1.0.9 h1:SmEGRBAKN+TBn8BMlfKqLqD34m/CXYnmJfiUZTxu5EA=
|
||||
github.com/coder/preview v1.0.9/go.mod h1:3+ponddy+zyv07w6mU3QPaSiAQQ06l8i2aHbWBvpJhU=
|
||||
github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg=
|
||||
github.com/coder/quartz v0.3.0/go.mod h1:BgE7DOj/8NfvRgvKw0jPLDQH/2Lya2kxcTaNJ8X0rZk=
|
||||
github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc=
|
||||
|
||||
Generated
+9
@@ -3625,6 +3625,7 @@ export interface DynamicParametersResponse {
|
||||
readonly id: number;
|
||||
readonly diagnostics: readonly FriendlyDiagnostic[];
|
||||
readonly parameters: readonly PreviewParameter[];
|
||||
readonly secret_requirements?: readonly SecretRequirementStatus[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
@@ -6615,6 +6616,14 @@ export interface STUNReport {
|
||||
readonly Error: string | null;
|
||||
}
|
||||
|
||||
// From codersdk/parameters.go
|
||||
export interface SecretRequirementStatus {
|
||||
readonly env?: string;
|
||||
readonly file?: string;
|
||||
readonly help_message: string;
|
||||
readonly satisfied: boolean;
|
||||
}
|
||||
|
||||
// From serpent/serpent.go
|
||||
/**
|
||||
* Annotations is an arbitrary key-mapping used to extend the Option and Command types.
|
||||
|
||||
Reference in New Issue
Block a user