chore: use static params when dynamic param metadata is missing (#17836)

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.
This commit is contained in:
Steven Masley
2025-05-16 11:47:59 -05:00
committed by GitHub
parent fb0e3d64db
commit f36fb67f57
14 changed files with 559 additions and 252 deletions

View File

@ -18,10 +18,13 @@ import (
"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"
)
@ -34,9 +37,7 @@ import (
// @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)
ctx := r.Context()
templateVersion := httpmw.TemplateVersionParam(r)
// Check that the job has completed successfully
@ -59,6 +60,33 @@ func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http
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)
@ -71,6 +99,7 @@ func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http
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{
@ -85,34 +114,25 @@ func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http
// 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 {
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}})
}
} 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)
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)
owner, err := api.getWorkspaceOwnerData(ctx, user, templateVersion.OrganizationID)
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.",
@ -127,6 +147,129 @@ func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http
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 := &params[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{
@ -143,10 +286,10 @@ func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http
)
// Send an initial form state, computed without any user input.
result, diagnostics := preview.Preview(ctx, input, templateFS)
result, diagnostics := render(ctx, map[string]string{})
response := codersdk.DynamicParametersResponse{
ID: -1,
Diagnostics: previewtypes.Diagnostics(diagnostics.Extend(staticDiagnostics)),
ID: -1, // Always start with -1.
Diagnostics: previewtypes.Diagnostics(diagnostics),
}
if result != nil {
response.Parameters = result.Parameters
@ -170,11 +313,11 @@ func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http
// 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)
result, diagnostics := render(ctx, update.Inputs)
response := codersdk.DynamicParametersResponse{
ID: update.ID,
Diagnostics: previewtypes.Diagnostics(diagnostics.Extend(staticDiagnostics)),
Diagnostics: previewtypes.Diagnostics(diagnostics),
}
if result != nil {
response.Parameters = result.Parameters
@ -188,8 +331,9 @@ func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http
}
}
func (api *API) getWorkspaceOwnerData(
func getWorkspaceOwnerData(
ctx context.Context,
db database.Store,
user database.User,
organizationID uuid.UUID,
) (previewtypes.WorkspaceOwner, error) {
@ -200,7 +344,7 @@ func (api *API) getWorkspaceOwnerData(
// 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)
row, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
if err != nil {
return err
}
@ -227,7 +371,10 @@ func (api *API) getWorkspaceOwnerData(
var publicKey string
g.Go(func() error {
key, err := api.Database.GetGitSSHKey(ctx, user.ID)
// 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
}
@ -237,7 +384,11 @@ func (api *API) getWorkspaceOwnerData(
var groupNames []string
g.Go(func() error {
groups, err := api.Database.GetGroups(ctx, database.GetGroupsParams{
// 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,
})
@ -267,31 +418,3 @@ func (api *API) getWorkspaceOwnerData(
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
}