mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
chore: improve dynamic parameter validation errors (#18501)
`BuildError` response from `wsbuilder` does not support rich errors from validation. Changed this to use the `Validations` block of codersdk responses to return all errors for invalid parameters.
This commit is contained in:
@ -26,6 +26,45 @@ type parameterValue struct {
|
|||||||
Source parameterValueSource
|
Source parameterValueSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResolverError struct {
|
||||||
|
Diagnostics hcl.Diagnostics
|
||||||
|
Parameter map[string]hcl.Diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error is a pretty bad format for these errors. Try to avoid using this.
|
||||||
|
func (e *ResolverError) Error() string {
|
||||||
|
var diags hcl.Diagnostics
|
||||||
|
diags = diags.Extend(e.Diagnostics)
|
||||||
|
for _, d := range e.Parameter {
|
||||||
|
diags = diags.Extend(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return diags.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ResolverError) HasError() bool {
|
||||||
|
if e.Diagnostics.HasErrors() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, diags := range e.Parameter {
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ResolverError) Extend(parameterName string, diag hcl.Diagnostics) {
|
||||||
|
if e.Parameter == nil {
|
||||||
|
e.Parameter = make(map[string]hcl.Diagnostics)
|
||||||
|
}
|
||||||
|
if _, ok := e.Parameter[parameterName]; !ok {
|
||||||
|
e.Parameter[parameterName] = hcl.Diagnostics{}
|
||||||
|
}
|
||||||
|
e.Parameter[parameterName] = e.Parameter[parameterName].Extend(diag)
|
||||||
|
}
|
||||||
|
|
||||||
//nolint:revive // firstbuild is a control flag to turn on immutable validation
|
//nolint:revive // firstbuild is a control flag to turn on immutable validation
|
||||||
func ResolveParameters(
|
func ResolveParameters(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@ -35,7 +74,7 @@ func ResolveParameters(
|
|||||||
previousValues []database.WorkspaceBuildParameter,
|
previousValues []database.WorkspaceBuildParameter,
|
||||||
buildValues []codersdk.WorkspaceBuildParameter,
|
buildValues []codersdk.WorkspaceBuildParameter,
|
||||||
presetValues []database.TemplateVersionPresetParameter,
|
presetValues []database.TemplateVersionPresetParameter,
|
||||||
) (map[string]string, hcl.Diagnostics) {
|
) (map[string]string, error) {
|
||||||
previousValuesMap := slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, string) {
|
previousValuesMap := slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, string) {
|
||||||
return p.Name, p.Value
|
return p.Name, p.Value
|
||||||
})
|
})
|
||||||
@ -73,7 +112,10 @@ func ResolveParameters(
|
|||||||
// always be valid. If there is a case where this is not true, then this has to
|
// always be valid. If there is a case where this is not true, then this has to
|
||||||
// be changed to allow the build to continue with a different set of values.
|
// be changed to allow the build to continue with a different set of values.
|
||||||
|
|
||||||
return nil, diags
|
return nil, &ResolverError{
|
||||||
|
Diagnostics: diags,
|
||||||
|
Parameter: nil,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user's input now needs to be validated against the parameters.
|
// The user's input now needs to be validated against the parameters.
|
||||||
@ -113,12 +155,16 @@ func ResolveParameters(
|
|||||||
// are fatal. Additional validation for immutability has to be done manually.
|
// are fatal. Additional validation for immutability has to be done manually.
|
||||||
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
|
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
return nil, diags
|
return nil, &ResolverError{
|
||||||
|
Diagnostics: diags,
|
||||||
|
Parameter: nil,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parameterNames is going to be used to remove any excess values that were left
|
// parameterNames is going to be used to remove any excess values that were left
|
||||||
// around without a parameter.
|
// around without a parameter.
|
||||||
parameterNames := make(map[string]struct{}, len(output.Parameters))
|
parameterNames := make(map[string]struct{}, len(output.Parameters))
|
||||||
|
parameterError := &ResolverError{}
|
||||||
for _, parameter := range output.Parameters {
|
for _, parameter := range output.Parameters {
|
||||||
parameterNames[parameter.Name] = struct{}{}
|
parameterNames[parameter.Name] = struct{}{}
|
||||||
|
|
||||||
@ -132,20 +178,22 @@ func ResolveParameters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An immutable parameter was changed, which is not allowed.
|
// An immutable parameter was changed, which is not allowed.
|
||||||
// Add the failed diagnostic to the output.
|
// Add a failed diagnostic to the output.
|
||||||
diags = diags.Append(&hcl.Diagnostic{
|
parameterError.Extend(parameter.Name, hcl.Diagnostics{
|
||||||
|
&hcl.Diagnostic{
|
||||||
Severity: hcl.DiagError,
|
Severity: hcl.DiagError,
|
||||||
Summary: "Immutable parameter changed",
|
Summary: "Immutable parameter changed",
|
||||||
Detail: fmt.Sprintf("Parameter %q is not mutable, so it can't be updated after creating a workspace.", parameter.Name),
|
Detail: fmt.Sprintf("Parameter %q is not mutable, so it can't be updated after creating a workspace.", parameter.Name),
|
||||||
Subject: src,
|
Subject: src,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Fix the `hcl.Diagnostics(...)` type casting. It should not be needed.
|
// TODO: Fix the `hcl.Diagnostics(...)` type casting. It should not be needed.
|
||||||
if hcl.Diagnostics(parameter.Diagnostics).HasErrors() {
|
if hcl.Diagnostics(parameter.Diagnostics).HasErrors() {
|
||||||
// All validation errors are raised here.
|
// All validation errors are raised here for each parameter.
|
||||||
diags = diags.Extend(hcl.Diagnostics(parameter.Diagnostics))
|
parameterError.Extend(parameter.Name, hcl.Diagnostics(parameter.Diagnostics))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the parameter has a value, but it was not set explicitly by the user at any
|
// If the parameter has a value, but it was not set explicitly by the user at any
|
||||||
@ -174,8 +222,13 @@ func ResolveParameters(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if parameterError.HasError() {
|
||||||
|
// If there are any errors, return them.
|
||||||
|
return nil, parameterError
|
||||||
|
}
|
||||||
|
|
||||||
// Return the values to be saved for the build.
|
// Return the values to be saved for the build.
|
||||||
return values.ValuesMap(), diags
|
return values.ValuesMap(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type parameterValueMap map[string]parameterValue
|
type parameterValueMap map[string]parameterValue
|
||||||
|
4
coderd/httpapi/httperror/doc.go
Normal file
4
coderd/httpapi/httperror/doc.go
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Package httperror handles formatting and writing some sentinel errors returned
|
||||||
|
// within coder to the API.
|
||||||
|
// This package exists outside httpapi to avoid some cyclic dependencies
|
||||||
|
package httperror
|
91
coderd/httpapi/httperror/wsbuild.go
Normal file
91
coderd/httpapi/httperror/wsbuild.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package httperror
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl/v2"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||||
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteWorkspaceBuildError(ctx context.Context, rw http.ResponseWriter, err error) {
|
||||||
|
var buildErr wsbuilder.BuildError
|
||||||
|
if errors.As(err, &buildErr) {
|
||||||
|
if httpapi.IsUnauthorizedError(err) {
|
||||||
|
buildErr.Status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, rw, buildErr.Status, codersdk.Response{
|
||||||
|
Message: buildErr.Message,
|
||||||
|
Detail: buildErr.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameterErr *dynamicparameters.ResolverError
|
||||||
|
if errors.As(err, ¶meterErr) {
|
||||||
|
resp := codersdk.Response{
|
||||||
|
Message: "Unable to validate parameters",
|
||||||
|
Validations: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the parameter names so that the order is consistent.
|
||||||
|
sortedNames := make([]string, 0, len(parameterErr.Parameter))
|
||||||
|
for name := range parameterErr.Parameter {
|
||||||
|
sortedNames = append(sortedNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedNames)
|
||||||
|
|
||||||
|
for _, name := range sortedNames {
|
||||||
|
diag := parameterErr.Parameter[name]
|
||||||
|
resp.Validations = append(resp.Validations, codersdk.ValidationError{
|
||||||
|
Field: name,
|
||||||
|
Detail: DiagnosticsErrorString(diag),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if parameterErr.Diagnostics.HasErrors() {
|
||||||
|
resp.Detail = DiagnosticsErrorString(parameterErr.Diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error creating workspace build.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiagnosticError(d *hcl.Diagnostic) string {
|
||||||
|
return fmt.Sprintf("%s; %s", d.Summary, d.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiagnosticsErrorString(d hcl.Diagnostics) string {
|
||||||
|
count := len(d)
|
||||||
|
switch {
|
||||||
|
case count == 0:
|
||||||
|
return "no diagnostics"
|
||||||
|
case count == 1:
|
||||||
|
return DiagnosticError(d[0])
|
||||||
|
default:
|
||||||
|
for _, d := range d {
|
||||||
|
// Render the first error diag.
|
||||||
|
// If there are warnings, do not priority them over errors.
|
||||||
|
if d.Severity == hcl.DiagError {
|
||||||
|
return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticError(d), count-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All warnings? ok...
|
||||||
|
return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticError(d[0]), count-1)
|
||||||
|
}
|
||||||
|
}
|
@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
||||||
"github.com/coder/coder/v2/coderd/httpmw"
|
"github.com/coder/coder/v2/coderd/httpmw"
|
||||||
"github.com/coder/coder/v2/coderd/notifications"
|
"github.com/coder/coder/v2/coderd/notifications"
|
||||||
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
||||||
@ -406,28 +407,8 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}, nil)
|
}, nil)
|
||||||
var buildErr wsbuilder.BuildError
|
|
||||||
if xerrors.As(err, &buildErr) {
|
|
||||||
var authErr dbauthz.NotAuthorizedError
|
|
||||||
if xerrors.As(err, &authErr) {
|
|
||||||
buildErr.Status = http.StatusForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildErr.Status == http.StatusInternalServerError {
|
|
||||||
api.Logger.Error(ctx, "workspace build error", slog.Error(buildErr.Wrapped))
|
|
||||||
}
|
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, buildErr.Status, codersdk.Response{
|
|
||||||
Message: buildErr.Message,
|
|
||||||
Detail: buildErr.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
httperror.WriteWorkspaceBuildError(ctx, rw, err)
|
||||||
Message: "Error posting new build",
|
|
||||||
Detail: err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
||||||
"github.com/coder/coder/v2/coderd/httpmw"
|
"github.com/coder/coder/v2/coderd/httpmw"
|
||||||
"github.com/coder/coder/v2/coderd/notifications"
|
"github.com/coder/coder/v2/coderd/notifications"
|
||||||
"github.com/coder/coder/v2/coderd/prebuilds"
|
"github.com/coder/coder/v2/coderd/prebuilds"
|
||||||
@ -728,21 +729,11 @@ func createWorkspace(
|
|||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}, nil)
|
}, nil)
|
||||||
var bldErr wsbuilder.BuildError
|
|
||||||
if xerrors.As(err, &bldErr) {
|
|
||||||
httpapi.Write(ctx, rw, bldErr.Status, codersdk.Response{
|
|
||||||
Message: bldErr.Message,
|
|
||||||
Detail: bldErr.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
httperror.WriteWorkspaceBuildError(ctx, rw, err)
|
||||||
Message: "Internal error creating workspace.",
|
|
||||||
Detail: err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob)
|
err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Client probably doesn't care about this error, so just log it.
|
// Client probably doesn't care about this error, so just log it.
|
||||||
|
@ -752,20 +752,12 @@ func (b *Builder) getDynamicParameters() (names, values []string, err error) {
|
|||||||
return nil, nil, BuildError{http.StatusInternalServerError, "failed to check if first build", err}
|
return nil, nil, BuildError{http.StatusInternalServerError, "failed to check if first build", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildValues, diagnostics := dynamicparameters.ResolveParameters(b.ctx, b.workspace.OwnerID, render, firstBuild,
|
buildValues, err := dynamicparameters.ResolveParameters(b.ctx, b.workspace.OwnerID, render, firstBuild,
|
||||||
lastBuildParameters,
|
lastBuildParameters,
|
||||||
b.richParameterValues,
|
b.richParameterValues,
|
||||||
presetParameterValues)
|
presetParameterValues)
|
||||||
|
if err != nil {
|
||||||
if diagnostics.HasErrors() {
|
return nil, nil, xerrors.Errorf("resolve parameters: %w", err)
|
||||||
// TODO: Improve the error response. The response should include the validations for each failed
|
|
||||||
// parameter. The response should also indicate it's a validation error or a more general form failure.
|
|
||||||
// For now, any error is sufficient.
|
|
||||||
return nil, nil, BuildError{
|
|
||||||
Status: http.StatusBadRequest,
|
|
||||||
Message: fmt.Sprintf("%d errors occurred while resolving parameters", len(diagnostics)),
|
|
||||||
Wrapped: diagnostics,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
names = make([]string, 0, len(buildValues))
|
names = make([]string, 0, len(buildValues))
|
||||||
|
Reference in New Issue
Block a user