mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+41
-378
@@ -2,31 +2,18 @@ package coderd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/hashicorp/hcl/v2"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
"golang.org/x/xerrors"
|
"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/db2sdk"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||||
"github.com/coder/coder/v2/coderd/files"
|
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
"github.com/coder/coder/v2/coderd/httpmw"
|
"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"
|
||||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
"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"
|
"github.com/coder/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,292 +68,54 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter
|
|||||||
})(rw, r)
|
})(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) {
|
func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.DynamicParametersRequest) func(rw http.ResponseWriter, r *http.Request) {
|
||||||
return func(rw http.ResponseWriter, r *http.Request) {
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
templateVersion := httpmw.TemplateVersionParam(r)
|
templateVersion := httpmw.TemplateVersionParam(r)
|
||||||
|
|
||||||
// Check that the job has completed successfully
|
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
|
||||||
job, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
|
dynamicparameters.WithTemplateVersion(templateVersion),
|
||||||
if httpapi.Is404Error(err) {
|
)
|
||||||
httpapi.ResourceNotFound(rw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
Message: "Internal error fetching provisioner job.",
|
Message: "Internal error fetching template version data.",
|
||||||
Detail: err.Error(),
|
Detail: err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !job.CompletedAt.Valid {
|
defer renderer.Close()
|
||||||
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 listen {
|
||||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
api.handleParameterWebsocket(rw, r, initial, renderer)
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
||||||
Message: "Failed to retrieve Terraform values for template version",
|
|
||||||
Detail: err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) {
|
|
||||||
api.handleDynamicParameters(listen, rw, r, tf, templateVersion, initial)
|
|
||||||
} else {
|
} else {
|
||||||
api.handleStaticParameters(listen, rw, r, templateVersion.ID, initial)
|
api.handleParameterEvaluate(rw, r, initial, renderer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type previewFunction func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
|
func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
|
||||||
|
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// Send an initial form state, computed without any user input.
|
// 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{
|
response := codersdk.DynamicParametersResponse{
|
||||||
ID: 0,
|
ID: 0,
|
||||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
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)
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||||||
defer cancel()
|
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.
|
// 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{
|
response := codersdk.DynamicParametersResponse{
|
||||||
ID: -1, // Always start with -1.
|
ID: -1, // Always start with -1.
|
||||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
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,
|
// As the user types into the form, reprocess the state using their input,
|
||||||
// and respond with updates.
|
// and respond with updates.
|
||||||
updates := stream.Chan()
|
updates := stream.Chan()
|
||||||
|
ownerID := initial.OwnerID
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -426,7 +176,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
|||||||
return
|
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{
|
response := codersdk.DynamicParametersResponse{
|
||||||
ID: update.ID,
|
ID: update.ID,
|
||||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
+14
-25
@@ -203,11 +203,16 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
|||||||
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
||||||
mainTF: dynamicParametersTerraformSource,
|
mainTF: dynamicParametersTerraformSource,
|
||||||
modulesArchive: modulesArchive,
|
modulesArchive: modulesArchive,
|
||||||
expectWebsocketError: true,
|
|
||||||
})
|
})
|
||||||
// This is checked in setupDynamicParamsTest. Just doing this in the
|
|
||||||
// test to make it obvious what this test is doing.
|
stream := setup.stream
|
||||||
require.Zero(t, setup.api.FileCache.Count())
|
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) {
|
t.Run("RebuildParameters", func(t *testing.T) {
|
||||||
@@ -363,28 +368,12 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
|
|||||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||||
|
|
||||||
files := echo.WithExtraFiles(map[string][]byte{
|
tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
||||||
"main.tf": args.mainTF,
|
MainTF: string(args.mainTF),
|
||||||
|
Plan: args.plan,
|
||||||
|
ModulesArchive: args.modulesArchive,
|
||||||
|
StaticParams: args.static,
|
||||||
})
|
})
|
||||||
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),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := testutil.Context(t, testutil.WaitShort)
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID)
|
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID)
|
||||||
|
|||||||
@@ -217,3 +217,16 @@ func CountConsecutive[T comparable](needle T, haystack ...T) int {
|
|||||||
|
|
||||||
return max(maxLength, curLength)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
|
|||||||
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
|
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)
|
_, noGroupUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||||
|
|
||||||
// Create the group to be asserted
|
// Create the group to be asserted
|
||||||
@@ -79,10 +79,10 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer stream.Close(websocket.StatusGoingAway)
|
defer stream.Close(websocket.StatusGoingAway)
|
||||||
|
|
||||||
previews := stream.Chan()
|
previews, pop := coderdtest.SynchronousStream(stream)
|
||||||
|
|
||||||
// Should automatically send a form state with all defaulted/empty values
|
// 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.Equal(t, -1, preview.ID)
|
||||||
require.Empty(t, preview.Diagnostics)
|
require.Empty(t, preview.Diagnostics)
|
||||||
require.Equal(t, "group", preview.Parameters[0].Name)
|
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)
|
require.Equal(t, database.EveryoneGroup, preview.Parameters[0].Value.Value)
|
||||||
|
|
||||||
// Send a new value, and see it reflected
|
// Send a new value, and see it reflected
|
||||||
err = stream.Send(codersdk.DynamicParametersRequest{
|
preview, err = previews(codersdk.DynamicParametersRequest{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Inputs: map[string]string{"group": group.Name},
|
Inputs: map[string]string{"group": group.Name},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
preview = testutil.RequireReceive(ctx, t, previews)
|
|
||||||
require.Equal(t, 1, preview.ID)
|
require.Equal(t, 1, preview.ID)
|
||||||
require.Empty(t, preview.Diagnostics)
|
require.Empty(t, preview.Diagnostics)
|
||||||
require.Equal(t, "group", preview.Parameters[0].Name)
|
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)
|
require.Equal(t, group.Name, preview.Parameters[0].Value.Value)
|
||||||
|
|
||||||
// Back to default
|
// Back to default
|
||||||
err = stream.Send(codersdk.DynamicParametersRequest{
|
preview, err = previews(codersdk.DynamicParametersRequest{
|
||||||
ID: 3,
|
ID: 3,
|
||||||
Inputs: map[string]string{},
|
Inputs: map[string]string{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
preview = testutil.RequireReceive(ctx, t, previews)
|
|
||||||
require.Equal(t, 3, preview.ID)
|
require.Equal(t, 3, preview.ID)
|
||||||
require.Empty(t, preview.Diagnostics)
|
require.Empty(t, preview.Diagnostics)
|
||||||
require.Equal(t, "group", preview.Parameters[0].Name)
|
require.Equal(t, "group", preview.Parameters[0].Name)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user