feat: evaluate dynamic parameters http endpoint (#18182)

Used when a websocket is too heavy. This implements a single request to
the preview engine.
This commit is contained in:
Steven Masley
2025-06-02 13:50:07 -05:00
committed by GitHub
parent 322f1e4dd2
commit 246a829ea9
6 changed files with 1154 additions and 61 deletions

301
coderd/apidoc/docs.go generated
View File

@ -5893,14 +5893,6 @@ const docTemplate = `{
"summary": "Open dynamic parameters WebSocket by template version",
"operationId": "open-dynamic-parameters-websocket-by-template-version",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Template version ID",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
@ -5917,6 +5909,53 @@ const docTemplate = `{
}
}
},
"/templateversions/{templateversion}/dynamic-parameters/evaluate": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Templates"
],
"summary": "Evaluate dynamic parameters for template version",
"operationId": "evaluate-dynamic-parameters-for-template-version",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Template version ID",
"name": "templateversion",
"in": "path",
"required": true
},
{
"description": "Initial parameter values",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.DynamicParametersRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.DynamicParametersResponse"
}
}
}
}
},
"/templateversions/{templateversion}/external-auth": {
"get": {
"security": [
@ -12573,6 +12612,25 @@ const docTemplate = `{
}
}
},
"codersdk.DiagnosticExtra": {
"type": "object",
"properties": {
"code": {
"type": "string"
}
}
},
"codersdk.DiagnosticSeverityString": {
"type": "string",
"enum": [
"error",
"warning"
],
"x-enum-varnames": [
"DiagnosticSeverityError",
"DiagnosticSeverityWarning"
]
},
"codersdk.DisplayApp": {
"type": "string",
"enum": [
@ -12590,6 +12648,46 @@ const docTemplate = `{
"DisplayAppSSH"
]
},
"codersdk.DynamicParametersRequest": {
"type": "object",
"properties": {
"id": {
"description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.",
"type": "integer"
},
"inputs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"owner_id": {
"description": "OwnerID if uuid.Nil, it defaults to ` + "`" + `codersdk.Me` + "`" + `",
"type": "string",
"format": "uuid"
}
}
},
"codersdk.DynamicParametersResponse": {
"type": "object",
"properties": {
"diagnostics": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.FriendlyDiagnostic"
}
},
"id": {
"type": "integer"
},
"parameters": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PreviewParameter"
}
}
}
},
"codersdk.Entitlement": {
"type": "string",
"enum": [
@ -12870,6 +12968,23 @@ const docTemplate = `{
}
}
},
"codersdk.FriendlyDiagnostic": {
"type": "object",
"properties": {
"detail": {
"type": "string"
},
"extra": {
"$ref": "#/definitions/codersdk.DiagnosticExtra"
},
"severity": {
"$ref": "#/definitions/codersdk.DiagnosticSeverityString"
},
"summary": {
"type": "string"
}
}
},
"codersdk.GenerateAPIKeyResponse": {
"type": "object",
"properties": {
@ -13661,6 +13776,17 @@ const docTemplate = `{
}
}
},
"codersdk.NullHCLString": {
"type": "object",
"properties": {
"valid": {
"type": "boolean"
},
"value": {
"type": "string"
}
}
},
"codersdk.OAuth2AppEndpoints": {
"type": "object",
"properties": {
@ -13918,6 +14044,21 @@ const docTemplate = `{
}
}
},
"codersdk.OptionType": {
"type": "string",
"enum": [
"string",
"number",
"bool",
"list(string)"
],
"x-enum-varnames": [
"OptionTypeString",
"OptionTypeNumber",
"OptionTypeBoolean",
"OptionTypeListString"
]
},
"codersdk.Organization": {
"type": "object",
"required": [
@ -14065,6 +14206,35 @@ const docTemplate = `{
}
}
},
"codersdk.ParameterFormType": {
"type": "string",
"enum": [
"",
"radio",
"slider",
"input",
"dropdown",
"checkbox",
"switch",
"multi-select",
"tag-select",
"textarea",
"error"
],
"x-enum-varnames": [
"ParameterFormTypeDefault",
"ParameterFormTypeRadio",
"ParameterFormTypeSlider",
"ParameterFormTypeInput",
"ParameterFormTypeDropdown",
"ParameterFormTypeCheckbox",
"ParameterFormTypeSwitch",
"ParameterFormTypeMultiSelect",
"ParameterFormTypeTagSelect",
"ParameterFormTypeTextArea",
"ParameterFormTypeError"
]
},
"codersdk.PatchGroupIDPSyncConfigRequest": {
"type": "object",
"properties": {
@ -14381,6 +14551,121 @@ const docTemplate = `{
}
}
},
"codersdk.PreviewParameter": {
"type": "object",
"properties": {
"default_value": {
"$ref": "#/definitions/codersdk.NullHCLString"
},
"description": {
"type": "string"
},
"diagnostics": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.FriendlyDiagnostic"
}
},
"display_name": {
"type": "string"
},
"ephemeral": {
"type": "boolean"
},
"form_type": {
"$ref": "#/definitions/codersdk.ParameterFormType"
},
"icon": {
"type": "string"
},
"mutable": {
"type": "boolean"
},
"name": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PreviewParameterOption"
}
},
"order": {
"description": "legacy_variable_name was removed (= 14)",
"type": "integer"
},
"required": {
"type": "boolean"
},
"styling": {
"$ref": "#/definitions/codersdk.PreviewParameterStyling"
},
"type": {
"$ref": "#/definitions/codersdk.OptionType"
},
"validations": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PreviewParameterValidation"
}
},
"value": {
"$ref": "#/definitions/codersdk.NullHCLString"
}
}
},
"codersdk.PreviewParameterOption": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
},
"value": {
"$ref": "#/definitions/codersdk.NullHCLString"
}
}
},
"codersdk.PreviewParameterStyling": {
"type": "object",
"properties": {
"disabled": {
"type": "boolean"
},
"label": {
"type": "string"
},
"placeholder": {
"type": "string"
}
}
},
"codersdk.PreviewParameterValidation": {
"type": "object",
"properties": {
"validation_error": {
"type": "string"
},
"validation_max": {
"type": "integer"
},
"validation_min": {
"type": "integer"
},
"validation_monotonic": {
"type": "string"
},
"validation_regex": {
"description": "All validation attributes are optional.",
"type": "string"
}
}
},
"codersdk.PrometheusConfig": {
"type": "object",
"properties": {

View File

@ -5208,14 +5208,6 @@
"summary": "Open dynamic parameters WebSocket by template version",
"operationId": "open-dynamic-parameters-websocket-by-template-version",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Template version ID",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
@ -5232,6 +5224,47 @@
}
}
},
"/templateversions/{templateversion}/dynamic-parameters/evaluate": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Templates"],
"summary": "Evaluate dynamic parameters for template version",
"operationId": "evaluate-dynamic-parameters-for-template-version",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Template version ID",
"name": "templateversion",
"in": "path",
"required": true
},
{
"description": "Initial parameter values",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.DynamicParametersRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.DynamicParametersResponse"
}
}
}
}
},
"/templateversions/{templateversion}/external-auth": {
"get": {
"security": [
@ -11279,6 +11312,22 @@
}
}
},
"codersdk.DiagnosticExtra": {
"type": "object",
"properties": {
"code": {
"type": "string"
}
}
},
"codersdk.DiagnosticSeverityString": {
"type": "string",
"enum": ["error", "warning"],
"x-enum-varnames": [
"DiagnosticSeverityError",
"DiagnosticSeverityWarning"
]
},
"codersdk.DisplayApp": {
"type": "string",
"enum": [
@ -11296,6 +11345,46 @@
"DisplayAppSSH"
]
},
"codersdk.DynamicParametersRequest": {
"type": "object",
"properties": {
"id": {
"description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.",
"type": "integer"
},
"inputs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"owner_id": {
"description": "OwnerID if uuid.Nil, it defaults to `codersdk.Me`",
"type": "string",
"format": "uuid"
}
}
},
"codersdk.DynamicParametersResponse": {
"type": "object",
"properties": {
"diagnostics": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.FriendlyDiagnostic"
}
},
"id": {
"type": "integer"
},
"parameters": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PreviewParameter"
}
}
}
},
"codersdk.Entitlement": {
"type": "string",
"enum": ["entitled", "grace_period", "not_entitled"],
@ -11572,6 +11661,23 @@
}
}
},
"codersdk.FriendlyDiagnostic": {
"type": "object",
"properties": {
"detail": {
"type": "string"
},
"extra": {
"$ref": "#/definitions/codersdk.DiagnosticExtra"
},
"severity": {
"$ref": "#/definitions/codersdk.DiagnosticSeverityString"
},
"summary": {
"type": "string"
}
}
},
"codersdk.GenerateAPIKeyResponse": {
"type": "object",
"properties": {
@ -12314,6 +12420,17 @@
}
}
},
"codersdk.NullHCLString": {
"type": "object",
"properties": {
"valid": {
"type": "boolean"
},
"value": {
"type": "string"
}
}
},
"codersdk.OAuth2AppEndpoints": {
"type": "object",
"properties": {
@ -12571,6 +12688,16 @@
}
}
},
"codersdk.OptionType": {
"type": "string",
"enum": ["string", "number", "bool", "list(string)"],
"x-enum-varnames": [
"OptionTypeString",
"OptionTypeNumber",
"OptionTypeBoolean",
"OptionTypeListString"
]
},
"codersdk.Organization": {
"type": "object",
"required": ["created_at", "id", "is_default", "updated_at"],
@ -12713,6 +12840,35 @@
}
}
},
"codersdk.ParameterFormType": {
"type": "string",
"enum": [
"",
"radio",
"slider",
"input",
"dropdown",
"checkbox",
"switch",
"multi-select",
"tag-select",
"textarea",
"error"
],
"x-enum-varnames": [
"ParameterFormTypeDefault",
"ParameterFormTypeRadio",
"ParameterFormTypeSlider",
"ParameterFormTypeInput",
"ParameterFormTypeDropdown",
"ParameterFormTypeCheckbox",
"ParameterFormTypeSwitch",
"ParameterFormTypeMultiSelect",
"ParameterFormTypeTagSelect",
"ParameterFormTypeTextArea",
"ParameterFormTypeError"
]
},
"codersdk.PatchGroupIDPSyncConfigRequest": {
"type": "object",
"properties": {
@ -13021,6 +13177,121 @@
}
}
},
"codersdk.PreviewParameter": {
"type": "object",
"properties": {
"default_value": {
"$ref": "#/definitions/codersdk.NullHCLString"
},
"description": {
"type": "string"
},
"diagnostics": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.FriendlyDiagnostic"
}
},
"display_name": {
"type": "string"
},
"ephemeral": {
"type": "boolean"
},
"form_type": {
"$ref": "#/definitions/codersdk.ParameterFormType"
},
"icon": {
"type": "string"
},
"mutable": {
"type": "boolean"
},
"name": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PreviewParameterOption"
}
},
"order": {
"description": "legacy_variable_name was removed (= 14)",
"type": "integer"
},
"required": {
"type": "boolean"
},
"styling": {
"$ref": "#/definitions/codersdk.PreviewParameterStyling"
},
"type": {
"$ref": "#/definitions/codersdk.OptionType"
},
"validations": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PreviewParameterValidation"
}
},
"value": {
"$ref": "#/definitions/codersdk.NullHCLString"
}
}
},
"codersdk.PreviewParameterOption": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
},
"value": {
"$ref": "#/definitions/codersdk.NullHCLString"
}
}
},
"codersdk.PreviewParameterStyling": {
"type": "object",
"properties": {
"disabled": {
"type": "boolean"
},
"label": {
"type": "string"
},
"placeholder": {
"type": "string"
}
}
},
"codersdk.PreviewParameterValidation": {
"type": "object",
"properties": {
"validation_error": {
"type": "string"
},
"validation_max": {
"type": "integer"
},
"validation_min": {
"type": "integer"
},
"validation_monotonic": {
"type": "string"
},
"validation_regex": {
"description": "All validation attributes are optional.",
"type": "string"
}
}
},
"codersdk.PrometheusConfig": {
"type": "object",
"properties": {

View File

@ -1156,7 +1156,10 @@ func New(options *Options) *API {
r.Use(
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentDynamicParameters),
)
r.Get("/dynamic-parameters", api.templateVersionDynamicParameters)
r.Route("/dynamic-parameters", func(r chi.Router) {
r.Post("/evaluate", api.templateVersionDynamicParametersEvaluate)
r.Get("/", api.templateVersionDynamicParametersWebsocket)
})
})
})
r.Route("/users", func(r chi.Router) {

View File

@ -29,57 +29,89 @@ import (
"github.com/coder/websocket"
)
// @Summary Evaluate dynamic parameters for template version
// @ID evaluate-dynamic-parameters-for-template-version
// @Security CoderSessionToken
// @Tags Templates
// @Param templateversion path string true "Template version ID" format(uuid)
// @Accept json
// @Produce json
// @Param request body codersdk.DynamicParametersRequest true "Initial parameter values"
// @Success 200 {object} codersdk.DynamicParametersResponse
// @Router /templateversions/{templateversion}/dynamic-parameters/evaluate [post]
func (api *API) templateVersionDynamicParametersEvaluate(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req codersdk.DynamicParametersRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
api.templateVersionDynamicParameters(false, req)(rw, r)
}
// @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 /templateversions/{templateversion}/dynamic-parameters [get]
func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
templateVersion := httpmw.TemplateVersionParam(r)
func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter, r *http.Request) {
apikey := httpmw.APIKey(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
}
api.templateVersionDynamicParameters(true, codersdk.DynamicParametersRequest{
ID: -1,
Inputs: map[string]string{},
OwnerID: apikey.UserID,
})(rw, r)
}
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
}
func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.DynamicParametersRequest) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
templateVersion := httpmw.TemplateVersionParam(r)
if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) {
api.handleDynamicParameters(rw, r, tf, templateVersion)
} else {
api.handleStaticParameters(rw, r, templateVersion.ID)
// 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
}
if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) {
api.handleDynamicParameters(listen, rw, r, tf, templateVersion, initial)
} else {
api.handleStaticParameters(listen, rw, r, templateVersion.ID, initial)
}
}
}
type previewFunction func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
func (api *API) handleDynamicParameters(rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion) {
// nolint:revive
func (api *API) handleDynamicParameters(listen bool, rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion, initial codersdk.DynamicParametersRequest) {
var (
ctx = r.Context()
apikey = httpmw.APIKey(r)
@ -159,7 +191,7 @@ func (api *API) handleDynamicParameters(rw http.ResponseWriter, r *http.Request,
},
}
api.handleParameterWebsocket(rw, r, apikey.UserID, func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
dynamicRender := func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
if ownerID == uuid.Nil {
// Default to the authenticated user
// Nice for testing
@ -186,10 +218,16 @@ func (api *API) handleDynamicParameters(rw http.ResponseWriter, r *http.Request,
}
return preview.Preview(ctx, input, templateFS)
})
}
if listen {
api.handleParameterWebsocket(rw, r, initial, dynamicRender)
} else {
api.handleParameterEvaluate(rw, r, initial, dynamicRender)
}
}
func (api *API) handleStaticParameters(rw http.ResponseWriter, r *http.Request, version uuid.UUID) {
// nolint:revive
func (api *API) handleStaticParameters(listen bool, rw http.ResponseWriter, r *http.Request, version uuid.UUID, initial codersdk.DynamicParametersRequest) {
ctx := r.Context()
dbTemplateVersionParameters, err := api.Database.GetTemplateVersionParameters(ctx, version)
if err != nil {
@ -275,7 +313,7 @@ func (api *API) handleStaticParameters(rw http.ResponseWriter, r *http.Request,
params = append(params, param)
}
api.handleParameterWebsocket(rw, r, uuid.Nil, func(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
staticRender := func(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
for i := range params {
param := &params[i]
paramValue, ok := values[param.Name]
@ -297,10 +335,31 @@ func (api *API) handleStaticParameters(rw http.ResponseWriter, r *http.Request,
Detail: "To restore full functionality, please re-import the terraform as a new template version.",
},
}
})
}
if listen {
api.handleParameterWebsocket(rw, r, initial, staticRender)
} else {
api.handleParameterEvaluate(rw, r, initial, staticRender)
}
}
func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, ownerID uuid.UUID, render previewFunction) {
func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
ctx := r.Context()
// Send an initial form state, computed without any user input.
result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: 0,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = db2sdk.List(result.Parameters, db2sdk.PreviewParameter)
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
@ -320,7 +379,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
)
// Send an initial form state, computed without any user input.
result, diagnostics := render(ctx, ownerID, map[string]string{})
result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: -1, // Always start with -1.
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),