10000 chore: refactor dynamic parameters into dedicated package by Emyrk · Pull Request #18420 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

chore: refactor dynamic parameters into dedicated package #18420

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jun 20, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor and move dynamic param rendering
  • Loading branch information
Emyrk committed Jun 18, 2025
commit d4dab77f7d80e3e94f56add3c99a7e29bf92ec8b
201 changes: 196 additions & 5 deletions coderd/dynamicparameters/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,37 @@ package dynamicparameters

import (
"context"
"encoding/json"
"io/fs"
"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"
)

type Renderer interface {
Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
Close()
}

var (
ErrorTemplateVersionNotReady error = 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. Closing the
// Renderer will release the cached files.
type Loader struct {
templateVersionID uuid.UUID

Expand All @@ -24,7 +42,7 @@ type Loader struct {
terraformValues *database.TemplateVersionTerraformValue
}

func New(ctx context.Context, versionID uuid.UUID) *Loader {
func New(versionID uuid.UUID) *Loader {
return &Loader{
templateVersionID: versionID,
}
Expand Down Expand Up @@ -70,7 +88,7 @@ func (r *Loader) Load(ctx context.Context, db database.Store) error {
}

if !r.job.CompletedAt.Valid {
return xerrors.Errorf("job has not completed")
return ErrorTemplateVersionNotReady
}

if r.terraformValues == nil {
Expand All @@ -88,23 +106,196 @@ func (r *Loader) loaded() bool {
return r.templateVersion != nil && r.job != nil && r.terraformValues != nil
}

func (r *Loader) Renderer(ctx context.Context, cache *files.Cache) (any, error) {
func (r *Loader) Renderer(ctx context.Context, db database.Store, cache *files.Cache) (Renderer, error) {
if !r.loaded() {
return nil, xerrors.New("Load() must be called before Renderer()")
}

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.
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 moduleFilesFS fs.FS
if r.terraformValues.CachedModuleFiles.Valid {
moduleFilesFS, err = cache.Acquire(fileCtx, r.terraformValues.CachedModuleFiles.UUID)
if err != nil {
cache.Release(r.job.FileID)
return nil, xerrors.Errorf("acquire module files: %w", err)
}
templateFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
}

plan := json.RawMessage("{}")
if len(r.terraformValues.CachedPlan) > 0 {
plan = r.terraformValues.CachedPlan
}

return &DynamicRenderer{
data: r,
templateFS: templateFS,
db: db,
plan: plan,
close: func() {
cache.Release(r.job.FileID)
if moduleFilesFS != nil {
cache.Release(r.terraformValues.CachedModuleFiles.UUID)
}
},
}, nil
}

func (r *Loader) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
type DynamicRenderer struct {
db database.Store
data *Loader
templateFS fs.FS
plan json.RawMessage

failedOwners 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) {
err := r.getWorkspaceOwnerData(ctx, ownerID)
if err != nil || r.currentOwner == nil {
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: map[string]string{},
Owner: *r.currentOwner,
}

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
}

if r.failedOwners[ownerID] != nil {
// previously failed, do not try again
return r.failedOwners[ownerID]
}

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 := r.db.GetUserByID(ctx, ownerID)
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.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 != 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.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 := r.db.GetGroups(dbauthz.AsSystemRestricted(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: user.ID.String(),
Name: user.Username,
FullName: user.Name,
Email: user.Email,
LoginType: string(user.LoginType),
RBACRoles: ownerRoles,
SSHPublicKey: publicKey,
Groups: groupNames,
}
return nil
}

return nil, nil
func (r *DynamicRenderer) Close() {
r.once.Do(r.close)
}

func ProvisionerVersionSupportsDynamicParameters(version string) bool {
Expand Down
134 changes: 134 additions & 0 deletions coderd/dynamicparameters/static.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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/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 := 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: 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: 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going to comment this, can we document why we ignore this error, so that future contributors know when it may or may not be useful to reassess this? Is it simply because we're pretty sure this will be valid json since it comes from the DB? I'd like to avoid Chesterton's Fence.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I can throw this error on the parameter now as a diagnostic. I'm going to toss the error in there. 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it as a diagnostic on the param 👍

db612b3

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)
}

return &StaticRender{
staticParams: params,
}, nil
}

func (r *StaticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
params := r.staticParams
for i := range params {
param := &params[i]
paramValue, ok := values[param.Name]
if ok {
param.Value = previewtypes.StringLiteral(paramValue)
} else {
param.Value = param.DefaultValue
}
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
}

return &preview.Output{
Parameters: params,
}, hcl.Diagnostics{
{
// Only a warning because the form does still work.
Severity: hcl.DiagWarning,
Summary: "This template version is missing required metadata to support dynamic parameters.",
Detail: "To restore full functionality, please re-import the terraform as a new template version.",
},
}
}

func (r *StaticRender) Close() {}
Loading
0