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
|
||||
}
|
||||
+32
-369
@@ -2,31 +2,18 @@ package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/files"
|
||||
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/preview"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
"github.com/coder/terraform-provider-coder/v2/provider"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@@ -81,292 +68,54 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter
|
||||
})(rw, r)
|
||||
}
|
||||
|
||||
// The `listen` control flag determines whether to open a websocket connection to
|
||||
// handle the request or not. This same function is used to 'evaluate' a template
|
||||
// as a single invocation, or to 'listen' for a back and forth interaction with
|
||||
// the user to update the form as they type.
|
||||
//
|
||||
//nolint:revive // listen is a control flag
|
||||
func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.DynamicParametersRequest) func(rw http.ResponseWriter, r *http.Request) {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
templateVersion := httpmw.TemplateVersionParam(r)
|
||||
|
||||
// Check that the job has completed successfully
|
||||
job, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
|
||||
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
|
||||
dynamicparameters.WithTemplateVersion(templateVersion),
|
||||
)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching provisioner job.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !job.CompletedAt.Valid {
|
||||
|
||||
if xerrors.Is(err, dynamicparameters.ErrTemplateVersionNotReady) {
|
||||
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
|
||||
Message: "Template version job has not finished",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tf, err := api.Database.GetTemplateVersionTerraformValues(ctx, templateVersion.ID)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to retrieve Terraform values for template version",
|
||||
Message: "Internal error fetching template version data.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer renderer.Close()
|
||||
|
||||
if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) {
|
||||
api.handleDynamicParameters(listen, rw, r, tf, templateVersion, initial)
|
||||
} else {
|
||||
api.handleStaticParameters(listen, rw, r, templateVersion.ID, initial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type previewFunction func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
|
||||
|
||||
// nolint:revive
|
||||
func (api *API) handleDynamicParameters(listen bool, rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion, initial codersdk.DynamicParametersRequest) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apikey = httpmw.APIKey(r)
|
||||
)
|
||||
|
||||
// nolint:gocritic // We need to fetch the templates files for the Terraform
|
||||
// evaluator, and the user likely does not have permission.
|
||||
fileCtx := dbauthz.AsFileReader(ctx)
|
||||
fileID, err := api.Database.GetFileIDByTemplateVersionID(fileCtx, templateVersion.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error finding template version Terraform.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Add the file first. Calling `Release` if it fails is a no-op, so this is safe.
|
||||
var templateFS fs.FS
|
||||
closeableTemplateFS, err := api.FileCache.Acquire(fileCtx, fileID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Internal error fetching template version Terraform.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer closeableTemplateFS.Close()
|
||||
// templateFS does not implement the Close method. For it to be later merged with
|
||||
// the module files, we need to convert it to an OverlayFS.
|
||||
templateFS = closeableTemplateFS
|
||||
|
||||
// Having the Terraform plan available for the evaluation engine is helpful
|
||||
// for populating values from data blocks, but isn't strictly required. If
|
||||
// we don't have a cached plan available, we just use an empty one instead.
|
||||
plan := json.RawMessage("{}")
|
||||
if len(tf.CachedPlan) > 0 {
|
||||
plan = tf.CachedPlan
|
||||
}
|
||||
|
||||
if tf.CachedModuleFiles.Valid {
|
||||
moduleFilesFS, err := api.FileCache.Acquire(fileCtx, tf.CachedModuleFiles.UUID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Internal error fetching Terraform modules.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer moduleFilesFS.Close()
|
||||
|
||||
templateFS = files.NewOverlayFS(closeableTemplateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
|
||||
}
|
||||
|
||||
owner, err := getWorkspaceOwnerData(ctx, api.Database, apikey.UserID, templateVersion.OrganizationID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace owner.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input := preview.Input{
|
||||
PlanJSON: plan,
|
||||
ParameterValues: map[string]string{},
|
||||
Owner: owner,
|
||||
}
|
||||
|
||||
// failedOwners keeps track of which owners failed to fetch from the database.
|
||||
// This prevents db spam on repeated requests for the same failed owner.
|
||||
failedOwners := make(map[uuid.UUID]error)
|
||||
failedOwnerDiag := hcl.Diagnostics{
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch workspace owner",
|
||||
Detail: "Please check your permissions or the user may not exist.",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: "owner_not_found",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dynamicRender := func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
if ownerID == uuid.Nil {
|
||||
// Default to the authenticated user
|
||||
// Nice for testing
|
||||
ownerID = apikey.UserID
|
||||
}
|
||||
|
||||
if _, ok := failedOwners[ownerID]; ok {
|
||||
// If it has failed once, assume it will fail always.
|
||||
// Re-open the websocket to try again.
|
||||
return nil, failedOwnerDiag
|
||||
}
|
||||
|
||||
// Update the input values with the new values.
|
||||
input.ParameterValues = values
|
||||
|
||||
// Update the owner if there is a change
|
||||
if input.Owner.ID != ownerID.String() {
|
||||
owner, err = getWorkspaceOwnerData(ctx, api.Database, ownerID, templateVersion.OrganizationID)
|
||||
if err != nil {
|
||||
failedOwners[ownerID] = err
|
||||
return nil, failedOwnerDiag
|
||||
}
|
||||
input.Owner = owner
|
||||
}
|
||||
|
||||
return preview.Preview(ctx, input, templateFS)
|
||||
}
|
||||
if listen {
|
||||
api.handleParameterWebsocket(rw, r, initial, dynamicRender)
|
||||
api.handleParameterWebsocket(rw, r, initial, renderer)
|
||||
} else {
|
||||
api.handleParameterEvaluate(rw, r, initial, dynamicRender)
|
||||
api.handleParameterEvaluate(rw, r, initial, renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nolint:revive
|
||||
func (api *API) handleStaticParameters(listen bool, rw http.ResponseWriter, r *http.Request, version uuid.UUID, initial codersdk.DynamicParametersRequest) {
|
||||
ctx := r.Context()
|
||||
dbTemplateVersionParameters, err := api.Database.GetTemplateVersionParameters(ctx, version)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to retrieve template version parameters",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
params := make([]previewtypes.Parameter, 0, len(dbTemplateVersionParameters))
|
||||
for _, it := range dbTemplateVersionParameters {
|
||||
param := previewtypes.Parameter{
|
||||
ParameterData: previewtypes.ParameterData{
|
||||
Name: it.Name,
|
||||
DisplayName: it.DisplayName,
|
||||
Description: it.Description,
|
||||
Type: previewtypes.ParameterType(it.Type),
|
||||
FormType: "", // ooooof
|
||||
Styling: previewtypes.ParameterStyling{},
|
||||
Mutable: it.Mutable,
|
||||
DefaultValue: previewtypes.StringLiteral(it.DefaultValue),
|
||||
Icon: it.Icon,
|
||||
Options: make([]*previewtypes.ParameterOption, 0),
|
||||
Validations: make([]*previewtypes.ParameterValidation, 0),
|
||||
Required: it.Required,
|
||||
Order: int64(it.DisplayOrder),
|
||||
Ephemeral: it.Ephemeral,
|
||||
Source: nil,
|
||||
},
|
||||
// Always use the default, since we used to assume the empty string
|
||||
Value: previewtypes.StringLiteral(it.DefaultValue),
|
||||
Diagnostics: nil,
|
||||
}
|
||||
|
||||
if it.ValidationError != "" || it.ValidationRegex != "" || it.ValidationMonotonic != "" {
|
||||
var reg *string
|
||||
if it.ValidationRegex != "" {
|
||||
reg = ptr.Ref(it.ValidationRegex)
|
||||
}
|
||||
|
||||
var vMin *int64
|
||||
if it.ValidationMin.Valid {
|
||||
vMin = ptr.Ref(int64(it.ValidationMin.Int32))
|
||||
}
|
||||
|
||||
var vMax *int64
|
||||
if it.ValidationMax.Valid {
|
||||
vMin = ptr.Ref(int64(it.ValidationMax.Int32))
|
||||
}
|
||||
|
||||
var monotonic *string
|
||||
if it.ValidationMonotonic != "" {
|
||||
monotonic = ptr.Ref(it.ValidationMonotonic)
|
||||
}
|
||||
|
||||
param.Validations = append(param.Validations, &previewtypes.ParameterValidation{
|
||||
Error: it.ValidationError,
|
||||
Regex: reg,
|
||||
Min: vMin,
|
||||
Max: vMax,
|
||||
Monotonic: monotonic,
|
||||
})
|
||||
}
|
||||
|
||||
var protoOptions []*sdkproto.RichParameterOption
|
||||
_ = json.Unmarshal(it.Options, &protoOptions) // Not going to make this fatal
|
||||
for _, opt := range protoOptions {
|
||||
param.Options = append(param.Options, &previewtypes.ParameterOption{
|
||||
Name: opt.Name,
|
||||
Description: opt.Description,
|
||||
Value: previewtypes.StringLiteral(opt.Value),
|
||||
Icon: opt.Icon,
|
||||
})
|
||||
}
|
||||
|
||||
// Take the form type from the ValidateFormType function. This is a bit
|
||||
// unfortunate we have to do this, but it will return the default form_type
|
||||
// for a given set of conditions.
|
||||
_, param.FormType, _ = provider.ValidateFormType(provider.OptionType(param.Type), len(param.Options), param.FormType)
|
||||
|
||||
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
|
||||
params = append(params, param)
|
||||
}
|
||||
|
||||
staticRender := func(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
for i := range params {
|
||||
param := ¶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
|
||||
}
|
||||
|
||||
+13
-24
@@ -203,11 +203,16 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
||||
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
||||
mainTF: dynamicParametersTerraformSource,
|
||||
modulesArchive: modulesArchive,
|
||||
expectWebsocketError: true,
|
||||
})
|
||||
// This is checked in setupDynamicParamsTest. Just doing this in the
|
||||
// test to make it obvious what this test is doing.
|
||||
require.Zero(t, setup.api.FileCache.Count())
|
||||
|
||||
stream := setup.stream
|
||||
previews := stream.Chan()
|
||||
|
||||
// Assert the failed owner
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
preview := testutil.RequireReceive(ctx, t, previews)
|
||||
require.Len(t, preview.Diagnostics, 1)
|
||||
require.Equal(t, preview.Diagnostics[0].Summary, "Failed to fetch workspace owner")
|
||||
})
|
||||
|
||||
t.Run("RebuildParameters", func(t *testing.T) {
|
||||
@@ -363,28 +368,12 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
files := echo.WithExtraFiles(map[string][]byte{
|
||||
"main.tf": args.mainTF,
|
||||
})
|
||||
files.ProvisionPlan = []*proto.Response{{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{
|
||||
tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
||||
MainTF: string(args.mainTF),
|
||||
Plan: args.plan,
|
||||
ModuleFiles: args.modulesArchive,
|
||||
Parameters: args.static,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
|
||||
tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
|
||||
|
||||
var err error
|
||||
tpl, err = templateAdmin.UpdateTemplateMeta(t.Context(), tpl.ID, codersdk.UpdateTemplateMeta{
|
||||
UseClassicParameterFlow: ptr.Ref(false),
|
||||
ModulesArchive: args.modulesArchive,
|
||||
StaticParams: args.static,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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