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:
Steven Masley
2025-06-23 15:08:18 -05:00
committed by GitHub
parent f6e4ba6ed9
commit 5ed0c7abcb
6 changed files with 168 additions and 56 deletions

View File

@ -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

View 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

View 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, &parameterErr) {
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)
}
}

View File

@ -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
} }

View File

@ -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.

View File

@ -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))