diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 183951a2ac..bec25890ff 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4bef3d5c83..356d14b6df 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 84fff375e0..c616db0ab3 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -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 && diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bbc09d3573..615c5aae86 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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}, }), diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 96ce142706..48572488d4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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() diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go index aa2fc04bd8..d6e3625c9e 100644 --- a/coderd/dynamicparameters/render.go +++ b/coderd/dynamicparameters/render.go @@ -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) } diff --git a/coderd/dynamicparameters/render_internal_test.go b/coderd/dynamicparameters/render_internal_test.go new file mode 100644 index 0000000000..9da5744ddf --- /dev/null +++ b/coderd/dynamicparameters/render_internal_test.go @@ -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) + } + } +} diff --git a/coderd/dynamicparameters/rendermock/rendermock.go b/coderd/dynamicparameters/rendermock/rendermock.go index 996b02a555..d23c6b4705 100644 --- a/coderd/dynamicparameters/rendermock/rendermock.go +++ b/coderd/dynamicparameters/rendermock/rendermock.go @@ -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...) } diff --git a/coderd/dynamicparameters/resolver.go b/coderd/dynamicparameters/resolver.go index b0a5a027c6..9220685931 100644 --- a/coderd/dynamicparameters/resolver.go +++ b/coderd/dynamicparameters/resolver.go @@ -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() +} diff --git a/coderd/dynamicparameters/resolver_internal_test.go b/coderd/dynamicparameters/resolver_internal_test.go new file mode 100644 index 0000000000..5979f10bc0 --- /dev/null +++ b/coderd/dynamicparameters/resolver_internal_test.go @@ -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)) + }) + } +} diff --git a/coderd/dynamicparameters/resolver_test.go b/coderd/dynamicparameters/resolver_test.go index 4f4785bb02..0084442b62 100644 --- a/coderd/dynamicparameters/resolver_test.go +++ b/coderd/dynamicparameters/resolver_test.go @@ -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, + } } diff --git a/coderd/dynamicparameters/static.go b/coderd/dynamicparameters/static.go index 46682d3378..025d817a52 100644 --- a/coderd/dynamicparameters/static.go +++ b/coderd/dynamicparameters/static.go @@ -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. diff --git a/coderd/dynamicparameters/testdata/secret_conditional/main.tf b/coderd/dynamicparameters/testdata/secret_conditional/main.tf new file mode 100644 index 0000000000..bcdc90ebed --- /dev/null +++ b/coderd/dynamicparameters/testdata/secret_conditional/main.tf @@ -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" +} diff --git a/coderd/dynamicparameters/testdata/secret_env_and_file/main.tf b/coderd/dynamicparameters/testdata/secret_env_and_file/main.tf new file mode 100644 index 0000000000..24ee85ade3 --- /dev/null +++ b/coderd/dynamicparameters/testdata/secret_env_and_file/main.tf @@ -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" +} diff --git a/coderd/dynamicparameters/testdata/secret_required/main.tf b/coderd/dynamicparameters/testdata/secret_required/main.tf new file mode 100644 index 0000000000..98434c5a26 --- /dev/null +++ b/coderd/dynamicparameters/testdata/secret_required/main.tf @@ -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" +} diff --git a/coderd/parameters.go b/coderd/parameters.go index 00a0e0369c..1ba928d4cb 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -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() diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 1229a61dc9..3473cc01e8 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -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 { diff --git a/coderd/testdata/parameters/secret_required/main.tf b/coderd/testdata/parameters/secret_required/main.tf new file mode 100644 index 0000000000..98434c5a26 --- /dev/null +++ b/coderd/testdata/parameters/secret_required/main.tf @@ -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" +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 3f7b48b9ec..6c794e3cdf 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -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 != "" { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f33af027ba..bba70502f5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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) diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index f935c3512c..ff8d6d623f 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -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 } diff --git a/coderd/wsbuilder/wsbuilder_internal_test.go b/coderd/wsbuilder/wsbuilder_internal_test.go new file mode 100644 index 0000000000..92f313b82b --- /dev/null +++ b/coderd/wsbuilder/wsbuilder_internal_test.go @@ -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() {} diff --git a/codersdk/parameters.go b/codersdk/parameters.go index 937fbe4005..ba1ac864e9 100644 --- a/codersdk/parameters.go +++ b/codersdk/parameters.go @@ -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 } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c7ef91a142..33a5b48440 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index c36709bb6f..1c319bb041 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -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 + } ] } ``` diff --git a/go.mod b/go.mod index 20987ae110..f4c5d57268 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7246ace75c..5374bc9af4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0b3913ab03..8ebdad635b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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.