Files
coder/coderd/dynamicparameters/render.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
}