chore: refactor dynamic parameters into dedicated package (#18420)

This PR extracts dynamic parameter rendering logic from
coderd/parameters.go into a new coderd/dynamicparameters package. Partly
for organization and maintainability, but primarily to be reused in
`wsbuilder` to be leveraged as validation.
This commit is contained in:
Steven Masley
2025-06-20 13:00:39 -05:00
committed by GitHub
parent 72f7d70bab
commit 9b5d49967c
10 changed files with 942 additions and 410 deletions
+129
View File
@@ -0,0 +1,129 @@
package coderdtest
import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
)
type DynamicParameterTemplateParams struct {
MainTF string
Plan json.RawMessage
ModulesArchive []byte
// StaticParams is used if the provisioner daemon version does not support dynamic parameters.
StaticParams []*proto.RichParameter
}
func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UUID, args DynamicParameterTemplateParams) (codersdk.Template, codersdk.TemplateVersion) {
t.Helper()
files := echo.WithExtraFiles(map[string][]byte{
"main.tf": []byte(args.MainTF),
})
files.ProvisionPlan = []*proto.Response{{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Plan: args.Plan,
ModuleFiles: args.ModulesArchive,
Parameters: args.StaticParams,
},
},
}}
version := CreateTemplateVersion(t, client, org, files)
AwaitTemplateVersionJobCompleted(t, client, version.ID)
tpl := CreateTemplate(t, client, org, version.ID)
var err error
tpl, err = client.UpdateTemplateMeta(t.Context(), tpl.ID, codersdk.UpdateTemplateMeta{
UseClassicParameterFlow: ptr.Ref(false),
})
require.NoError(t, err)
return tpl, version
}
type ParameterAsserter struct {
Name string
Params []codersdk.PreviewParameter
t *testing.T
}
func AssertParameter(t *testing.T, name string, params []codersdk.PreviewParameter) *ParameterAsserter {
return &ParameterAsserter{
Name: name,
Params: params,
t: t,
}
}
func (a *ParameterAsserter) find(name string) *codersdk.PreviewParameter {
a.t.Helper()
for _, p := range a.Params {
if p.Name == name {
return &p
}
}
assert.Fail(a.t, "parameter not found", "expected parameter %q to exist", a.Name)
return nil
}
func (a *ParameterAsserter) NotExists() *ParameterAsserter {
a.t.Helper()
names := slice.Convert(a.Params, func(p codersdk.PreviewParameter) string {
return p.Name
})
assert.NotContains(a.t, names, a.Name)
return a
}
func (a *ParameterAsserter) Exists() *ParameterAsserter {
a.t.Helper()
names := slice.Convert(a.Params, func(p codersdk.PreviewParameter) string {
return p.Name
})
assert.Contains(a.t, names, a.Name)
return a
}
func (a *ParameterAsserter) Value(expected string) *ParameterAsserter {
a.t.Helper()
p := a.find(a.Name)
if p == nil {
return a
}
assert.Equal(a.t, expected, p.Value.Value)
return a
}
func (a *ParameterAsserter) Options(expected ...string) *ParameterAsserter {
a.t.Helper()
p := a.find(a.Name)
if p == nil {
return a
}
optValues := slice.Convert(p.Options, func(p codersdk.PreviewParameterOption) string {
return p.Value.Value
})
assert.ElementsMatch(a.t, expected, optValues, "parameter %q options", a.Name)
return a
}
+25
View File
@@ -0,0 +1,25 @@
package coderdtest
import "github.com/coder/coder/v2/codersdk/wsjson"
// SynchronousStream returns a function that assumes the stream is synchronous.
// Meaning each request sent assumes exactly one response will be received.
// The function will block until the response is received or an error occurs.
//
// This should not be used in production code, as it does not handle edge cases.
// The second function `pop` can be used to retrieve the next response from the
// stream without sending a new request. This is useful for dynamic parameters
func SynchronousStream[R any, W any](stream *wsjson.Stream[R, W]) (do func(W) (R, error), pop func() R) {
rec := stream.Chan()
return func(req W) (R, error) {
err := stream.Send(req)
if err != nil {
return *new(R), err
}
return <-rec, nil
}, func() R {
return <-rec
}
}
+340
View File
@@ -0,0 +1,340 @@
package dynamicparameters
import (
"context"
"io/fs"
"log/slog"
"sync"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"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/preview"
previewtypes "github.com/coder/preview/types"
"github.com/hashicorp/hcl/v2"
)
// 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`.
//
// '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)
Close()
}
var ErrTemplateVersionNotReady = xerrors.New("template version job not finished")
// 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
// cache of objects
templateVersion *database.TemplateVersion
job *database.ProvisionerJob
terraformValues *database.TemplateVersionTerraformValue
}
// Prepare is the entrypoint for this package. It loads the necessary objects &
// files from the database and returns a Renderer that can be used to render the
// template version's parameters.
func Prepare(ctx context.Context, db database.Store, cache *files.Cache, versionID uuid.UUID, options ...func(r *loader)) (Renderer, error) {
l := &loader{
templateVersionID: versionID,
}
for _, opt := range options {
opt(l)
}
return l.Renderer(ctx, db, cache)
}
func WithTemplateVersion(tv database.TemplateVersion) func(r *loader) {
return func(r *loader) {
if tv.ID == r.templateVersionID {
r.templateVersion = &tv
}
}
}
func WithProvisionerJob(job database.ProvisionerJob) func(r *loader) {
return func(r *loader) {
r.job = &job
}
}
func WithTerraformValues(values database.TemplateVersionTerraformValue) func(r *loader) {
return func(r *loader) {
if values.TemplateVersionID == r.templateVersionID {
r.terraformValues = &values
}
}
}
func (r *loader) loadData(ctx context.Context, db database.Store) error {
if r.templateVersion == nil {
tv, err := db.GetTemplateVersionByID(ctx, r.templateVersionID)
if err != nil {
return xerrors.Errorf("template version: %w", err)
}
r.templateVersion = &tv
}
if r.job == nil {
job, err := db.GetProvisionerJobByID(ctx, r.templateVersion.JobID)
if err != nil {
return xerrors.Errorf("provisioner job: %w", err)
}
r.job = &job
}
if !r.job.CompletedAt.Valid {
return ErrTemplateVersionNotReady
}
if r.terraformValues == nil {
values, err := db.GetTemplateVersionTerraformValues(ctx, r.templateVersion.ID)
if err != nil {
return xerrors.Errorf("template version terraform values: %w", err)
}
r.terraformValues = &values
}
return nil
}
// Renderer returns a Renderer that can be used to render the template version's
// parameters. It automatically determines whether to use a static or dynamic
// renderer based on the template version's state.
//
// Static parameter rendering is required to support older template versions that
// do not have the database state to support dynamic parameters. A constant
// warning will be displayed for these template versions.
func (r *loader) Renderer(ctx context.Context, db database.Store, cache *files.Cache) (Renderer, error) {
err := r.loadData(ctx, db)
if err != nil {
return nil, xerrors.Errorf("load data: %w", err)
}
if !ProvisionerVersionSupportsDynamicParameters(r.terraformValues.ProvisionerdVersion) {
return r.staticRender(ctx, db)
}
return r.dynamicRenderer(ctx, db, cache)
}
// Renderer caches all the necessary files when rendering a template version's
// parameters. It must be closed after use to release the cached files.
func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *files.Cache) (*dynamicRenderer, error) {
// If they can read the template version, then they can read the file for
// parameter loading purposes.
//nolint:gocritic
fileCtx := dbauthz.AsFileReader(ctx)
templateFS, err := cache.Acquire(fileCtx, r.job.FileID)
if err != nil {
return nil, xerrors.Errorf("acquire template file: %w", err)
}
var terraformFS fs.FS = templateFS
var moduleFilesFS *files.CloseFS
if r.terraformValues.CachedModuleFiles.Valid {
moduleFilesFS, err = cache.Acquire(fileCtx, r.terraformValues.CachedModuleFiles.UUID)
if err != nil {
templateFS.Close()
return nil, xerrors.Errorf("acquire module files: %w", err)
}
terraformFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
}
return &dynamicRenderer{
data: r,
templateFS: terraformFS,
db: db,
ownerErrors: make(map[uuid.UUID]error),
close: func() {
// Up to 2 files are cached, and must be released when rendering is complete.
// TODO: Might be smart to always call release when the context is
// canceled.
templateFS.Close()
if moduleFilesFS != nil {
moduleFilesFS.Close()
}
},
}, nil
}
type dynamicRenderer struct {
db database.Store
data *loader
templateFS fs.FS
ownerErrors map[uuid.UUID]error
currentOwner *previewtypes.WorkspaceOwner
once sync.Once
close func()
}
func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
// Always start with the cached error, if we have one.
ownerErr := r.ownerErrors[ownerID]
if ownerErr == nil {
ownerErr = r.getWorkspaceOwnerData(ctx, ownerID)
}
if ownerErr != nil || r.currentOwner == nil {
r.ownerErrors[ownerID] = ownerErr
return nil, hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Failed to fetch workspace owner",
Detail: "Please check your permissions or the user may not exist.",
Extra: previewtypes.DiagnosticExtra{
Code: "owner_not_found",
},
},
}
}
input := preview.Input{
PlanJSON: r.data.terraformValues.CachedPlan,
ParameterValues: values,
Owner: *r.currentOwner,
// 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),
}
return preview.Preview(ctx, input, r.templateFS)
}
func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error {
if r.currentOwner != nil && r.currentOwner.ID == ownerID.String() {
return nil // already fetched
}
var g errgroup.Group
// You only need to be able to read the organization member to get the owner
// data. Only the terraform files can therefore leak more information than the
// caller should have access to. All this info should be public assuming you can
// read the user though.
mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: r.data.templateVersion.OrganizationID,
UserID: ownerID,
IncludeSystem: false,
}))
if err != nil {
return err
}
// User data is required for the form. Org member is checked above
// nolint:gocritic
user, err := r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID)
if err != nil {
return xerrors.Errorf("fetch user: %w", err)
}
var ownerRoles []previewtypes.WorkspaceOwnerRBACRole
g.Go(func() error {
// nolint:gocritic // This is kind of the wrong query to use here, but it
// matches how the provisioner currently works. We should figure out
// something that needs less escalation but has the correct behavior.
row, err := r.db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID)
if err != nil {
return err
}
roles, err := row.RoleNames()
if err != nil {
return err
}
ownerRoles = make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
for _, it := range roles {
if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID {
continue
}
var orgID string
if it.OrganizationID != uuid.Nil {
orgID = it.OrganizationID.String()
}
ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{
Name: it.Name,
OrgID: orgID,
})
}
return nil
})
var publicKey string
g.Go(func() error {
// The correct public key has to be sent. This will not be leaked
// unless the template leaks it.
// nolint:gocritic
key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID)
if err != nil {
return err
}
publicKey = key.PublicKey
return nil
})
var groupNames []string
g.Go(func() error {
// The groups need to be sent to preview. These groups are not exposed to the
// user, unless the template does it through the parameters. Regardless, we need
// the correct groups, and a user might not have read access.
// nolint:gocritic
groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{
OrganizationID: r.data.templateVersion.OrganizationID,
HasMemberID: ownerID,
})
if err != nil {
return err
}
groupNames = make([]string, 0, len(groups))
for _, it := range groups {
groupNames = append(groupNames, it.Group.Name)
}
return nil
})
err = g.Wait()
if err != nil {
return err
}
r.currentOwner = &previewtypes.WorkspaceOwner{
ID: mem.OrganizationMember.UserID.String(),
Name: mem.Username,
FullName: mem.Name,
Email: mem.Email,
LoginType: string(user.LoginType),
RBACRoles: ownerRoles,
SSHPublicKey: publicKey,
Groups: groupNames,
}
return nil
}
func (r *dynamicRenderer) Close() {
r.once.Do(r.close)
}
func ProvisionerVersionSupportsDynamicParameters(version string) bool {
major, minor, err := apiversion.Parse(version)
// If the api version is not valid or less than 1.6, we need to use the static parameters
useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6)
return !useStaticParams
}
+143
View File
@@ -0,0 +1,143 @@
package dynamicparameters
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/util/ptr"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
"github.com/coder/terraform-provider-coder/v2/provider"
)
type staticRender struct {
staticParams []previewtypes.Parameter
}
func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRender, error) {
dbTemplateVersionParameters, err := db.GetTemplateVersionParameters(ctx, r.templateVersionID)
if err != nil {
return nil, xerrors.Errorf("template version parameters: %w", err)
}
params := db2sdk.List(dbTemplateVersionParameters, TemplateVersionParameter)
return &staticRender{
staticParams: params,
}, nil
}
func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
params := r.staticParams
for i := range params {
param := &params[i]
paramValue, ok := values[param.Name]
if ok {
param.Value = previewtypes.StringLiteral(paramValue)
} else {
param.Value = param.DefaultValue
}
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
}
return &preview.Output{
Parameters: params,
}, hcl.Diagnostics{
{
// Only a warning because the form does still work.
Severity: hcl.DiagWarning,
Summary: "This template version is missing required metadata to support dynamic parameters.",
Detail: "To restore full functionality, please re-import the terraform as a new template version.",
},
}
}
func (*staticRender) Close() {}
func TemplateVersionParameter(it database.TemplateVersionParameter) previewtypes.Parameter {
param := previewtypes.Parameter{
ParameterData: previewtypes.ParameterData{
Name: it.Name,
DisplayName: it.DisplayName,
Description: it.Description,
Type: previewtypes.ParameterType(it.Type),
FormType: provider.ParameterFormType(it.FormType),
Styling: previewtypes.ParameterStyling{},
Mutable: it.Mutable,
DefaultValue: previewtypes.StringLiteral(it.DefaultValue),
Icon: it.Icon,
Options: make([]*previewtypes.ParameterOption, 0),
Validations: make([]*previewtypes.ParameterValidation, 0),
Required: it.Required,
Order: int64(it.DisplayOrder),
Ephemeral: it.Ephemeral,
Source: nil,
},
// Always use the default, since we used to assume the empty string
Value: previewtypes.StringLiteral(it.DefaultValue),
Diagnostics: make(previewtypes.Diagnostics, 0),
}
if it.ValidationError != "" || it.ValidationRegex != "" || it.ValidationMonotonic != "" {
var reg *string
if it.ValidationRegex != "" {
reg = ptr.Ref(it.ValidationRegex)
}
var vMin *int64
if it.ValidationMin.Valid {
vMin = ptr.Ref(int64(it.ValidationMin.Int32))
}
var vMax *int64
if it.ValidationMax.Valid {
vMax = ptr.Ref(int64(it.ValidationMax.Int32))
}
var monotonic *string
if it.ValidationMonotonic != "" {
monotonic = ptr.Ref(it.ValidationMonotonic)
}
param.Validations = append(param.Validations, &previewtypes.ParameterValidation{
Error: it.ValidationError,
Regex: reg,
Min: vMin,
Max: vMax,
Monotonic: monotonic,
})
}
var protoOptions []*sdkproto.RichParameterOption
err := json.Unmarshal(it.Options, &protoOptions)
if err != nil {
param.Diagnostics = append(param.Diagnostics, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse json parameter options",
Detail: err.Error(),
})
}
for _, opt := range protoOptions {
param.Options = append(param.Options, &previewtypes.ParameterOption{
Name: opt.Name,
Description: opt.Description,
Value: previewtypes.StringLiteral(opt.Value),
Icon: opt.Icon,
})
}
// Take the form type from the ValidateFormType function. This is a bit
// unfortunate we have to do this, but it will return the default form_type
// for a given set of conditions.
_, param.FormType, _ = provider.ValidateFormType(provider.OptionType(param.Type), len(param.Options), param.FormType)
param.Diagnostics = append(param.Diagnostics, previewtypes.Diagnostics(param.Valid(param.Value))...)
return param
}
+32 -369
View File
@@ -2,31 +2,18 @@ package coderd
import (
"context"
"database/sql"
"encoding/json"
"io/fs"
"net/http"
"time"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
"github.com/coder/terraform-provider-coder/v2/provider"
"github.com/coder/websocket"
)
@@ -81,292 +68,54 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter
})(rw, r)
}
// The `listen` control flag determines whether to open a websocket connection to
// handle the request or not. This same function is used to 'evaluate' a template
// as a single invocation, or to 'listen' for a back and forth interaction with
// the user to update the form as they type.
//
//nolint:revive // listen is a control flag
func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.DynamicParametersRequest) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
templateVersion := httpmw.TemplateVersionParam(r)
// Check that the job has completed successfully
job, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
dynamicparameters.WithTemplateVersion(templateVersion),
)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
if !job.CompletedAt.Valid {
if xerrors.Is(err, dynamicparameters.ErrTemplateVersionNotReady) {
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
Message: "Template version job has not finished",
})
return
}
tf, err := api.Database.GetTemplateVersionTerraformValues(ctx, templateVersion.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to retrieve Terraform values for template version",
Message: "Internal error fetching template version data.",
Detail: err.Error(),
})
return
}
defer renderer.Close()
if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) {
api.handleDynamicParameters(listen, rw, r, tf, templateVersion, initial)
} else {
api.handleStaticParameters(listen, rw, r, templateVersion.ID, initial)
}
}
}
type previewFunction func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
// nolint:revive
func (api *API) handleDynamicParameters(listen bool, rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion, initial codersdk.DynamicParametersRequest) {
var (
ctx = r.Context()
apikey = httpmw.APIKey(r)
)
// nolint:gocritic // We need to fetch the templates files for the Terraform
// evaluator, and the user likely does not have permission.
fileCtx := dbauthz.AsFileReader(ctx)
fileID, err := api.Database.GetFileIDByTemplateVersionID(fileCtx, templateVersion.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error finding template version Terraform.",
Detail: err.Error(),
})
return
}
// Add the file first. Calling `Release` if it fails is a no-op, so this is safe.
var templateFS fs.FS
closeableTemplateFS, err := api.FileCache.Acquire(fileCtx, fileID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Internal error fetching template version Terraform.",
Detail: err.Error(),
})
return
}
defer closeableTemplateFS.Close()
// templateFS does not implement the Close method. For it to be later merged with
// the module files, we need to convert it to an OverlayFS.
templateFS = closeableTemplateFS
// Having the Terraform plan available for the evaluation engine is helpful
// for populating values from data blocks, but isn't strictly required. If
// we don't have a cached plan available, we just use an empty one instead.
plan := json.RawMessage("{}")
if len(tf.CachedPlan) > 0 {
plan = tf.CachedPlan
}
if tf.CachedModuleFiles.Valid {
moduleFilesFS, err := api.FileCache.Acquire(fileCtx, tf.CachedModuleFiles.UUID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Internal error fetching Terraform modules.",
Detail: err.Error(),
})
return
}
defer moduleFilesFS.Close()
templateFS = files.NewOverlayFS(closeableTemplateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
}
owner, err := getWorkspaceOwnerData(ctx, api.Database, apikey.UserID, templateVersion.OrganizationID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace owner.",
Detail: err.Error(),
})
return
}
input := preview.Input{
PlanJSON: plan,
ParameterValues: map[string]string{},
Owner: owner,
}
// failedOwners keeps track of which owners failed to fetch from the database.
// This prevents db spam on repeated requests for the same failed owner.
failedOwners := make(map[uuid.UUID]error)
failedOwnerDiag := hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Failed to fetch workspace owner",
Detail: "Please check your permissions or the user may not exist.",
Extra: previewtypes.DiagnosticExtra{
Code: "owner_not_found",
},
},
}
dynamicRender := func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
if ownerID == uuid.Nil {
// Default to the authenticated user
// Nice for testing
ownerID = apikey.UserID
}
if _, ok := failedOwners[ownerID]; ok {
// If it has failed once, assume it will fail always.
// Re-open the websocket to try again.
return nil, failedOwnerDiag
}
// Update the input values with the new values.
input.ParameterValues = values
// Update the owner if there is a change
if input.Owner.ID != ownerID.String() {
owner, err = getWorkspaceOwnerData(ctx, api.Database, ownerID, templateVersion.OrganizationID)
if err != nil {
failedOwners[ownerID] = err
return nil, failedOwnerDiag
}
input.Owner = owner
}
return preview.Preview(ctx, input, templateFS)
}
if listen {
api.handleParameterWebsocket(rw, r, initial, dynamicRender)
api.handleParameterWebsocket(rw, r, initial, renderer)
} else {
api.handleParameterEvaluate(rw, r, initial, dynamicRender)
api.handleParameterEvaluate(rw, r, initial, renderer)
}
}
}
// nolint:revive
func (api *API) handleStaticParameters(listen bool, rw http.ResponseWriter, r *http.Request, version uuid.UUID, initial codersdk.DynamicParametersRequest) {
ctx := r.Context()
dbTemplateVersionParameters, err := api.Database.GetTemplateVersionParameters(ctx, version)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to retrieve template version parameters",
Detail: err.Error(),
})
return
}
params := make([]previewtypes.Parameter, 0, len(dbTemplateVersionParameters))
for _, it := range dbTemplateVersionParameters {
param := previewtypes.Parameter{
ParameterData: previewtypes.ParameterData{
Name: it.Name,
DisplayName: it.DisplayName,
Description: it.Description,
Type: previewtypes.ParameterType(it.Type),
FormType: "", // ooooof
Styling: previewtypes.ParameterStyling{},
Mutable: it.Mutable,
DefaultValue: previewtypes.StringLiteral(it.DefaultValue),
Icon: it.Icon,
Options: make([]*previewtypes.ParameterOption, 0),
Validations: make([]*previewtypes.ParameterValidation, 0),
Required: it.Required,
Order: int64(it.DisplayOrder),
Ephemeral: it.Ephemeral,
Source: nil,
},
// Always use the default, since we used to assume the empty string
Value: previewtypes.StringLiteral(it.DefaultValue),
Diagnostics: nil,
}
if it.ValidationError != "" || it.ValidationRegex != "" || it.ValidationMonotonic != "" {
var reg *string
if it.ValidationRegex != "" {
reg = ptr.Ref(it.ValidationRegex)
}
var vMin *int64
if it.ValidationMin.Valid {
vMin = ptr.Ref(int64(it.ValidationMin.Int32))
}
var vMax *int64
if it.ValidationMax.Valid {
vMin = ptr.Ref(int64(it.ValidationMax.Int32))
}
var monotonic *string
if it.ValidationMonotonic != "" {
monotonic = ptr.Ref(it.ValidationMonotonic)
}
param.Validations = append(param.Validations, &previewtypes.ParameterValidation{
Error: it.ValidationError,
Regex: reg,
Min: vMin,
Max: vMax,
Monotonic: monotonic,
})
}
var protoOptions []*sdkproto.RichParameterOption
_ = json.Unmarshal(it.Options, &protoOptions) // Not going to make this fatal
for _, opt := range protoOptions {
param.Options = append(param.Options, &previewtypes.ParameterOption{
Name: opt.Name,
Description: opt.Description,
Value: previewtypes.StringLiteral(opt.Value),
Icon: opt.Icon,
})
}
// Take the form type from the ValidateFormType function. This is a bit
// unfortunate we have to do this, but it will return the default form_type
// for a given set of conditions.
_, param.FormType, _ = provider.ValidateFormType(provider.OptionType(param.Type), len(param.Options), param.FormType)
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
params = append(params, param)
}
staticRender := func(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
for i := range params {
param := &params[i]
paramValue, ok := values[param.Name]
if ok {
param.Value = previewtypes.StringLiteral(paramValue)
} else {
param.Value = param.DefaultValue
}
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
}
return &preview.Output{
Parameters: params,
}, hcl.Diagnostics{
{
// Only a warning because the form does still work.
Severity: hcl.DiagWarning,
Summary: "This template version is missing required metadata to support dynamic parameters.",
Detail: "To restore full functionality, please re-import the terraform as a new template version.",
},
}
}
if listen {
api.handleParameterWebsocket(rw, r, initial, staticRender)
} else {
api.handleParameterEvaluate(rw, r, initial, staticRender)
}
}
func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
ctx := r.Context()
// Send an initial form state, computed without any user input.
result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: 0,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
@@ -378,7 +127,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini
httpapi.Write(ctx, rw, http.StatusOK, response)
}
func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
@@ -398,7 +147,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
)
// Send an initial form state, computed without any user input.
result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: -1, // Always start with -1.
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
@@ -415,6 +164,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
// As the user types into the form, reprocess the state using their input,
// and respond with updates.
updates := stream.Chan()
ownerID := initial.OwnerID
for {
select {
case <-ctx.Done():
@@ -426,7 +176,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
return
}
result, diagnostics := render(ctx, update.OwnerID, update.Inputs)
// Take a nil uuid to mean the previous owner ID.
// This just removes the need to constantly send who you are.
if update.OwnerID == uuid.Nil {
update.OwnerID = ownerID
}
ownerID = update.OwnerID
result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs)
response := codersdk.DynamicParametersResponse{
ID: update.ID,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
@@ -442,98 +200,3 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
}
}
}
func getWorkspaceOwnerData(
ctx context.Context,
db database.Store,
ownerID uuid.UUID,
organizationID uuid.UUID,
) (previewtypes.WorkspaceOwner, error) {
var g errgroup.Group
// TODO: @emyrk we should only need read access on the org member, not the
// site wide user object. Figure out a better way to handle this.
user, err := db.GetUserByID(ctx, ownerID)
if err != nil {
return previewtypes.WorkspaceOwner{}, xerrors.Errorf("fetch user: %w", err)
}
var ownerRoles []previewtypes.WorkspaceOwnerRBACRole
g.Go(func() error {
// nolint:gocritic // This is kind of the wrong query to use here, but it
// matches how the provisioner currently works. We should figure out
// something that needs less escalation but has the correct behavior.
row, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), ownerID)
if err != nil {
return err
}
roles, err := row.RoleNames()
if err != nil {
return err
}
ownerRoles = make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
for _, it := range roles {
if it.OrganizationID != uuid.Nil && it.OrganizationID != organizationID {
continue
}
var orgID string
if it.OrganizationID != uuid.Nil {
orgID = it.OrganizationID.String()
}
ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{
Name: it.Name,
OrgID: orgID,
})
}
return nil
})
var publicKey string
g.Go(func() error {
// The correct public key has to be sent. This will not be leaked
// unless the template leaks it.
// nolint:gocritic
key, err := db.GetGitSSHKey(dbauthz.AsSystemRestricted(ctx), ownerID)
if err != nil {
return err
}
publicKey = key.PublicKey
return nil
})
var groupNames []string
g.Go(func() error {
// The groups need to be sent to preview. These groups are not exposed to the
// user, unless the template does it through the parameters. Regardless, we need
// the correct groups, and a user might not have read access.
// nolint:gocritic
groups, err := db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{
OrganizationID: organizationID,
HasMemberID: ownerID,
})
if err != nil {
return err
}
groupNames = make([]string, 0, len(groups))
for _, it := range groups {
groupNames = append(groupNames, it.Group.Name)
}
return nil
})
err = g.Wait()
if err != nil {
return previewtypes.WorkspaceOwner{}, err
}
return previewtypes.WorkspaceOwner{
ID: user.ID.String(),
Name: user.Username,
FullName: user.Name,
Email: user.Email,
LoginType: string(user.LoginType),
RBACRoles: ownerRoles,
SSHPublicKey: publicKey,
Groups: groupNames,
}, nil
}
+13 -24
View File
@@ -203,11 +203,16 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
modulesArchive: modulesArchive,
expectWebsocketError: true,
})
// This is checked in setupDynamicParamsTest. Just doing this in the
// test to make it obvious what this test is doing.
require.Zero(t, setup.api.FileCache.Count())
stream := setup.stream
previews := stream.Chan()
// Assert the failed owner
ctx := testutil.Context(t, testutil.WaitShort)
preview := testutil.RequireReceive(ctx, t, previews)
require.Len(t, preview.Diagnostics, 1)
require.Equal(t, preview.Diagnostics[0].Summary, "Failed to fetch workspace owner")
})
t.Run("RebuildParameters", func(t *testing.T) {
@@ -363,28 +368,12 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
owner := coderdtest.CreateFirstUser(t, ownerClient)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
files := echo.WithExtraFiles(map[string][]byte{
"main.tf": args.mainTF,
})
files.ProvisionPlan = []*proto.Response{{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: string(args.mainTF),
Plan: args.plan,
ModuleFiles: args.modulesArchive,
Parameters: args.static,
},
},
}}
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
var err error
tpl, err = templateAdmin.UpdateTemplateMeta(t.Context(), tpl.ID, codersdk.UpdateTemplateMeta{
UseClassicParameterFlow: ptr.Ref(false),
ModulesArchive: args.modulesArchive,
StaticParams: args.static,
})
require.NoError(t, err)
ctx := testutil.Context(t, testutil.WaitShort)
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID)
+13
View File
@@ -217,3 +217,16 @@ func CountConsecutive[T comparable](needle T, haystack ...T) int {
return max(maxLength, curLength)
}
// Convert converts a slice of type F to a slice of type T using the provided function f.
func Convert[F any, T any](a []F, f func(F) T) []T {
if a == nil {
return []T{}
}
tmp := make([]T, 0, len(a))
for _, v := range a {
tmp = append(tmp, f(v))
}
return tmp
}
+129
View File
@@ -0,0 +1,129 @@
package coderd_test
import (
_ "embed"
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
"github.com/coder/websocket"
)
// TestDynamicParameterTemplate uses a template with some dynamic elements, and
// tests the parameters, values, etc are all as expected.
func TestDynamicParameterTemplate(t *testing.T) {
t.Parallel()
owner, _, api, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
orgID := first.OrganizationID
_, userData := coderdtest.CreateAnotherUser(t, owner, orgID)
templateAdmin, templateAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID))
userAdmin, userAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgUserAdmin(orgID))
_, auditorData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgAuditor(orgID))
coderdtest.CreateGroup(t, owner, orgID, "developer", auditorData, userData)
coderdtest.CreateGroup(t, owner, orgID, "admin", templateAdminData, userAdminData)
coderdtest.CreateGroup(t, owner, orgID, "auditor", auditorData, templateAdminData, userAdminData)
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/dynamic/main.tf")
require.NoError(t, err)
_, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
MainTF: string(dynamicParametersTerraformSource),
Plan: nil,
ModulesArchive: nil,
StaticParams: nil,
})
_ = userAdmin
ctx := testutil.Context(t, testutil.WaitLong)
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, userData.ID.String(), version.ID)
require.NoError(t, err)
defer func() {
_ = stream.Close(websocket.StatusNormalClosure)
// Wait until the cache ends up empty. This verifies the cache does not
// leak any files.
require.Eventually(t, func() bool {
return api.AGPL.FileCache.Count() == 0
}, testutil.WaitShort, testutil.IntervalFast, "file cache should be empty after the test")
}()
// Initial response
preview, pop := coderdtest.SynchronousStream(stream)
init := pop()
require.Len(t, init.Diagnostics, 0, "no top level diags")
coderdtest.AssertParameter(t, "isAdmin", init.Parameters).
Exists().Value("false")
coderdtest.AssertParameter(t, "adminonly", init.Parameters).
NotExists()
coderdtest.AssertParameter(t, "groups", init.Parameters).
Exists().Options(database.EveryoneGroup, "developer")
// Switch to an admin
resp, err := preview(codersdk.DynamicParametersRequest{
ID: 1,
Inputs: map[string]string{
"colors": `["red"]`,
"thing": "apple",
},
OwnerID: userAdminData.ID,
})
require.NoError(t, err)
require.Equal(t, resp.ID, 1)
require.Len(t, resp.Diagnostics, 0, "no top level diags")
coderdtest.AssertParameter(t, "isAdmin", resp.Parameters).
Exists().Value("true")
coderdtest.AssertParameter(t, "adminonly", resp.Parameters).
Exists()
coderdtest.AssertParameter(t, "groups", resp.Parameters).
Exists().Options(database.EveryoneGroup, "admin", "auditor")
coderdtest.AssertParameter(t, "colors", resp.Parameters).
Exists().Value(`["red"]`)
coderdtest.AssertParameter(t, "thing", resp.Parameters).
Exists().Value("apple").Options("apple", "ruby")
coderdtest.AssertParameter(t, "cool", resp.Parameters).
NotExists()
// Try some other colors
resp, err = preview(codersdk.DynamicParametersRequest{
ID: 2,
Inputs: map[string]string{
"colors": `["yellow", "blue"]`,
"thing": "banana",
},
OwnerID: userAdminData.ID,
})
require.NoError(t, err)
require.Equal(t, resp.ID, 2)
require.Len(t, resp.Diagnostics, 0, "no top level diags")
coderdtest.AssertParameter(t, "cool", resp.Parameters).
Exists()
coderdtest.AssertParameter(t, "isAdmin", resp.Parameters).
Exists().Value("true")
coderdtest.AssertParameter(t, "colors", resp.Parameters).
Exists().Value(`["yellow", "blue"]`)
coderdtest.AssertParameter(t, "thing", resp.Parameters).
Exists().Value("banana").Options("banana", "ocean", "sky")
}
+5 -7
View File
@@ -31,7 +31,7 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
},
)
templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID))
_, noGroupUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// Create the group to be asserted
@@ -79,10 +79,10 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
require.NoError(t, err)
defer stream.Close(websocket.StatusGoingAway)
previews := stream.Chan()
previews, pop := coderdtest.SynchronousStream(stream)
// Should automatically send a form state with all defaulted/empty values
preview := testutil.RequireReceive(ctx, t, previews)
preview := pop()
require.Equal(t, -1, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
@@ -90,12 +90,11 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
require.Equal(t, database.EveryoneGroup, preview.Parameters[0].Value.Value)
// Send a new value, and see it reflected
err = stream.Send(codersdk.DynamicParametersRequest{
preview, err = previews(codersdk.DynamicParametersRequest{
ID: 1,
Inputs: map[string]string{"group": group.Name},
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 1, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
@@ -103,12 +102,11 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
require.Equal(t, group.Name, preview.Parameters[0].Value.Value)
// Back to default
err = stream.Send(codersdk.DynamicParametersRequest{
preview, err = previews(codersdk.DynamicParametersRequest{
ID: 3,
Inputs: map[string]string{},
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 3, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
+103
View File
@@ -0,0 +1,103 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = "2.5.3"
}
}
}
data "coder_workspace_owner" "me" {}
locals {
isAdmin = contains(data.coder_workspace_owner.me.groups, "admin")
}
data "coder_parameter" "isAdmin" {
name = "isAdmin"
type = "bool"
form_type = "switch"
default = local.isAdmin
order = 1
}
data "coder_parameter" "adminonly" {
count = local.isAdmin ? 1 : 0
name = "adminonly"
form_type = "input"
type = "string"
default = "I am an admin!"
order = 2
}
data "coder_parameter" "groups" {
name = "groups"
type = "list(string)"
form_type = "multi-select"
default = jsonencode([data.coder_workspace_owner.me.groups[0]])
order = 50
dynamic "option" {
for_each = data.coder_workspace_owner.me.groups
content {
name = option.value
value = option.value
}
}
}
locals {
colors = {
"red" : ["apple", "ruby"]
"yellow" : ["banana"]
"blue" : ["ocean", "sky"]
}
}
data "coder_parameter" "colors" {
name = "colors"
type = "list(string)"
form_type = "multi-select"
order = 100
dynamic "option" {
for_each = keys(local.colors)
content {
name = option.value
value = option.value
}
}
}
locals {
selected = jsondecode(data.coder_parameter.colors.value)
things = flatten([
for color in local.selected : local.colors[color]
])
}
data "coder_parameter" "thing" {
name = "thing"
type = "string"
form_type = "dropdown"
order = 101
dynamic "option" {
for_each = local.things
content {
name = option.value
value = option.value
}
}
}
// Cool people like blue. Idk what to tell you.
data "coder_parameter" "cool" {
count = contains(local.selected, "blue") ? 1 : 0
name = "cool"
type = "bool"
form_type = "switch"
order = 102
default = "true"
}