mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
# What does this do?
This does parameter validation for dynamic parameters in `wsbuilder`. All input parameters are validated in `coder/coder` before being sent to terraform.
The heart of this PR is [`ResolveParameters`](b65001e89c/coderd/dynamicparameters/resolver.go (L30-L30)
).
# What else changes?
`wsbuilder` now needs to load the terraform files into memory to succeed. This does add a larger memory requirement to workspace builds.
# Future work
- Sort autostart handling workspaces by template version id. So workspaces with the same template version only load the terraform files once from the db, and store them in the cache.
421 lines
14 KiB
Go
421 lines
14 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"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/provisioner/echo"
|
|
"github.com/coder/coder/v2/provisioner/terraform"
|
|
provProto "github.com/coder/coder/v2/provisionerd/proto"
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
func TestDynamicParametersOwnerSSHPublicKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
templateAdmin, _ := 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, codersdk.Me, 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)
|
|
}
|
|
|
|
func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("OK_Modules", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
|
|
require.NoError(t, err)
|
|
|
|
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
|
|
require.NoError(t, err)
|
|
|
|
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
|
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
|
mainTF: dynamicParametersTerraformSource,
|
|
modulesArchive: modulesArchive,
|
|
plan: nil,
|
|
static: nil,
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
stream := setup.stream
|
|
previews := stream.Chan()
|
|
|
|
// Should see the output of the module represented
|
|
preview := testutil.RequireReceive(ctx, t, previews)
|
|
require.Equal(t, -1, preview.ID)
|
|
require.Empty(t, preview.Diagnostics)
|
|
|
|
require.Len(t, preview.Parameters, 2)
|
|
coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters).
|
|
Exists().Value("CL")
|
|
coderdtest.AssertParameter(t, "region", preview.Parameters).
|
|
Exists().Value("na")
|
|
})
|
|
|
|
// OldProvisioners use the static parameters in the dynamic param flow
|
|
t.Run("OldProvisioner", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const defaultValue = "PS"
|
|
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
|
provisionerDaemonVersion: "1.4",
|
|
mainTF: nil,
|
|
modulesArchive: nil,
|
|
plan: nil,
|
|
static: []*proto.RichParameter{
|
|
{
|
|
Name: "jetbrains_ide",
|
|
Type: "string",
|
|
DefaultValue: defaultValue,
|
|
Icon: "",
|
|
Options: []*proto.RichParameterOption{
|
|
{
|
|
Name: "PHPStorm",
|
|
Description: "",
|
|
Value: defaultValue,
|
|
Icon: "",
|
|
},
|
|
{
|
|
Name: "Golang",
|
|
Description: "",
|
|
Value: "GO",
|
|
Icon: "",
|
|
},
|
|
},
|
|
ValidationRegex: "[PG][SO]",
|
|
ValidationError: "Regex check",
|
|
},
|
|
},
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
stream := setup.stream
|
|
previews := stream.Chan()
|
|
|
|
// Assert the initial state
|
|
preview := testutil.RequireReceive(ctx, t, previews)
|
|
diagCount := len(preview.Diagnostics)
|
|
require.Equal(t, 1, diagCount)
|
|
require.Contains(t, preview.Diagnostics[0].Summary, "required metadata to support dynamic parameters")
|
|
require.Len(t, preview.Parameters, 1)
|
|
require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name)
|
|
require.True(t, preview.Parameters[0].Value.Valid)
|
|
require.Equal(t, defaultValue, preview.Parameters[0].Value.Value)
|
|
|
|
// Test some inputs
|
|
for _, exp := range []string{defaultValue, "GO", "Invalid", defaultValue} {
|
|
inputs := map[string]string{}
|
|
if exp != defaultValue {
|
|
// Let the default value be the default without being explicitly set
|
|
inputs["jetbrains_ide"] = exp
|
|
}
|
|
err := stream.Send(codersdk.DynamicParametersRequest{
|
|
ID: 1,
|
|
Inputs: inputs,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
preview := testutil.RequireReceive(ctx, t, previews)
|
|
diagCount := len(preview.Diagnostics)
|
|
require.Equal(t, 1, diagCount)
|
|
require.Contains(t, preview.Diagnostics[0].Summary, "required metadata to support dynamic parameters")
|
|
|
|
require.Len(t, preview.Parameters, 1)
|
|
if exp == "Invalid" { // Try an invalid option
|
|
require.Len(t, preview.Parameters[0].Diagnostics, 1)
|
|
} else {
|
|
require.Len(t, preview.Parameters[0].Diagnostics, 0)
|
|
}
|
|
require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name)
|
|
require.True(t, preview.Parameters[0].Value.Valid)
|
|
require.Equal(t, exp, preview.Parameters[0].Value.Value)
|
|
}
|
|
})
|
|
|
|
t.Run("FileError", func(t *testing.T) {
|
|
// Verify files close even if the websocket terminates from an error
|
|
t.Parallel()
|
|
|
|
db, ps := dbtestutil.NewDB(t)
|
|
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
|
|
require.NoError(t, err)
|
|
|
|
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
|
|
require.NoError(t, err)
|
|
|
|
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
|
db: &dbRejectGitSSHKey{Store: db},
|
|
ps: ps,
|
|
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
|
mainTF: dynamicParametersTerraformSource,
|
|
modulesArchive: modulesArchive,
|
|
})
|
|
|
|
stream := setup.stream
|
|
previews := stream.Chan()
|
|
|
|
// Assert the failed owner
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
preview := testutil.RequireReceive(ctx, t, previews)
|
|
require.Len(t, preview.Diagnostics, 1)
|
|
require.Equal(t, preview.Diagnostics[0].Summary, "Failed to fetch workspace owner")
|
|
})
|
|
|
|
t.Run("RebuildParameters", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
|
|
require.NoError(t, err)
|
|
|
|
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
|
|
require.NoError(t, err)
|
|
|
|
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
|
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
|
mainTF: dynamicParametersTerraformSource,
|
|
modulesArchive: modulesArchive,
|
|
plan: nil,
|
|
static: nil,
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
stream := setup.stream
|
|
previews := stream.Chan()
|
|
|
|
// Should see the output of the module represented
|
|
preview := testutil.RequireReceive(ctx, t, previews)
|
|
require.Equal(t, -1, preview.ID)
|
|
require.Empty(t, preview.Diagnostics)
|
|
|
|
require.Len(t, preview.Parameters, 2)
|
|
coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters).
|
|
Exists().Value("CL")
|
|
coderdtest.AssertParameter(t, "region", preview.Parameters).
|
|
Exists().Value("na")
|
|
_ = stream.Close(websocket.StatusGoingAway)
|
|
|
|
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) {
|
|
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
|
{
|
|
Name: preview.Parameters[0].Name,
|
|
Value: "GO",
|
|
},
|
|
{
|
|
Name: preview.Parameters[1].Name,
|
|
Value: "eu",
|
|
},
|
|
}
|
|
request.EnableDynamicParameters = true
|
|
})
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
|
|
|
|
params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, []codersdk.WorkspaceBuildParameter{
|
|
{Name: "jetbrains_ide", Value: "GO"}, {Name: "region", Value: "eu"},
|
|
}, params)
|
|
|
|
regionOptions := []string{"na", "af", "sa", "as"}
|
|
|
|
// A helper function to assert params
|
|
doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) {
|
|
t.Helper()
|
|
|
|
regionVal := regionOptions[0]
|
|
regionOptions = regionOptions[1:] // Choose the next region on the next build
|
|
|
|
bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
|
|
TemplateVersionID: setup.template.ActiveVersionID,
|
|
Transition: trans,
|
|
RichParameterValues: []codersdk.WorkspaceBuildParameter{
|
|
{Name: "region", Value: regionVal},
|
|
},
|
|
EnableDynamicParameters: ptr.Ref(true),
|
|
})
|
|
require.NoError(t, err)
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, bld.ID)
|
|
|
|
latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{
|
|
{Name: "jetbrains_ide", Value: "GO"},
|
|
{Name: "region", Value: regionVal},
|
|
})
|
|
}
|
|
|
|
// Restart the workspace, then delete. Asserting params on all builds.
|
|
doTransition(t, codersdk.WorkspaceTransitionStop)
|
|
doTransition(t, codersdk.WorkspaceTransitionStart)
|
|
doTransition(t, codersdk.WorkspaceTransitionDelete)
|
|
})
|
|
|
|
t.Run("BadOwner", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
|
|
require.NoError(t, err)
|
|
|
|
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
|
|
require.NoError(t, err)
|
|
|
|
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
|
|
provisionerDaemonVersion: provProto.CurrentVersion.String(),
|
|
mainTF: dynamicParametersTerraformSource,
|
|
modulesArchive: modulesArchive,
|
|
plan: nil,
|
|
static: nil,
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
stream := setup.stream
|
|
previews := stream.Chan()
|
|
|
|
// Should see the output of the module represented
|
|
preview := testutil.RequireReceive(ctx, t, previews)
|
|
require.Equal(t, -1, preview.ID)
|
|
require.Empty(t, preview.Diagnostics)
|
|
|
|
err = stream.Send(codersdk.DynamicParametersRequest{
|
|
ID: 1,
|
|
Inputs: map[string]string{
|
|
"jetbrains_ide": "GO",
|
|
},
|
|
OwnerID: uuid.New(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
preview = testutil.RequireReceive(ctx, t, previews)
|
|
require.Equal(t, 1, preview.ID)
|
|
require.Len(t, preview.Diagnostics, 1)
|
|
require.Equal(t, preview.Diagnostics[0].Extra.Code, "owner_not_found")
|
|
})
|
|
}
|
|
|
|
type setupDynamicParamsTestParams struct {
|
|
db database.Store
|
|
ps pubsub.Pubsub
|
|
provisionerDaemonVersion string
|
|
mainTF []byte
|
|
modulesArchive []byte
|
|
plan []byte
|
|
|
|
static []*proto.RichParameter
|
|
expectWebsocketError bool
|
|
}
|
|
|
|
type dynamicParamsTest struct {
|
|
client *codersdk.Client
|
|
api *coderd.API
|
|
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
|
|
template codersdk.Template
|
|
}
|
|
|
|
func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest {
|
|
ownerClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
|
Database: args.db,
|
|
Pubsub: args.ps,
|
|
IncludeProvisionerDaemon: true,
|
|
ProvisionerDaemonVersion: args.provisionerDaemonVersion,
|
|
})
|
|
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
|
|
|
tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
|
MainTF: string(args.mainTF),
|
|
Plan: args.plan,
|
|
ModulesArchive: args.modulesArchive,
|
|
StaticParams: args.static,
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID)
|
|
if args.expectWebsocketError {
|
|
require.Errorf(t, err, "expected error forming websocket")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
if stream != nil {
|
|
_ = stream.Close(websocket.StatusGoingAway)
|
|
}
|
|
// Cache should always have 0 files when the only stream is closed
|
|
require.Eventually(t, func() bool {
|
|
return api.FileCache.Count() == 0
|
|
}, testutil.WaitShort/5, testutil.IntervalMedium)
|
|
})
|
|
|
|
return dynamicParamsTest{
|
|
client: ownerClient,
|
|
api: api,
|
|
stream: stream,
|
|
template: tpl,
|
|
}
|
|
}
|
|
|
|
// dbRejectGitSSHKey is a cheeky way to force an error to occur in a place
|
|
// that is generally impossible to force an error.
|
|
type dbRejectGitSSHKey struct {
|
|
database.Store
|
|
}
|
|
|
|
func (*dbRejectGitSSHKey) GetGitSSHKey(_ context.Context, _ uuid.UUID) (database.GitSSHKey, error) {
|
|
return database.GitSSHKey{}, xerrors.New("forcing a fake error")
|
|
}
|