mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: fill out workspace owner data for dynamic parameters (#17366)
This commit is contained in:
Generated
+37
-29
@@ -5686,35 +5686,6 @@ 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": [
|
||||
@@ -7570,6 +7541,43 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/templateversions/{templateversion}/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": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Template version ID",
|
||||
"name": "templateversion",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"101": {
|
||||
"description": "Switching Protocols"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/webpush/subscription": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
||||
Generated
+35
-27
@@ -5029,33 +5029,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -6693,6 +6666,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/templateversions/{templateversion}/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": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Template version ID",
|
||||
"name": "templateversion",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"101": {
|
||||
"description": "Switching Protocols"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/webpush/subscription": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
||||
+11
-4
@@ -1108,10 +1108,6 @@ 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)
|
||||
@@ -1177,6 +1173,17 @@ func New(options *Options) *API {
|
||||
// organization member. This endpoint should match the authz story of
|
||||
// postWorkspacesByOrganization
|
||||
r.Post("/workspaces", api.postUserWorkspaces)
|
||||
|
||||
// Similarly to creating a workspace, evaluating parameters for a
|
||||
// new workspace should also match the authz story of
|
||||
// postWorkspacesByOrganization
|
||||
r.Route("/templateversions/{templateversion}", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractTemplateVersionParam(options.Database),
|
||||
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentDynamicParameters),
|
||||
)
|
||||
r.Get("/parameters", api.templateVersionDynamicParameters)
|
||||
})
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/preview"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// @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 /users/{user}/templateversions/{templateversion}/parameters [get]
|
||||
func (api *API) templateVersionDynamicParameters(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||||
defer cancel()
|
||||
user := httpmw.UserParam(r)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
} else if !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
|
||||
}
|
||||
|
||||
owner, err := api.getWorkspaceOwnerData(ctx, user, templateVersion.OrganizationID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace owner.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input := preview.Input{
|
||||
PlanJSON: plan,
|
||||
ParameterValues: map[string]string{},
|
||||
Owner: owner,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) getWorkspaceOwnerData(
|
||||
ctx context.Context,
|
||||
user database.User,
|
||||
organizationID uuid.UUID,
|
||||
) (previewtypes.WorkspaceOwner, error) {
|
||||
var g errgroup.Group
|
||||
|
||||
var ownerRoles []previewtypes.WorkspaceOwnerRBACRole
|
||||
g.Go(func() error {
|
||||
// nolint:gocritic // This is kind of the wrong query to use here, but it
|
||||
// matches how the provisioner currently works. We should figure out
|
||||
// something that needs less escalation but has the correct behavior.
|
||||
row, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roles, err := row.RoleNames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownerRoles = make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
|
||||
for _, it := range roles {
|
||||
if it.OrganizationID != uuid.Nil && it.OrganizationID != organizationID {
|
||||
continue
|
||||
}
|
||||
var orgID string
|
||||
if it.OrganizationID != uuid.Nil {
|
||||
orgID = it.OrganizationID.String()
|
||||
}
|
||||
ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{
|
||||
Name: it.Name,
|
||||
OrgID: orgID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
var publicKey string
|
||||
g.Go(func() error {
|
||||
key, err := api.Database.GetGitSSHKey(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey = key.PublicKey
|
||||
return nil
|
||||
})
|
||||
|
||||
var groupNames []string
|
||||
g.Go(func() error {
|
||||
groups, err := api.Database.GetGroups(ctx, database.GetGroupsParams{
|
||||
OrganizationID: organizationID,
|
||||
HasMemberID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupNames = make([]string, 0, len(groups))
|
||||
for _, it := range groups {
|
||||
groupNames = append(groupNames, it.Group.Name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := g.Wait()
|
||||
if err != nil {
|
||||
return previewtypes.WorkspaceOwner{}, err
|
||||
}
|
||||
|
||||
return previewtypes.WorkspaceOwner{
|
||||
ID: user.ID.String(),
|
||||
Name: user.Username,
|
||||
FullName: user.Name,
|
||||
Email: user.Email,
|
||||
LoginType: string(user.LoginType),
|
||||
RBACRoles: ownerRoles,
|
||||
SSHPublicKey: publicKey,
|
||||
Groups: groupNames,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
func TestDynamicParametersOwnerGroups(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, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/groups/main.tf")
|
||||
require.NoError(t, err)
|
||||
dynamicParametersTerraformPlan, err := os.ReadFile("testdata/parameters/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, templateAdminUser.ID, 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.RequireReceive(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, "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.RequireReceive(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.RequireReceive(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())
|
||||
}
|
||||
|
||||
func TestDynamicParametersOwnerSSHPublicKey(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, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/public_key/main.tf")
|
||||
require.NoError(t, err)
|
||||
dynamicParametersTerraformPlan, err := os.ReadFile("testdata/parameters/public_key/plan.json")
|
||||
require.NoError(t, err)
|
||||
sshKey, err := templateAdmin.GitSSHKey(t.Context(), "me")
|
||||
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, templateAdminUser.ID, 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.RequireReceive(ctx, t, previews)
|
||||
require.Equal(t, -1, preview.ID)
|
||||
require.Empty(t, preview.Diagnostics)
|
||||
require.Equal(t, "public_key", preview.Parameters[0].Name)
|
||||
require.True(t, preview.Parameters[0].Value.Valid())
|
||||
require.Equal(t, sshKey.PublicKey, preview.Parameters[0].Value.Value.AsString())
|
||||
}
|
||||
@@ -35,14 +35,10 @@ 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
|
||||
@@ -270,135 +266,6 @@ 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
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -28,7 +27,6 @@ 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) {
|
||||
@@ -2134,73 +2132,3 @@ 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.TryReceive(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.TryReceive(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.TryReceive(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())
|
||||
}
|
||||
|
||||
Vendored
-4
@@ -8,10 +8,6 @@ terraform {
|
||||
|
||||
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], "")
|
||||
Vendored
+6
-18
@@ -17,12 +17,12 @@
|
||||
"provider_name": "registry.terraform.io/coder/coder",
|
||||
"schema_version": 0,
|
||||
"values": {
|
||||
"id": "25e81ec3-0eb9-4ee3-8b6d-738b8552f7a9",
|
||||
"name": "default",
|
||||
"email": "default@example.com",
|
||||
"id": "",
|
||||
"name": "",
|
||||
"email": "",
|
||||
"groups": [],
|
||||
"full_name": "default",
|
||||
"login_type": null,
|
||||
"full_name": "",
|
||||
"login_type": "",
|
||||
"rbac_roles": [],
|
||||
"session_token": "",
|
||||
"ssh_public_key": "",
|
||||
@@ -74,19 +74,7 @@
|
||||
"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"]
|
||||
"attribute": ["groups"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "coder_parameter" "public_key" {
|
||||
name = "public_key"
|
||||
default = data.coder_workspace_owner.me.ssh_public_key
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"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": "",
|
||||
"name": "",
|
||||
"email": "",
|
||||
"groups": [],
|
||||
"full_name": "",
|
||||
"login_type": "",
|
||||
"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": ["ssh_public_key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user