feat: implement dynamic parameter validation (#18482)

# 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`](https://github.com/coder/coder/blob/b65001e89c0577199a8e470c138c51e91cf2350c/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.
This commit is contained in:
Steven Masley
2025-06-23 12:35:15 -05:00
committed by GitHub
parent 7254c08af4
commit 82af2e019d
23 changed files with 961 additions and 159 deletions
+311
View File
@@ -1,15 +1,19 @@
package coderd_test
import (
"context"
_ "embed"
"os"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -17,6 +21,289 @@ import (
"github.com/coder/websocket"
)
func TestDynamicParameterBuild(t *testing.T) {
t.Parallel()
owner, _, _, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
orgID := first.OrganizationID
templateAdmin, templateAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID))
coderdtest.CreateGroup(t, owner, orgID, "developer")
coderdtest.CreateGroup(t, owner, orgID, "admin", templateAdminData)
coderdtest.CreateGroup(t, owner, orgID, "auditor")
// Create a set of templates to test with
numberValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
MainTF: string(must(os.ReadFile("testdata/parameters/numbers/main.tf"))),
})
regexValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
MainTF: string(must(os.ReadFile("testdata/parameters/regex/main.tf"))),
})
ephemeralValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
MainTF: string(must(os.ReadFile("testdata/parameters/ephemeral/main.tf"))),
})
// complexValidation does conditional parameters, conditional options, and more.
complexValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
MainTF: string(must(os.ReadFile("testdata/parameters/dynamic/main.tf"))),
})
t.Run("NumberValidation", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: numberValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "number", Value: `7`},
},
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID)
})
t.Run("TooLow", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: numberValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "number", Value: `-10`},
},
})
require.ErrorContains(t, err, "Number must be between 0 and 10")
})
t.Run("TooHigh", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: numberValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "number", Value: `15`},
},
})
require.ErrorContains(t, err, "Number must be between 0 and 10")
})
})
t.Run("RegexValidation", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: regexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "string", Value: `Hello World!`},
},
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID)
})
t.Run("NoValue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: regexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{},
})
require.ErrorContains(t, err, "All messages must start with 'Hello'")
})
t.Run("Invalid", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: regexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "string", Value: `Goodbye!`},
},
})
require.ErrorContains(t, err, "All messages must start with 'Hello'")
})
})
t.Run("EphemeralValidation", func(t *testing.T) {
t.Parallel()
t.Run("OK_EphemeralNoPrevious", func(t *testing.T) {
t.Parallel()
// Ephemeral params do not take the previous values into account.
ctx := testutil.Context(t, testutil.WaitShort)
wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: ephemeralValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "required", Value: `Hello World!`},
{Name: "defaulted", Value: `Changed`},
},
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID)
assertWorkspaceBuildParameters(ctx, t, templateAdmin, wrk.LatestBuild.ID, map[string]string{
"required": "Hello World!",
"defaulted": "Changed",
})
bld, err := templateAdmin.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "required", Value: `Hello World, Again!`},
},
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, bld.ID)
assertWorkspaceBuildParameters(ctx, t, templateAdmin, bld.ID, map[string]string{
"required": "Hello World, Again!",
"defaulted": "original", // Reverts back to the original default value.
})
})
t.Run("Immutable", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: numberValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "number", Value: `7`},
},
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID)
assertWorkspaceBuildParameters(ctx, t, templateAdmin, wrk.LatestBuild.ID, map[string]string{
"number": "7",
})
_, err = templateAdmin.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "number", Value: `8`},
},
})
require.ErrorContains(t, err, `Parameter "number" is not mutable`)
})
t.Run("RequiredMissing", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: ephemeralValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{},
})
require.ErrorContains(t, err, "Required parameter not provided")
})
})
t.Run("ComplexValidation", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: complexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "groups", Value: `["admin"]`},
{Name: "colors", Value: `["red"]`},
{Name: "thing", Value: "apple"},
},
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID)
})
t.Run("BadGroup", func(t *testing.T) {
// Template admin is not in the "auditor" group, so this should fail.
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: complexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "groups", Value: `["auditor", "admin"]`},
{Name: "colors", Value: `["red"]`},
{Name: "thing", Value: "apple"},
},
})
require.ErrorContains(t, err, "is not a valid option")
})
t.Run("BadColor", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: complexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "groups", Value: `["admin"]`},
{Name: "colors", Value: `["purple"]`},
},
})
require.ErrorContains(t, err, "is not a valid option")
require.ErrorContains(t, err, "purple")
})
t.Run("BadThing", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: complexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "groups", Value: `["admin"]`},
{Name: "colors", Value: `["red"]`},
{Name: "thing", Value: "leaf"},
},
})
require.ErrorContains(t, err, "must be defined as one of options")
require.ErrorContains(t, err, "leaf")
})
t.Run("BadNumber", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: complexValidation.ID,
Name: coderdtest.RandomUsername(t),
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: "groups", Value: `["admin"]`},
{Name: "colors", Value: `["green"]`},
{Name: "thing", Value: "leaf"},
{Name: "number", Value: "100"},
},
})
require.ErrorContains(t, err, "Number must be between 0 and 10")
})
})
}
// TestDynamicParameterTemplate uses a template with some dynamic elements, and
// tests the parameters, values, etc are all as expected.
func TestDynamicParameterTemplate(t *testing.T) {
@@ -127,3 +414,27 @@ func TestDynamicParameterTemplate(t *testing.T) {
coderdtest.AssertParameter(t, "thing", resp.Parameters).
Exists().Value("banana").Options("banana", "ocean", "sky")
}
func assertWorkspaceBuildParameters(ctx context.Context, t *testing.T, client *codersdk.Client, buildID uuid.UUID, values map[string]string) {
t.Helper()
params, err := client.WorkspaceBuildParameters(ctx, buildID)
require.NoError(t, err)
for name, value := range values {
param, ok := slice.Find(params, func(parameter codersdk.WorkspaceBuildParameter) bool {
return parameter.Name == name
})
if !ok {
assert.Failf(t, "parameter not found", "expected parameter %q to exist with value %q", name, value)
continue
}
assert.Equalf(t, value, param.Value, "parameter %q should have value %q", name, value)
}
for _, param := range params {
if _, ok := values[param.Name]; !ok {
assert.Failf(t, "unexpected parameter", "parameter %q should not exist", param.Name)
}
}
}