diff --git a/coderd/coderdtest/dynamicparameters.go b/coderd/coderdtest/dynamicparameters.go new file mode 100644 index 0000000000000..b5bb34a0e3468 --- /dev/null +++ b/coderd/coderdtest/dynamicparameters.go @@ -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 +} diff --git a/coderd/coderdtest/stream.go b/coderd/coderdtest/stream.go new file mode 100644 index 0000000000000..83bcce2ed29db --- /dev/null +++ b/coderd/coderdtest/stream.go @@ -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 + } +} diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go new file mode 100644 index 0000000000000..9c4c73f87e5bc --- /dev/null +++ b/coderd/dynamicparameters/render.go @@ -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 +} diff --git a/coderd/dynamicparameters/static.go b/coderd/dynamicparameters/static.go new file mode 100644 index 0000000000000..14988a2d162c0 --- /dev/null +++ b/coderd/dynamicparameters/static.go @@ -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 := ¶ms[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 +} diff --git a/coderd/parameters.go b/coderd/parameters.go index dacd8de812ab8..4b8b13486934f 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -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) - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } + renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID, + dynamicparameters.WithTemplateVersion(templateVersion), + ) 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 { - httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{ - Message: "Template version job has not finished", - }) - return - } + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + + 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) + if listen { + api.handleParameterWebsocket(rw, r, initial, renderer) } 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) - } else { - api.handleParameterEvaluate(rw, r, initial, dynamicRender) - } -} - -// 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, - }) + api.handleParameterEvaluate(rw, r, initial, renderer) } - - // 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 := ¶ms[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 -} diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 3c792c2ce9a7a..794ff8db3354d 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -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{ - 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), + tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(args.mainTF), + Plan: args.plan, + 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) diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index f3811650786b7..2a510e24d2b53 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -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 +} diff --git a/enterprise/coderd/dynamicparameters_test.go b/enterprise/coderd/dynamicparameters_test.go new file mode 100644 index 0000000000000..60d68fecd87d1 --- /dev/null +++ b/enterprise/coderd/dynamicparameters_test.go @@ -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") +} diff --git a/enterprise/coderd/parameters_test.go b/enterprise/coderd/parameters_test.go index 93f5057206527..bda9e3c59e021 100644 --- a/enterprise/coderd/parameters_test.go +++ b/enterprise/coderd/parameters_test.go @@ -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) diff --git a/enterprise/coderd/testdata/parameters/dynamic/main.tf b/enterprise/coderd/testdata/parameters/dynamic/main.tf new file mode 100644 index 0000000000000..615f57dc9c074 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/dynamic/main.tf @@ -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" +}