Files
coder/coderd/parameters.go
T
Steven Masley a90ad83af3 feat!: add a hidden flag to disable dynamic parameter closure evaluation (#29356)
**What changed**
Dynamic parameter rendering now evaluates only the coder parameter blocks and what they reference. Other root-module resources are skipped. Parameter, preset, and tag values are unchanged; optimizing rendering on large templates.

**How to revert**
Set `CODER_DYNAMIC_PARAMETERS_FULL_EVALUATION=true` (or `--dynamic-parameters-full-evaluation`) on `coder server` to restore full evaluation. The flag is hidden and needs no restart of workspaces. If you need it, please [open an issue](https://github.com/coder/coder/issues/new) with the template so we can fix the root cause.

**Troubleshooting**
Symptoms would be a parameter default, option list, preset value, or workspace tag that shows as unknown or empty after upgrading, when it was populated before. Confirm by setting the flag above; if the value comes back, report it. Templates written in `.tf.json` are not affected (they always use full evaluation).

**Why**
Rendering re-ran the entire template graph on every parameter change in the workspace form, which dominated latency on large templates. Resources a workspace would create cannot feed a parameter, so evaluating them is wasted work. The optimization lives in coder/preview#226 (coder/trivy#74); this PR adds the deployment-level escape hatch.

---

- `CODER_DYNAMIC_PARAMETERS_FULL_EVALUATION` / `--dynamic-parameters-full-evaluation` (hidden, default `false`) on `DeploymentValues`.
- `dynamicparameters.PreviewOptions(dv)` maps it to `preview.OptionFullEvaluation()`; `dynamicparameters.WithPreviewOptions` threads it through the loader to `preview.Preview`.
- Wired at the three render paths that hold deployment values: the template version tags extractor, the dynamic parameters websocket, and `wsbuilder` builds created from `coderd` (which call `.DeploymentValues(...)`).

Not covered: the autobuild lifecycle executor and the enterprise prebuilds reconciler construct `wsbuilder` without deployment values, so builds started from those paths use preview's default regardless of the flag. Those only re-render for builds, not the keystroke websocket path the optimization targets.

Prepared with Coder Agents assistance.
2026-09-17 14:33:37 -05:00

207 lines
6.4 KiB
Go

package coderd
import (
"context"
"net/http"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"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 /api/v2/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 templateversion path string true "Template version ID" format(uuid)
// @Success 101
// @Router /api/v2/templateversions/{templateversion}/dynamic-parameters [get]
func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter, r *http.Request) {
apikey := httpmw.APIKey(r)
userID := apikey.UserID
qUserID := r.URL.Query().Get("user_id")
if qUserID != "" && qUserID != codersdk.Me {
uid, err := uuid.Parse(qUserID)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid user_id query parameter",
Detail: err.Error(),
})
return
}
userID = uid
}
api.templateVersionDynamicParameters(true, codersdk.DynamicParametersRequest{
ID: -1,
Inputs: map[string]string{},
OwnerID: userID,
})(rw, r)
}
// The `listen` control flag determines whether to open a websocket connection to
// handle the request or not. This same function is used to 'evaluate' a template
// as a single invocation, or to 'listen' for a back and forth interaction with
// the user to update the form as they type.
//
//nolint:revive // listen is a control flag
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)
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
dynamicparameters.WithTemplateVersion(templateVersion),
dynamicparameters.WithPreviewOptions(dynamicparameters.PreviewOptions(api.DeploymentValues)...),
)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if xerrors.Is(err, dynamicparameters.ErrTemplateVersionNotReady) {
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
Message: "Template version job has not finished",
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching template version data.",
Detail: err.Error(),
})
return
}
defer renderer.Close()
if listen {
api.handleParameterWebsocket(rw, r, initial, renderer)
} else {
api.handleParameterEvaluate(rw, r, initial, renderer)
}
}
}
func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
ctx := r.Context()
// Send an initial form state, computed without any user input.
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: 0,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = slice.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 dynamicparameters.Renderer) {
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{
Message: "Failed to accept WebSocket.",
Detail: err.Error(),
})
return
}
ctx = api.wsWatcher.Watch(ctx, api.Logger, conn)
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 := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: -1, // Always start with -1.
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
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()
ownerID := initial.OwnerID
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
}
// Take a nil uuid to mean the previous owner ID.
// This just removes the need to constantly send who you are.
if update.OwnerID == uuid.Nil {
update.OwnerID = ownerID
}
ownerID = update.OwnerID
result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs)
response := codersdk.DynamicParametersResponse{
ID: update.ID,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
err = stream.Send(response)
if err != nil {
stream.Drop()
return
}
}
}
}