mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +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:
@ -2,31 +2,18 @@ package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/files"
|
||||
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
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"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@ -81,292 +68,54 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter
|
||||
})(rw, r)
|
||||
}
|
||||
|
||||
// The `listen` control flag determines whether to open a websocket connection to
|
||||
// handle the request or not. This same function is used to 'evaluate' a template
|
||||
// as a single invocation, or to 'listen' for a back and forth interaction with
|
||||
// the user to update the form as they type.
|
||||
//
|
||||
//nolint:revive // listen is a control flag
|
||||
func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.DynamicParametersRequest) func(rw http.ResponseWriter, r *http.Request) {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
templateVersion := httpmw.TemplateVersionParam(r)
|
||||
|
||||
// Check that the job has completed successfully
|
||||
job, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
|
||||
dynamicparameters.WithTemplateVersion(templateVersion),
|
||||
)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
if xerrors.Is(err, dynamicparameters.ErrTemplateVersionNotReady) {
|
||||
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
|
||||
Message: "Template version job has not finished",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching provisioner job.",
|
||||
Message: "Internal error fetching template version data.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !job.CompletedAt.Valid {
|
||||
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
|
||||
Message: "Template version job has not finished",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer renderer.Close()
|
||||
|
||||
tf, err := api.Database.GetTemplateVersionTerraformValues(ctx, templateVersion.ID)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to retrieve Terraform values for template version",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) {
|
||||
api.handleDynamicParameters(listen, rw, r, tf, templateVersion, initial)
|
||||
if listen {
|
||||
api.handleParameterWebsocket(rw, r, initial, renderer)
|
||||
} else {
|
||||
api.handleStaticParameters(listen, rw, r, templateVersion.ID, initial)
|
||||
api.handleParameterEvaluate(rw, r, initial, renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type previewFunction func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
|
||||
|
||||
// nolint:revive
|
||||
func (api *API) handleDynamicParameters(listen bool, rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion, initial codersdk.DynamicParametersRequest) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apikey = httpmw.APIKey(r)
|
||||
)
|
||||
|
||||
// nolint:gocritic // We need to fetch the templates files for the Terraform
|
||||
// evaluator, and the user likely does not have permission.
|
||||
fileCtx := dbauthz.AsFileReader(ctx)
|
||||
fileID, err := api.Database.GetFileIDByTemplateVersionID(fileCtx, templateVersion.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error finding template version Terraform.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Add the file first. Calling `Release` if it fails is a no-op, so this is safe.
|
||||
var templateFS fs.FS
|
||||
closeableTemplateFS, err := api.FileCache.Acquire(fileCtx, fileID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Internal error fetching template version Terraform.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer closeableTemplateFS.Close()
|
||||
// templateFS does not implement the Close method. For it to be later merged with
|
||||
// the module files, we need to convert it to an OverlayFS.
|
||||
templateFS = closeableTemplateFS
|
||||
|
||||
// Having the Terraform plan available for the evaluation engine is helpful
|
||||
// for populating values from data blocks, but isn't strictly required. If
|
||||
// we don't have a cached plan available, we just use an empty one instead.
|
||||
plan := json.RawMessage("{}")
|
||||
if len(tf.CachedPlan) > 0 {
|
||||
plan = tf.CachedPlan
|
||||
}
|
||||
|
||||
if tf.CachedModuleFiles.Valid {
|
||||
moduleFilesFS, err := api.FileCache.Acquire(fileCtx, tf.CachedModuleFiles.UUID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Internal error fetching Terraform modules.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer moduleFilesFS.Close()
|
||||
|
||||
templateFS = files.NewOverlayFS(closeableTemplateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
|
||||
}
|
||||
|
||||
owner, err := getWorkspaceOwnerData(ctx, api.Database, apikey.UserID, templateVersion.OrganizationID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace owner.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input := preview.Input{
|
||||
PlanJSON: plan,
|
||||
ParameterValues: map[string]string{},
|
||||
Owner: owner,
|
||||
}
|
||||
|
||||
// failedOwners keeps track of which owners failed to fetch from the database.
|
||||
// This prevents db spam on repeated requests for the same failed owner.
|
||||
failedOwners := make(map[uuid.UUID]error)
|
||||
failedOwnerDiag := 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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dynamicRender := func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
if ownerID == uuid.Nil {
|
||||
// Default to the authenticated user
|
||||
// Nice for testing
|
||||
ownerID = apikey.UserID
|
||||
}
|
||||
|
||||
if _, ok := failedOwners[ownerID]; ok {
|
||||
// If it has failed once, assume it will fail always.
|
||||
// Re-open the websocket to try again.
|
||||
return nil, failedOwnerDiag
|
||||
}
|
||||
|
||||
// Update the input values with the new values.
|
||||
input.ParameterValues = values
|
||||
|
||||
// Update the owner if there is a change
|
||||
if input.Owner.ID != ownerID.String() {
|
||||
owner, err = getWorkspaceOwnerData(ctx, api.Database, ownerID, templateVersion.OrganizationID)
|
||||
if err != nil {
|
||||
failedOwners[ownerID] = err
|
||||
return nil, failedOwnerDiag
|
||||
}
|
||||
input.Owner = owner
|
||||
}
|
||||
|
||||
return preview.Preview(ctx, input, templateFS)
|
||||
}
|
||||
if listen {
|
||||
api.handleParameterWebsocket(rw, r, initial, dynamicRender)
|
||||
} else {
|
||||
api.handleParameterEvaluate(rw, r, initial, dynamicRender)
|
||||
}
|
||||
}
|
||||
|
||||
// nolint:revive
|
||||
func (api *API) handleStaticParameters(listen bool, rw http.ResponseWriter, r *http.Request, version uuid.UUID, initial codersdk.DynamicParametersRequest) {
|
||||
ctx := r.Context()
|
||||
dbTemplateVersionParameters, err := api.Database.GetTemplateVersionParameters(ctx, version)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to retrieve template version parameters",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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: "", // ooooof
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
staticRender := func(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
||||
for i := range params {
|
||||
param := ¶ms[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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
if listen {
|
||||
api.handleParameterWebsocket(rw, r, initial, staticRender)
|
||||
} else {
|
||||
api.handleParameterEvaluate(rw, r, initial, staticRender)
|
||||
}
|
||||
}
|
||||
|
||||
func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
|
||||
func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Send an initial form state, computed without any user input.
|
||||
result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
|
||||
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
|
||||
response := codersdk.DynamicParametersResponse{
|
||||
ID: 0,
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
@ -378,7 +127,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini
|
||||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
|
||||
func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@ -398,7 +147,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
)
|
||||
|
||||
// Send an initial form state, computed without any user input.
|
||||
result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
|
||||
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
|
||||
response := codersdk.DynamicParametersResponse{
|
||||
ID: -1, // Always start with -1.
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
@ -415,6 +164,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
// As the user types into the form, reprocess the state using their input,
|
||||
// and respond with updates.
|
||||
updates := stream.Chan()
|
||||
ownerID := initial.OwnerID
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@ -426,7 +176,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
result, diagnostics := render(ctx, update.OwnerID, update.Inputs)
|
||||
// Take a nil uuid to mean the previous owner ID.
|
||||
// This just removes the need to constantly send who you are.
|
||||
if update.OwnerID == uuid.Nil {
|
||||
update.OwnerID = ownerID
|
||||
}
|
||||
|
||||
ownerID = update.OwnerID
|
||||
|
||||
result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs)
|
||||
response := codersdk.DynamicParametersResponse{
|
||||
ID: update.ID,
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
@ -442,98 +200,3 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getWorkspaceOwnerData(
|
||||
ctx context.Context,
|
||||
db database.Store,
|
||||
ownerID uuid.UUID,
|
||||
organizationID uuid.UUID,
|
||||
) (previewtypes.WorkspaceOwner, error) {
|
||||
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 := db.GetUserByID(ctx, ownerID)
|
||||
if err != nil {
|
||||
return previewtypes.WorkspaceOwner{}, 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 := 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 != 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 := 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 := db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{
|
||||
OrganizationID: 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 previewtypes.WorkspaceOwner{}, err
|
||||
}
|
||||
|
||||
return 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,
|
||||
}, nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user