Files
coder/coderd/parameters.go

298 lines
9.0 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/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
"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, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
user := httpmw.UserParam(r)
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
}
// 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
}
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("{}")
tf, err := api.Database.GetTemplateVersionTerraformValues(ctx, templateVersion.ID)
if err == nil {
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}})
}
} else if !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 the err is sql.ErrNoRows, an empty terraform values struct is correct.
staticDiagnostics := parameterProvisionerVersionDiagnostic(tf)
owner, err := api.getWorkspaceOwnerData(ctx, 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,
}
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 := preview.Preview(ctx, input, templateFS)
response := codersdk.DynamicParametersResponse{
ID: -1,
Diagnostics: previewtypes.Diagnostics(diagnostics.Extend(staticDiagnostics)),
}
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
}
input.ParameterValues = update.Inputs
result, diagnostics := preview.Preview(ctx, input, templateFS)
response := codersdk.DynamicParametersResponse{
ID: update.ID,
Diagnostics: previewtypes.Diagnostics(diagnostics.Extend(staticDiagnostics)),
}
if result != nil {
response.Parameters = result.Parameters
}
err = stream.Send(response)
if err != nil {
stream.Drop()
return
}
}
}
}
func (api *API) getWorkspaceOwnerData(
ctx context.Context,
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 := api.Database.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 {
key, err := api.Database.GetGitSSHKey(ctx, user.ID)
if err != nil {
return err
}
publicKey = key.PublicKey
return nil
})
var groupNames []string
g.Go(func() error {
groups, err := api.Database.GetGroups(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
}
// parameterProvisionerVersionDiagnostic checks the version of the provisioner
// used to create the template version. If the version is less than 1.5, it
// returns a warning diagnostic. Only versions 1.5+ return the module & plan data
// required.
func parameterProvisionerVersionDiagnostic(tf database.TemplateVersionTerraformValue) hcl.Diagnostics {
missingMetadata := hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "This template version is missing required metadata to support dynamic parameters. Go back to the classic creation flow.",
Detail: "To restore full functionality, please re-import the terraform as a new template version.",
}
if tf.ProvisionerdVersion == "" {
return hcl.Diagnostics{&missingMetadata}
}
major, minor, err := apiversion.Parse(tf.ProvisionerdVersion)
if err != nil || tf.ProvisionerdVersion == "" {
return hcl.Diagnostics{&missingMetadata}
} else if major < 1 || (major == 1 && minor < 5) {
missingMetadata.Detail = "This template version does not support dynamic parameters. " +
"Some options may be missing or incorrect. " +
"Please contact an administrator to update the provisioner and re-import the template version."
return hcl.Diagnostics{&missingMetadata}
}
return nil
}