mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
Existing template versions do not have the metadata (modules + plan) in the db. So revert to using static parameter information from the original template import. This data will still be served over the websocket.
421 lines
13 KiB
Go
421 lines
13 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"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/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/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"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"
|
|
)
|
|
|
|
// @Summary Open dynamic parameters WebSocket by template version
|
|
// @ID open-dynamic-parameters-websocket-by-template-version
|
|
// @Security CoderSessionToken
|
|
// @Tags Templates
|
|
// @Param user path string true "Template version ID" format(uuid)
|
|
// @Param templateversion path string true "Template version ID" format(uuid)
|
|
// @Success 101
|
|
// @Router /users/{user}/templateversions/{templateversion}/parameters [get]
|
|
func (api *API) templateVersionDynamicParameters(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
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching provisioner job.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if !job.CompletedAt.Valid {
|
|
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
|
|
Message: "Template version job has not finished",
|
|
})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
major, minor, err := apiversion.Parse(tf.ProvisionerdVersion)
|
|
// If the api version is not valid or less than 1.5, we need to use the static parameters
|
|
useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6)
|
|
if useStaticParams {
|
|
api.handleStaticParameters(rw, r, templateVersion.ID)
|
|
} else {
|
|
api.handleDynamicParameters(rw, r, tf, templateVersion)
|
|
}
|
|
}
|
|
|
|
type previewFunction func(ctx context.Context, values map[string]string) (*preview.Output, hcl.Diagnostics)
|
|
|
|
func (api *API) handleDynamicParameters(rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
)
|
|
|
|
// nolint:gocritic // We need to fetch the templates files for the Terraform
|
|
// evaluator, and the user likely does not have permission.
|
|
fileCtx := dbauthz.AsProvisionerd(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.
|
|
templateFS, 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 api.FileCache.Release(fileID)
|
|
|
|
// 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 api.FileCache.Release(tf.CachedModuleFiles.UUID)
|
|
|
|
templateFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
|
|
}
|
|
|
|
owner, err := getWorkspaceOwnerData(ctx, api.Database, user, 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,
|
|
}
|
|
|
|
api.handleParameterWebsocket(rw, r, func(ctx context.Context, values map[string]string) (*preview.Output, hcl.Diagnostics) {
|
|
// Update the input values with the new values.
|
|
// The rest of the input is unchanged.
|
|
input.ParameterValues = values
|
|
return preview.Preview(ctx, input, templateFS)
|
|
})
|
|
}
|
|
|
|
func (api *API) handleStaticParameters(rw http.ResponseWriter, r *http.Request, version uuid.UUID) {
|
|
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)
|
|
}
|
|
|
|
api.handleParameterWebsocket(rw, r, func(_ context.Context, 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.",
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, render previewFunction) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
|
defer cancel()
|
|
|
|
conn, err := websocket.Accept(rw, r, nil)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusUpgradeRequired, codersdk.Response{
|
|
Message: "Failed to accept WebSocket.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
stream := wsjson.NewStream[codersdk.DynamicParametersRequest, codersdk.DynamicParametersResponse](
|
|
conn,
|
|
websocket.MessageText,
|
|
websocket.MessageText,
|
|
api.Logger,
|
|
)
|
|
|
|
// Send an initial form state, computed without any user input.
|
|
result, diagnostics := render(ctx, map[string]string{})
|
|
response := codersdk.DynamicParametersResponse{
|
|
ID: -1, // Always start with -1.
|
|
Diagnostics: previewtypes.Diagnostics(diagnostics),
|
|
}
|
|
if result != nil {
|
|
response.Parameters = result.Parameters
|
|
}
|
|
err = stream.Send(response)
|
|
if err != nil {
|
|
stream.Drop()
|
|
return
|
|
}
|
|
|
|
// As the user types into the form, reprocess the state using their input,
|
|
// and respond with updates.
|
|
updates := stream.Chan()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
stream.Close(websocket.StatusGoingAway)
|
|
return
|
|
case update, ok := <-updates:
|
|
if !ok {
|
|
// The connection has been closed, so there is no one to write to
|
|
return
|
|
}
|
|
|
|
result, diagnostics := render(ctx, update.Inputs)
|
|
response := codersdk.DynamicParametersResponse{
|
|
ID: update.ID,
|
|
Diagnostics: previewtypes.Diagnostics(diagnostics),
|
|
}
|
|
if result != nil {
|
|
response.Parameters = result.Parameters
|
|
}
|
|
err = stream.Send(response)
|
|
if err != nil {
|
|
stream.Drop()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func getWorkspaceOwnerData(
|
|
ctx context.Context,
|
|
db database.Store,
|
|
user database.User,
|
|
organizationID uuid.UUID,
|
|
) (previewtypes.WorkspaceOwner, error) {
|
|
var g errgroup.Group
|
|
|
|
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), user.ID)
|
|
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), user.ID)
|
|
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: user.ID,
|
|
})
|
|
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
|
|
}
|