mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
345 lines
11 KiB
Go
345 lines
11 KiB
Go
package dynamicparameters
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io/fs"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"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.FileAcquirer, 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 && !xerrors.Is(err, sql.ErrNoRows) {
|
|
return xerrors.Errorf("template version terraform values: %w", err)
|
|
}
|
|
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
// If the row does not exist, return zero values.
|
|
//
|
|
// Older template versions (prior to dynamic parameters) will be missing
|
|
// this row, and we can assume the 'ProvisionerdVersion' "" (unknown).
|
|
values = database.TemplateVersionTerraformValue{
|
|
TemplateVersionID: r.templateVersionID,
|
|
UpdatedAt: time.Time{},
|
|
CachedPlan: nil,
|
|
CachedModuleFiles: uuid.NullUUID{},
|
|
ProvisionerdVersion: "",
|
|
}
|
|
}
|
|
|
|
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.FileAcquirer) (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, files.NewCacheCloser(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.CacheCloser) (*dynamicRenderer, error) {
|
|
closeFiles := true // If the function returns with no error, this will toggle to false.
|
|
defer func() {
|
|
if closeFiles {
|
|
cache.Close()
|
|
}
|
|
}()
|
|
|
|
// If they can read the template version, then they can read the file for
|
|
// parameter loading purposes.
|
|
//nolint:gocritic
|
|
fileCtx := dbauthz.AsFileReader(ctx)
|
|
|
|
var templateFS fs.FS
|
|
var err error
|
|
|
|
templateFS, err = cache.Acquire(fileCtx, db, r.job.FileID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("acquire template file: %w", err)
|
|
}
|
|
|
|
var moduleFilesFS *files.CloseFS
|
|
if r.terraformValues.CachedModuleFiles.Valid {
|
|
moduleFilesFS, err = cache.Acquire(fileCtx, db, r.terraformValues.CachedModuleFiles.UUID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("acquire module files: %w", err)
|
|
}
|
|
templateFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
|
|
}
|
|
|
|
closeFiles = false // Caller will have to call close
|
|
return &dynamicRenderer{
|
|
data: r,
|
|
templateFS: templateFS,
|
|
db: db,
|
|
ownerErrors: make(map[uuid.UUID]error),
|
|
close: cache.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
|
|
}
|
|
|
|
user, err := r.db.GetUserByID(ctx, ownerID)
|
|
if err != nil {
|
|
// If the user failed to read, we also try to read the user from their
|
|
// organization member. 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: true,
|
|
}))
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch user: %w", err)
|
|
}
|
|
|
|
// Org member fetched, so use the provisioner context to fetch the user.
|
|
//nolint:gocritic // Has the correct permissions, and matches the provisioning flow.
|
|
user, err = r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch user: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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 xerrors.Errorf("user roles: %w", err)
|
|
}
|
|
roles, err := row.RoleNames()
|
|
if err != nil {
|
|
return xerrors.Errorf("expand roles: %w", 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,
|
|
})
|
|
}
|
|
|
|
// 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 && !xerrors.Is(err, sql.ErrNoRows) {
|
|
return xerrors.Errorf("ssh key: %w", err)
|
|
}
|
|
|
|
// 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 xerrors.Errorf("groups: %w", err)
|
|
}
|
|
groupNames := make([]string, 0, len(groups))
|
|
for _, it := range groups {
|
|
groupNames = append(groupNames, it.Group.Name)
|
|
}
|
|
|
|
r.currentOwner = &previewtypes.WorkspaceOwner{
|
|
ID: user.ID.String(),
|
|
Name: user.Username,
|
|
FullName: user.Name,
|
|
Email: user.Email,
|
|
LoginType: string(user.LoginType),
|
|
RBACRoles: ownerRoles,
|
|
SSHPublicKey: key.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
|
|
}
|