mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +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:
340
coderd/dynamicparameters/render.go
Normal file
340
coderd/dynamicparameters/render.go
Normal file
@ -0,0 +1,340 @@
|
||||
package dynamicparameters
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/apiversion"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/files"
|
||||
"github.com/coder/preview"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
)
|
||||
|
||||
// Renderer is able to execute and evaluate terraform with the given inputs.
|
||||
// It may use the database to fetch additional state, such as a user's groups,
|
||||
// roles, etc. Therefore, it requires an authenticated `ctx`.
|
||||
//
|
||||
// 'Close()' **must** be called once the renderer is no longer needed.
|
||||
// Forgetting to do so will result in a memory leak.
|
||||
type Renderer interface {
|
||||
Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
|
||||
Close()
|
||||
}
|
||||
|
||||
var ErrTemplateVersionNotReady = xerrors.New("template version job not finished")
|
||||
|
||||
// loader is used to load the necessary coder objects for rendering a template
|
||||
// version's parameters. The output is a Renderer, which is the object that uses
|
||||
// the cached objects to render the template version's parameters.
|
||||
type loader struct {
|
||||
templateVersionID uuid.UUID
|
||||
|
||||
// cache of objects
|
||||
templateVersion *database.TemplateVersion
|
||||
job *database.ProvisionerJob
|
||||
terraformValues *database.TemplateVersionTerraformValue
|
||||
}
|
||||
|
||||
// Prepare is the entrypoint for this package. It loads the necessary objects &
|
||||
// files from the database and returns a Renderer that can be used to render the
|
||||
// template version's parameters.
|
||||
func Prepare(ctx context.Context, db database.Store, cache *files.Cache, versionID uuid.UUID, options ...func(r *loader)) (Renderer, error) {
|
||||
l := &loader{
|
||||
templateVersionID: versionID,
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
opt(l)
|
||||
}
|
||||
|
||||
return l.Renderer(ctx, db, cache)
|
||||
}
|
||||
|
||||
func WithTemplateVersion(tv database.TemplateVersion) func(r *loader) {
|
||||
return func(r *loader) {
|
||||
if tv.ID == r.templateVersionID {
|
||||
r.templateVersion = &tv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithProvisionerJob(job database.ProvisionerJob) func(r *loader) {
|
||||
return func(r *loader) {
|
||||
r.job = &job
|
||||
}
|
||||
}
|
||||
|
||||
func WithTerraformValues(values database.TemplateVersionTerraformValue) func(r *loader) {
|
||||
return func(r *loader) {
|
||||
if values.TemplateVersionID == r.templateVersionID {
|
||||
r.terraformValues = &values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *loader) loadData(ctx context.Context, db database.Store) error {
|
||||
if r.templateVersion == nil {
|
||||
tv, err := db.GetTemplateVersionByID(ctx, r.templateVersionID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("template version: %w", err)
|
||||
}
|
||||
r.templateVersion = &tv
|
||||
}
|
||||
|
||||
if r.job == nil {
|
||||
job, err := db.GetProvisionerJobByID(ctx, r.templateVersion.JobID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("provisioner job: %w", err)
|
||||
}
|
||||
r.job = &job
|
||||
}
|
||||
|
||||
if !r.job.CompletedAt.Valid {
|
||||
return ErrTemplateVersionNotReady
|
||||
}
|
||||
|
||||
if r.terraformValues == nil {
|
||||
values, err := db.GetTemplateVersionTerraformValues(ctx, r.templateVersion.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("template version terraform values: %w", err)
|
||||
}
|
||||
r.terraformValues = &values
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Renderer returns a Renderer that can be used to render the template version's
|
||||
// parameters. It automatically determines whether to use a static or dynamic
|
||||
// renderer based on the template version's state.
|
||||
//
|
||||
// Static parameter rendering is required to support older template versions that
|
||||
// do not have the database state to support dynamic parameters. A constant
|
||||
// warning will be displayed for these template versions.
|
||||
func (r *loader) Renderer(ctx context.Context, db database.Store, cache *files.Cache) (Renderer, error) {
|
||||
err := r.loadData(ctx, db)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load data: %w", err)
|
||||
}
|
||||
|
||||
if !ProvisionerVersionSupportsDynamicParameters(r.terraformValues.ProvisionerdVersion) {
|
||||
return r.staticRender(ctx, db)
|
||||
}
|
||||
|
||||
return r.dynamicRenderer(ctx, db, cache)
|
||||
}
|
||||
|
||||
// Renderer caches all the necessary files when rendering a template version's
|
||||
// parameters. It must be closed after use to release the cached files.
|
||||
func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *files.Cache) (*dynamicRenderer, error) {
|
||||
// If they can read the template version, then they can read the file for
|
||||
// parameter loading purposes.
|
||||
//nolint:gocritic
|
||||
fileCtx := dbauthz.AsFileReader(ctx)
|
||||
templateFS, err := cache.Acquire(fileCtx, r.job.FileID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("acquire template file: %w", err)
|
||||
}
|
||||
|
||||
var terraformFS fs.FS = templateFS
|
||||
var moduleFilesFS *files.CloseFS
|
||||
if r.terraformValues.CachedModuleFiles.Valid {
|
||||
moduleFilesFS, err = cache.Acquire(fileCtx, r.terraformValues.CachedModuleFiles.UUID)
|
||||
if err != nil {
|
||||
templateFS.Close()
|
||||
return nil, xerrors.Errorf("acquire module files: %w", err)
|
||||
}
|
||||
terraformFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
|
||||
}
|
||||
|
||||
return &dynamicRenderer{
|
||||
data: r,
|
||||
templateFS: terraformFS,
|
||||
db: db,
|
||||
ownerErrors: make(map[uuid.UUID]error),
|
||||
close: func() {
|
||||
// Up to 2 files are cached, and must be released when rendering is complete.
|
||||
// TODO: Might be smart to always call release when the context is
|
||||
// canceled.
|
||||
templateFS.Close()
|
||||
if moduleFilesFS != nil {
|
||||
moduleFilesFS.Close()
|
||||
}
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type dynamicRenderer struct {
|
||||
db database.Store
|
||||
data *loader
|
||||
templateFS fs.FS
|
||||
|
||||
ownerErrors map[uuid.UUID]error
|
||||
currentOwner *previewtypes.WorkspaceOwner
|
||||
|
||||
once sync.Once
|
||||
close func()
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
// Always start with the cached error, if we have one.
|
||||
ownerErr := r.ownerErrors[ownerID]
|
||||
if ownerErr == nil {
|
||||
ownerErr = r.getWorkspaceOwnerData(ctx, ownerID)
|
||||
}
|
||||
|
||||
if ownerErr != nil || r.currentOwner == nil {
|
||||
r.ownerErrors[ownerID] = ownerErr
|
||||
return nil, hcl.Diagnostics{
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch workspace owner",
|
||||
Detail: "Please check your permissions or the user may not exist.",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: "owner_not_found",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
input := preview.Input{
|
||||
PlanJSON: r.data.terraformValues.CachedPlan,
|
||||
ParameterValues: values,
|
||||
Owner: *r.currentOwner,
|
||||
// Do not emit parser logs to coderd output logs.
|
||||
// TODO: Returning this logs in the output would benefit the caller.
|
||||
// Unsure how large the logs can be, so for now we just discard them.
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
|
||||
return preview.Preview(ctx, input, r.templateFS)
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error {
|
||||
if r.currentOwner != nil && r.currentOwner.ID == ownerID.String() {
|
||||
return nil // already fetched
|
||||
}
|
||||
|
||||
var g errgroup.Group
|
||||
|
||||
// You only need to be able to read the organization member to get the owner
|
||||
// data. Only the terraform files can therefore leak more information than the
|
||||
// caller should have access to. All this info should be public assuming you can
|
||||
// read the user though.
|
||||
mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
||||
OrganizationID: r.data.templateVersion.OrganizationID,
|
||||
UserID: ownerID,
|
||||
IncludeSystem: false,
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// User data is required for the form. Org member is checked above
|
||||
// nolint:gocritic
|
||||
user, err := r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch user: %w", err)
|
||||
}
|
||||
|
||||
var ownerRoles []previewtypes.WorkspaceOwnerRBACRole
|
||||
g.Go(func() error {
|
||||
// nolint:gocritic // This is kind of the wrong query to use here, but it
|
||||
// matches how the provisioner currently works. We should figure out
|
||||
// something that needs less escalation but has the correct behavior.
|
||||
row, err := r.db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roles, err := row.RoleNames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownerRoles = make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
|
||||
for _, it := range roles {
|
||||
if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID {
|
||||
continue
|
||||
}
|
||||
var orgID string
|
||||
if it.OrganizationID != uuid.Nil {
|
||||
orgID = it.OrganizationID.String()
|
||||
}
|
||||
ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{
|
||||
Name: it.Name,
|
||||
OrgID: orgID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
var publicKey string
|
||||
g.Go(func() error {
|
||||
// The correct public key has to be sent. This will not be leaked
|
||||
// unless the template leaks it.
|
||||
// nolint:gocritic
|
||||
key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey = key.PublicKey
|
||||
return nil
|
||||
})
|
||||
|
||||
var groupNames []string
|
||||
g.Go(func() error {
|
||||
// The groups need to be sent to preview. These groups are not exposed to the
|
||||
// user, unless the template does it through the parameters. Regardless, we need
|
||||
// the correct groups, and a user might not have read access.
|
||||
// nolint:gocritic
|
||||
groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{
|
||||
OrganizationID: r.data.templateVersion.OrganizationID,
|
||||
HasMemberID: ownerID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupNames = make([]string, 0, len(groups))
|
||||
for _, it := range groups {
|
||||
groupNames = append(groupNames, it.Group.Name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err = g.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.currentOwner = &previewtypes.WorkspaceOwner{
|
||||
ID: mem.OrganizationMember.UserID.String(),
|
||||
Name: mem.Username,
|
||||
FullName: mem.Name,
|
||||
Email: mem.Email,
|
||||
LoginType: string(user.LoginType),
|
||||
RBACRoles: ownerRoles,
|
||||
SSHPublicKey: publicKey,
|
||||
Groups: groupNames,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *dynamicRenderer) Close() {
|
||||
r.once.Do(r.close)
|
||||
}
|
||||
|
||||
func ProvisionerVersionSupportsDynamicParameters(version string) bool {
|
||||
major, minor, err := apiversion.Parse(version)
|
||||
// If the api version is not valid or less than 1.6, we need to use the static parameters
|
||||
useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6)
|
||||
return !useStaticParams
|
||||
}
|
Reference in New Issue
Block a user