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 }