feat: add dynamic parameters websocket endpoint (#17165)

This commit is contained in:
ケイラ
2025-04-10 13:08:50 -07:00
committed by GitHub
parent c9682cb6cf
commit 859dd2fc3f
19 changed files with 2291 additions and 347 deletions

97
coderd/apidoc/docs.go generated
View File

@ -5764,6 +5764,35 @@ const docTemplate = `{
}
}
},
"/templateversions/{templateversion}/dynamic-parameters": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Templates"
],
"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": "templateversion",
"in": "path",
"required": true
}
],
"responses": {
"101": {
"description": "Switching Protocols"
}
}
}
},
"/templateversions/{templateversion}/external-auth": {
"get": {
"security": [
@ -11332,73 +11361,7 @@ const docTemplate = `{
}
},
"codersdk.CreateTestAuditLogRequest": {
"type": "object",
"properties": {
"action": {
"enum": [
"create",
"write",
"delete",
"start",
"stop"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.AuditAction"
}
]
},
"additional_fields": {
"type": "array",
"items": {
"type": "integer"
}
},
"build_reason": {
"enum": [
"autostart",
"autostop",
"initiator"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.BuildReason"
}
]
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"request_id": {
"type": "string",
"format": "uuid"
},
"resource_id": {
"type": "string",
"format": "uuid"
},
"resource_type": {
"enum": [
"template",
"template_version",
"user",
"workspace",
"workspace_build",
"git_ssh_key",
"auditable_group"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ResourceType"
}
]
},
"time": {
"type": "string",
"format": "date-time"
}
}
"type": "object"
},
"codersdk.CreateTokenRequest": {
"type": "object",

View File

@ -5097,6 +5097,33 @@
}
}
},
"/templateversions/{templateversion}/dynamic-parameters": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Templates"],
"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": "templateversion",
"in": "path",
"required": true
}
],
"responses": {
"101": {
"description": "Switching Protocols"
}
}
}
},
"/templateversions/{templateversion}/external-auth": {
"get": {
"security": [
@ -10100,63 +10127,7 @@
}
},
"codersdk.CreateTestAuditLogRequest": {
"type": "object",
"properties": {
"action": {
"enum": ["create", "write", "delete", "start", "stop"],
"allOf": [
{
"$ref": "#/definitions/codersdk.AuditAction"
}
]
},
"additional_fields": {
"type": "array",
"items": {
"type": "integer"
}
},
"build_reason": {
"enum": ["autostart", "autostop", "initiator"],
"allOf": [
{
"$ref": "#/definitions/codersdk.BuildReason"
}
]
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"request_id": {
"type": "string",
"format": "uuid"
},
"resource_id": {
"type": "string",
"format": "uuid"
},
"resource_type": {
"enum": [
"template",
"template_version",
"user",
"workspace",
"workspace_build",
"git_ssh_key",
"auditable_group"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ResourceType"
}
]
},
"time": {
"type": "string",
"format": "date-time"
}
}
"type": "object"
},
"codersdk.CreateTokenRequest": {
"type": "object",

View File

@ -43,6 +43,7 @@ import (
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/webpush"
@ -557,6 +558,7 @@ func New(options *Options) *API {
TemplateScheduleStore: options.TemplateScheduleStore,
UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore,
AccessControlStore: options.AccessControlStore,
FileCache: files.NewFromStore(options.Database),
Experiments: experiments,
WebpushDispatcher: options.WebPushDispatcher,
healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{},
@ -1096,6 +1098,10 @@ func New(options *Options) *API {
// The idea is to return an empty [], so that the coder CLI won't get blocked accidentally.
r.Get("/schema", templateVersionSchemaDeprecated)
r.Get("/parameters", templateVersionParametersDeprecated)
r.Group(func(r chi.Router) {
r.Use(httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentDynamicParameters))
r.Get("/dynamic-parameters", api.templateVersionDynamicParameters)
})
r.Get("/rich-parameters", api.templateVersionRichParameters)
r.Get("/external-auth", api.templateVersionExternalAuth)
r.Get("/variables", api.templateVersionVariables)
@ -1545,6 +1551,7 @@ type API struct {
// passed to dbauthz.
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
PortSharer atomic.Pointer[portsharing.PortSharer]
FileCache files.Cache
UpdatesProvider tailnet.WorkspaceUpdatesProvider

View File

@ -35,10 +35,14 @@ import (
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/examples"
"github.com/coder/coder/v2/provisioner/terraform/tfparse"
"github.com/coder/coder/v2/provisionersdk"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
"github.com/coder/websocket"
)
// @Summary Get template version by ID
@ -266,6 +270,135 @@ func (api *API) patchCancelTemplateVersion(rw http.ResponseWriter, r *http.Reque
})
}
// @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 /templateversions/{templateversion}/dynamic-parameters [get]
func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
templateVersion := httpmw.TemplateVersionParam(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
}
// Having the Terraform plan available for the evaluation engine is helpful
// 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 {
plan = tf.CachedPlan
}
input := preview.Input{
PlanJSON: plan,
ParameterValues: map[string]string{},
// TODO: write a db query that fetches all of the data needed to fill out
// this owner value
Owner: previewtypes.WorkspaceOwner{
Groups: []string{"Everyone"},
},
}
// 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)
fileID, err := api.Database.GetFileIDByTemplateVersionID(fileCtx, templateVersion.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error finding template version Terraform.",
Detail: err.Error(),
})
return
}
fs, err := api.FileCache.Acquire(fileCtx, fileID)
defer api.FileCache.Release(fileID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Internal error fetching template version Terraform.",
Detail: err.Error(),
})
return
}
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
}
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 := preview.Preview(ctx, input, fs)
response := codersdk.DynamicParametersResponse{
ID: -1,
Diagnostics: previewtypes.Diagnostics(diagnostics),
}
if result != nil {
response.Parameters = result.Parameters
}
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()
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
}
input.ParameterValues = update.Inputs
result, diagnostics := preview.Preview(ctx, input, fs)
response := codersdk.DynamicParametersResponse{
ID: update.ID,
Diagnostics: previewtypes.Diagnostics(diagnostics),
}
if result != nil {
response.Parameters = result.Parameters
}
err = stream.Send(response)
if err != nil {
stream.Drop()
return
}
}
}
}
// @Summary Get rich parameters by template version
// @ID get-rich-parameters-by-template-version
// @Security CoderSessionToken
@ -287,8 +420,8 @@ func (api *API) templateVersionRichParameters(rw http.ResponseWriter, r *http.Re
return
}
if !job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Job hasn't completed!",
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
Message: "Template version job has not finished",
})
return
}
@ -428,7 +561,7 @@ func (api *API) templateVersionVariables(rw http.ResponseWriter, r *http.Request
}
if !job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Job hasn't completed!",
Message: "Template version job has not finished",
})
return
}
@ -483,7 +616,7 @@ func (api *API) postTemplateVersionDryRun(rw http.ResponseWriter, r *http.Reques
return
}
if !job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
Message: "Template version import job hasn't completed!",
})
return

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"net/http"
"os"
"regexp"
"strings"
"testing"
@ -27,6 +28,7 @@ import (
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
"github.com/coder/websocket"
)
func TestTemplateVersion(t *testing.T) {
@ -1207,7 +1209,7 @@ func TestTemplateVersionDryRun(t *testing.T) {
_, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Equal(t, http.StatusTooEarly, apiErr.StatusCode())
})
t.Run("Cancel", func(t *testing.T) {
@ -2056,11 +2058,7 @@ func TestTemplateArchiveVersions(t *testing.T) {
// Create some unused versions
for i := 0; i < 2; i++ {
unused := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ApplyComplete,
}, func(req *codersdk.CreateTemplateVersionRequest) {
unused := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(req *codersdk.CreateTemplateVersionRequest) {
req.TemplateID = template.ID
})
expArchived = append(expArchived, unused.ID)
@ -2069,11 +2067,7 @@ func TestTemplateArchiveVersions(t *testing.T) {
// Create some used template versions
for i := 0; i < 2; i++ {
used := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ApplyComplete,
}, func(req *codersdk.CreateTemplateVersionRequest) {
used := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(req *codersdk.CreateTemplateVersionRequest) {
req.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, used.ID)
@ -2140,3 +2134,73 @@ func TestTemplateArchiveVersions(t *testing.T) {
require.NoError(t, err, "fetch all versions")
require.Len(t, remaining, totalVersions-len(expArchived)-len(allFailed)+1, "remaining versions")
}
func TestTemplateVersionDynamicParameters(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentValues(t)
cfg.Experiments = []string{string(codersdk.ExperimentDynamicParameters)}
ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, DeploymentValues: cfg})
owner := coderdtest.CreateFirstUser(t, ownerClient)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
dynamicParametersTerraformSource, err := os.ReadFile("testdata/dynamicparameters/groups/main.tf")
require.NoError(t, err)
dynamicParametersTerraformPlan, err := os.ReadFile("testdata/dynamicparameters/groups/plan.json")
require.NoError(t, err)
files := echo.WithExtraFiles(map[string][]byte{
"main.tf": dynamicParametersTerraformSource,
})
files.ProvisionPlan = []*proto.Response{{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Plan: dynamicParametersTerraformPlan,
},
},
}}
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
_ = coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
ctx := testutil.Context(t, testutil.WaitShort)
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, version.ID)
require.NoError(t, err)
defer stream.Close(websocket.StatusGoingAway)
previews := stream.Chan()
// Should automatically send a form state with all defaulted/empty values
preview := testutil.RequireRecvCtx(ctx, t, previews)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
require.True(t, preview.Parameters[0].Value.Valid())
require.Equal(t, "Everyone", preview.Parameters[0].Value.Value.AsString())
// Send a new value, and see it reflected
err = stream.Send(codersdk.DynamicParametersRequest{
ID: 1,
Inputs: map[string]string{"group": "Bloob"},
})
require.NoError(t, err)
preview = testutil.RequireRecvCtx(ctx, t, previews)
require.Equal(t, 1, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
require.True(t, preview.Parameters[0].Value.Valid())
require.Equal(t, "Bloob", preview.Parameters[0].Value.Value.AsString())
// Back to default
err = stream.Send(codersdk.DynamicParametersRequest{
ID: 3,
Inputs: map[string]string{},
})
require.NoError(t, err)
preview = testutil.RequireRecvCtx(ctx, t, previews)
require.Equal(t, 3, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
require.True(t, preview.Parameters[0].Value.Valid())
require.Equal(t, "Everyone", preview.Parameters[0].Value.Value.AsString())
}

View File

@ -0,0 +1,25 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_workspace_owner" "me" {}
output "groups" {
value = data.coder_workspace_owner.me.groups
}
data "coder_parameter" "group" {
name = "group"
default = try(data.coder_workspace_owner.me.groups[0], "")
dynamic "option" {
for_each = data.coder_workspace_owner.me.groups
content {
name = option.value
value = option.value
}
}
}

View File

@ -0,0 +1,92 @@
{
"terraform_version": "1.11.2",
"format_version": "1.2",
"checks": [],
"complete": true,
"timestamp": "2025-04-02T01:29:59Z",
"variables": {},
"prior_state": {
"values": {
"root_module": {
"resources": [
{
"mode": "data",
"name": "me",
"type": "coder_workspace_owner",
"address": "data.coder_workspace_owner.me",
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"id": "25e81ec3-0eb9-4ee3-8b6d-738b8552f7a9",
"name": "default",
"email": "default@example.com",
"groups": [],
"full_name": "default",
"login_type": null,
"rbac_roles": [],
"session_token": "",
"ssh_public_key": "",
"ssh_private_key": "",
"oidc_access_token": ""
},
"sensitive_values": {
"groups": [],
"rbac_roles": [],
"ssh_private_key": true
}
}
],
"child_modules": []
}
},
"format_version": "1.0",
"terraform_version": "1.11.2"
},
"configuration": {
"root_module": {
"resources": [
{
"mode": "data",
"name": "me",
"type": "coder_workspace_owner",
"address": "data.coder_workspace_owner.me",
"schema_version": 0,
"provider_config_key": "coder"
}
],
"variables": {},
"module_calls": {}
},
"provider_config": {
"coder": {
"name": "coder",
"full_name": "registry.terraform.io/coder/coder"
}
}
},
"planned_values": {
"root_module": {
"resources": [],
"child_modules": []
}
},
"resource_changes": [],
"relevant_attributes": [
{
"resource": "data.coder_workspace_owner.me",
"attribute": ["full_name"]
},
{
"resource": "data.coder_workspace_owner.me",
"attribute": ["email"]
},
{
"resource": "data.coder_workspace_owner.me",
"attribute": ["id"]
},
{
"resource": "data.coder_workspace_owner.me",
"attribute": ["name"]
}
]
}