feat: make dynamic parameters opt-in by default for new templates (#19006)

resolves #18975 

---------

Co-authored-by: Steven Masley <stevenmasley@gmail.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
This commit is contained in:
Jaayden Halko
2025-07-29 03:41:49 +02:00
committed by GitHub
parent faac75389b
commit 1320b8d5be
35 changed files with 476 additions and 91 deletions
+9
View File
@@ -51,8 +51,17 @@ func (r *RootCmd) restart() *serpent.Command {
return err
}
stopParamValues, err := asWorkspaceBuildParameters(parameterFlags.ephemeralParameters)
if err != nil {
return xerrors.Errorf("parse ephemeral parameters: %w", err)
}
wbr := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
// Ephemeral parameters should be passed to both stop and start builds.
// TODO: maybe these values should be sourced from the previous build?
// It has to be manually sourced, as ephemeral parameters do not carry across
// builds.
RichParameterValues: stopParamValues,
}
if bflags.provisionerLogDebug {
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
+27 -6
View File
@@ -10,6 +10,7 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
@@ -70,8 +71,14 @@ func TestRestart(t *testing.T) {
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(request *codersdk.CreateTemplateRequest) {
request.UseClassicParameterFlow = ptr.Ref(true) // TODO: Remove when dynamic parameters prompt missing ephemeral parameters.
})
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "placeholder"},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "restart", workspace.Name, "--prompt-ephemeral-parameters")
@@ -125,7 +132,11 @@ func TestRestart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "placeholder"},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "restart", workspace.Name,
@@ -178,8 +189,14 @@ func TestRestart(t *testing.T) {
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(request *codersdk.CreateTemplateRequest) {
request.UseClassicParameterFlow = ptr.Ref(true) // TODO: Remove when dynamic parameters prompts missing ephemeral parameters
})
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "placeholder"},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "restart", workspace.Name, "--build-options")
@@ -233,7 +250,11 @@ func TestRestart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "placeholder"},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "restart", workspace.Name,
+20 -4
View File
@@ -113,10 +113,18 @@ func TestStart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "foo"}, // Value is required, set it to something
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop, func(request *codersdk.CreateWorkspaceBuildRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "foo"}, // Value is required, set it to something
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
inv, root := clitest.New(t, "start", workspace.Name, "--prompt-ephemeral-parameters")
@@ -167,10 +175,18 @@ func TestStart(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "foo"}, // Value is required, set it to something
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop, func(request *codersdk.CreateWorkspaceBuildRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: ephemeralParameterName, Value: "foo"}, // Value is required, set it to something
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
inv, root := clitest.New(t, "start", workspace.Name,
+1
View File
@@ -509,6 +509,7 @@ func TestTemplatePush(t *testing.T) {
default = "1"
}
data "coder_parameter" "b" {
name = "b"
type = string
default = "2"
}
+1 -1
View File
@@ -15,7 +15,7 @@
"template_allow_user_cancel_workspace_jobs": false,
"template_active_version_id": "============[version ID]============",
"template_require_active_version": false,
"template_use_classic_parameter_flow": true,
"template_use_classic_parameter_flow": false,
"latest_build": {
"id": "========[workspace build ID]========",
"created_at": "====[timestamp]=====",
+4 -2
View File
@@ -182,7 +182,7 @@ func TestUpdateWithRichParameters(t *testing.T) {
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
{Name: secondParameterName, Description: secondParameterDescription, Mutable: true},
{Name: ephemeralParameterName, Description: ephemeralParameterDescription, Mutable: true, Ephemeral: true},
{Name: ephemeralParameterName, Description: ephemeralParameterDescription, Mutable: true, Ephemeral: true, DefaultValue: "unset"},
})
}
@@ -811,7 +811,9 @@ func TestUpdateValidateRichParameters(t *testing.T) {
}
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(templateParameters))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(request *codersdk.CreateTemplateRequest) {
request.UseClassicParameterFlow = ptr.Ref(true) // TODO: Remove when dynamic parameters can pass this test
})
// Create new workspace
inv, root := clitest.New(t, "create", "my-workspace", "--yes", "--template", template.Name, "--parameter", fmt.Sprintf("%s=%s", numberParameterName, tempVal))
+1 -1
View File
@@ -147,7 +147,7 @@ func Template(t testing.TB, db database.Store, seed database.Template) database.
DisplayName: takeFirst(seed.DisplayName, testutil.GetRandomName(t)),
AllowUserCancelWorkspaceJobs: seed.AllowUserCancelWorkspaceJobs,
MaxPortSharingLevel: takeFirst(seed.MaxPortSharingLevel, database.AppSharingLevelOwner),
UseClassicParameterFlow: takeFirst(seed.UseClassicParameterFlow, true),
UseClassicParameterFlow: takeFirst(seed.UseClassicParameterFlow, false),
})
require.NoError(t, err, "insert template")
+1 -1
View File
@@ -1750,7 +1750,7 @@ CREATE TABLE templates (
deprecated text DEFAULT ''::text NOT NULL,
activity_bump bigint DEFAULT '3600000000000'::bigint NOT NULL,
max_port_sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL,
use_classic_parameter_flow boolean DEFAULT true NOT NULL
use_classic_parameter_flow boolean DEFAULT false NOT NULL
);
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
@@ -0,0 +1 @@
ALTER TABLE templates ALTER COLUMN use_classic_parameter_flow SET DEFAULT true;
@@ -0,0 +1 @@
ALTER TABLE templates ALTER COLUMN use_classic_parameter_flow SET DEFAULT false;
+10 -8
View File
@@ -665,10 +665,11 @@ func TestTemplateInsights_Golden(t *testing.T) {
// where we can control the template ID.
// createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
createdTemplate := dbgen.Template(t, db, database.Template{
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
UseClassicParameterFlow: true, // Required for testing classic parameter flow behavior
GroupACL: database.TemplateACL{
firstUser.OrganizationID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse),
},
@@ -1556,10 +1557,11 @@ func TestUserActivityInsights_Golden(t *testing.T) {
// where we can control the template ID.
// createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
createdTemplate := dbgen.Template(t, db, database.Template{
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
UseClassicParameterFlow: true, // Required for parameter usage tracking in this test
GroupACL: database.TemplateACL{
firstUser.OrganizationID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse),
},
+34 -3
View File
@@ -3,10 +3,12 @@ package coderd_test
import (
"context"
"os"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd"
@@ -199,8 +201,15 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
require.NoError(t, err)
c := atomic.NewInt32(0)
reject := &dbRejectGitSSHKey{Store: db, hook: func(d *dbRejectGitSSHKey) {
if c.Add(1) > 1 {
// Second call forward, reject
d.SetReject(true)
}
}}
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
db: &dbRejectGitSSHKey{Store: db},
db: reject,
ps: ps,
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
@@ -444,8 +453,30 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
// that is generally impossible to force an error.
type dbRejectGitSSHKey struct {
database.Store
rejectMu sync.RWMutex
reject bool
hook func(d *dbRejectGitSSHKey)
}
func (*dbRejectGitSSHKey) GetGitSSHKey(_ context.Context, _ uuid.UUID) (database.GitSSHKey, error) {
return database.GitSSHKey{}, xerrors.New("forcing a fake error")
// SetReject toggles whether GetGitSSHKey should return an error or passthrough to the underlying store.
func (d *dbRejectGitSSHKey) SetReject(reject bool) {
d.rejectMu.Lock()
defer d.rejectMu.Unlock()
d.reject = reject
}
func (d *dbRejectGitSSHKey) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
if d.hook != nil {
d.hook(d)
}
d.rejectMu.RLock()
reject := d.reject
d.rejectMu.RUnlock()
if reject {
return database.GitSSHKey{}, xerrors.New("forcing a fake error")
}
return d.Store.GetGitSSHKey(ctx, userID)
}
+2 -2
View File
@@ -197,8 +197,8 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
return
}
// Default is true until dynamic parameters are promoted to stable.
useClassicParameterFlow := ptr.NilToDefault(createTemplate.UseClassicParameterFlow, true)
// Default is false as dynamic parameters are now the preferred approach.
useClassicParameterFlow := ptr.NilToDefault(createTemplate.UseClassicParameterFlow, false)
// Make a temporary struct to represent the template. This is used for
// auditing if any of the following checks fail. It will be overwritten when
+2 -2
View File
@@ -77,7 +77,7 @@ func TestPostTemplateByOrganization(t *testing.T) {
assert.Equal(t, expected.Name, got.Name)
assert.Equal(t, expected.Description, got.Description)
assert.Equal(t, expected.ActivityBumpMillis, got.ActivityBumpMillis)
assert.Equal(t, expected.UseClassicParameterFlow, true) // Current default is true
assert.Equal(t, expected.UseClassicParameterFlow, false) // Current default is false
require.Len(t, auditor.AuditLogs(), 3)
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[0].Action)
@@ -1551,7 +1551,7 @@ func TestPatchTemplateMeta(t *testing.T) {
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.True(t, template.UseClassicParameterFlow, "default is true")
require.False(t, template.UseClassicParameterFlow, "default is false")
bTrue := true
bFalse := false
+1 -1
View File
@@ -1471,7 +1471,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
return
}
var dynamicTemplate bool
dynamicTemplate := true // Default to using dynamic templates
if req.TemplateID != uuid.Nil {
tpl, err := api.Database.GetTemplateByID(ctx, req.TemplateID)
if httpapi.Is404Error(err) {
+19 -40
View File
@@ -275,6 +275,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
files map[string]string
reqTags map[string]string
wantTags map[string]string
variables []codersdk.VariableValue
expectError string
}{
{
@@ -290,6 +291,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
default = "1"
}
data "coder_parameter" "b" {
name = "b"
type = string
default = "2"
}
@@ -311,6 +313,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
default = "1"
}
data "coder_parameter" "b" {
name = "b"
type = string
default = "2"
}
@@ -335,6 +338,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
default = "1"
}
data "coder_parameter" "b" {
name = "b"
type = string
default = "2"
}
@@ -365,6 +369,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
default = "1"
}
data "coder_parameter" "b" {
name = "b"
type = string
default = "2"
}
@@ -395,6 +400,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
default = "1"
}
data "coder_parameter" "b" {
name = "b"
type = string
default = "2"
}
@@ -429,11 +435,12 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
}
}`,
},
reqTags: map[string]string{"a": "b"},
wantTags: map[string]string{"owner": "", "scope": "organization", "a": "b"},
reqTags: map[string]string{"a": "b"},
wantTags: map[string]string{"owner": "", "scope": "organization", "a": "b"},
variables: []codersdk.VariableValue{{Name: "a", Value: "b"}},
},
{
name: "main.tf with disallowed workspace tag value",
name: "main.tf with resource reference",
files: map[string]string{
`main.tf`: `
variable "a" {
@@ -441,6 +448,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
default = "1"
}
data "coder_parameter" "b" {
name = "b"
type = string
default = "2"
}
@@ -461,38 +469,8 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
}
}`,
},
expectError: `Unknown variable; There is no variable named "null_resource".`,
},
{
name: "main.tf with disallowed function in tag value",
files: map[string]string{
`main.tf`: `
variable "a" {
type = string
default = "1"
}
data "coder_parameter" "b" {
type = string
default = "2"
}
data "coder_parameter" "unrelated" {
name = "unrelated"
type = "list(string)"
default = jsonencode(["a", "b"])
}
resource "null_resource" "test" {
name = "foo"
}
data "coder_workspace_tags" "tags" {
tags = {
"foo": "bar",
"a": var.a,
"b": data.coder_parameter.b.value,
"test": pathexpand("~/file.txt"),
}
}`,
},
expectError: `function "pathexpand" may not be used here`,
reqTags: map[string]string{"foo": "bar", "a": "1", "b": "2", "test": "foo"},
wantTags: map[string]string{"owner": "", "scope": "organization", "foo": "bar", "a": "1", "b": "2", "test": "foo"},
},
// We will allow coder_workspace_tags to set the scope on a template version import job
// BUT the user ID will be ultimately determined by the API key in the scope.
@@ -618,11 +596,12 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
// Create a template version from the archive
tvName := testutil.GetRandomNameHyphenated(t)
tv, err := templateAdmin.CreateTemplateVersion(ctx, owner.OrganizationID, codersdk.CreateTemplateVersionRequest{
Name: tvName,
StorageMethod: codersdk.ProvisionerStorageMethodFile,
Provisioner: codersdk.ProvisionerTypeTerraform,
FileID: fi.ID,
ProvisionerTags: tt.reqTags,
Name: tvName,
StorageMethod: codersdk.ProvisionerStorageMethodFile,
Provisioner: codersdk.ProvisionerTypeTerraform,
FileID: fi.ID,
ProvisionerTags: tt.reqTags,
UserVariableValues: tt.variables,
})
if tt.expectError == "" {
+6 -4
View File
@@ -431,9 +431,9 @@ func TestWorkspace(t *testing.T) {
// Test Utility variables
templateVersionParameters := []*proto.RichParameter{
{Name: "param1", Type: "string", Required: false},
{Name: "param2", Type: "string", Required: false},
{Name: "param3", Type: "string", Required: false},
{Name: "param1", Type: "string", Required: false, DefaultValue: "default1"},
{Name: "param2", Type: "string", Required: false, DefaultValue: "default2"},
{Name: "param3", Type: "string", Required: false, DefaultValue: "default3"},
}
presetParameters := []*proto.PresetParameter{
{Name: "param1", Value: "value1"},
@@ -3842,7 +3842,9 @@ func TestWorkspaceWithEphemeralRichParameters(t *testing.T) {
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(request *codersdk.CreateTemplateRequest) {
request.UseClassicParameterFlow = ptr.Ref(true) // TODO: Remove this when dynamic parameters handles this case
})
// Create workspace with default values
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
+103
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"text/template"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -377,6 +378,45 @@ func TarWithOptions(ctx context.Context, logger slog.Logger, responses *Response
logger.Debug(context.Background(), "extra file written", slog.F("name", name), slog.F("bytes_written", n))
}
// Write a main.tf with the appropriate parameters. This is to write terraform
// that matches the parameters defined in the responses. Dynamic parameters
// parsed these, even in the echo provisioner.
var mainTF bytes.Buffer
for _, respPlan := range responses.ProvisionPlan {
plan := respPlan.GetPlan()
if plan == nil {
continue
}
for _, param := range plan.Parameters {
paramTF, err := ParameterTerraform(param)
if err != nil {
return nil, xerrors.Errorf("parameter terraform: %w", err)
}
_, _ = mainTF.WriteString(paramTF)
}
}
if mainTF.Len() > 0 {
mainTFData := `
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
` + mainTF.String()
_ = writer.WriteHeader(&tar.Header{
Name: `main.tf`,
Size: int64(len(mainTFData)),
Mode: 0o644,
})
_, _ = writer.Write([]byte(mainTFData))
}
// `writer.Close()` function flushes the writer buffer, and adds extra padding to create a legal tarball.
err := writer.Close()
if err != nil {
@@ -385,6 +425,69 @@ func TarWithOptions(ctx context.Context, logger slog.Logger, responses *Response
return buffer.Bytes(), nil
}
// ParameterTerraform will create a Terraform data block for the provided parameter.
func ParameterTerraform(param *proto.RichParameter) (string, error) {
tmpl := template.Must(template.New("parameter").Funcs(map[string]any{
"showValidation": func(v *proto.RichParameter) bool {
return v != nil && (v.ValidationMax != nil || v.ValidationMin != nil ||
v.ValidationError != "" || v.ValidationRegex != "" ||
v.ValidationMonotonic != "")
},
"formType": func(v *proto.RichParameter) string {
s, _ := proto.ProviderFormType(v.FormType)
return string(s)
},
}).Parse(`
data "coder_parameter" "{{ .Name }}" {
name = "{{ .Name }}"
display_name = "{{ .DisplayName }}"
description = "{{ .Description }}"
icon = "{{ .Icon }}"
mutable = {{ .Mutable }}
ephemeral = {{ .Ephemeral }}
order = {{ .Order }}
{{- if .DefaultValue }}
default = {{ .DefaultValue }}
{{- end }}
{{- if .Type }}
type = "{{ .Type }}"
{{- end }}
{{- if .FormType }}
form_type = "{{ formType . }}"
{{- end }}
{{- range .Options }}
option {
name = "{{ .Name }}"
value = "{{ .Value }}"
}
{{- end }}
{{- if showValidation .}}
validation {
{{- if .ValidationRegex }}
regex = "{{ .ValidationRegex }}"
{{- end }}
{{- if .ValidationError }}
error = "{{ .ValidationError }}"
{{- end }}
{{- if .ValidationMin }}
min = {{ .ValidationMin }}
{{- end }}
{{- if .ValidationMax }}
max = {{ .ValidationMax }}
{{- end }}
{{- if .ValidationMonotonic }}
monotonic = "{{ .ValidationMonotonic }}"
{{- end }}
}
{{- end }}
}
`))
var buf bytes.Buffer
err := tmpl.Execute(&buf, param)
return buf.String(), err
}
func WithResources(resources []*proto.Resource) *Responses {
return &Responses{
Parse: ParseComplete,
+33
View File
@@ -1203,3 +1203,36 @@ export async function addUserToOrganization(
}
await page.mouse.click(10, 10); // close the popover by clicking outside of it
}
/**
* disableDynamicParameters navigates to the template settings page and disables
* dynamic parameters by unchecking the "Enable dynamic parameters" checkbox.
*/
export const disableDynamicParameters = async (
page: Page,
templateName: string,
orgName = defaultOrganizationName,
) => {
await page.goto(`/templates/${orgName}/${templateName}/settings`, {
waitUntil: "domcontentloaded",
});
// Find and uncheck the "Enable dynamic parameters" checkbox
const dynamicParamsCheckbox = page.getByRole("checkbox", {
name: /Enable dynamic parameters for workspace creation/,
});
// If the checkbox is checked, uncheck it
if (await dynamicParamsCheckbox.isChecked()) {
await dynamicParamsCheckbox.click();
}
// Save the changes
await page.getByRole("button", { name: /save/i }).click();
// Wait for the success message or page to update
await page.waitForSelector("text=Template updated successfully", {
state: "visible",
timeout: 10000,
});
};
@@ -4,6 +4,7 @@ import {
StarterTemplates,
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
login,
openTerminalWindow,
@@ -35,6 +36,9 @@ test("create workspace", async ({ page }) => {
apply: [{ apply: { resources: [{ name: "example" }] } }],
});
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
await createWorkspace(page, template);
});
@@ -51,6 +55,9 @@ test("create workspace with default immutable parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [
@@ -68,6 +75,9 @@ test("create workspace with default mutable parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [
@@ -95,6 +105,9 @@ test("create workspace with default and required parameters", async ({
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template, {
richParameters,
@@ -127,6 +140,9 @@ test("create workspace and overwrite default parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template, {
richParameters,
@@ -147,6 +163,9 @@ test("create workspace with disable_param search params", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, templateName);
await login(page, users.member);
await page.goto(
`/templates/${templateName}/workspace?disable_params=first_parameter,second_parameter`,
@@ -165,6 +184,9 @@ test.skip("create docker workspace", async ({ context, page }) => {
await login(page, users.templateAdmin);
const template = await createTemplate(page, StarterTemplates.STARTER_DOCKER);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -4,6 +4,7 @@ import {
buildWorkspaceWithParameters,
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
verifyParameters,
} from "../../helpers";
@@ -24,6 +25,9 @@ test("restart workspace with ephemeral parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -4,6 +4,7 @@ import {
buildWorkspaceWithParameters,
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
stopWorkspace,
verifyParameters,
@@ -25,6 +26,9 @@ test("start workspace with ephemeral parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -3,6 +3,7 @@ import { users } from "../../constants";
import {
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
updateTemplate,
updateWorkspace,
@@ -34,6 +35,9 @@ test("update workspace, new optional, immutable parameter added", async ({
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -77,6 +81,9 @@ test("update workspace, new required, mutable parameter added", async ({
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -122,6 +129,9 @@ test("update workspace with ephemeral parameter enabled", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { ClassicParameterFlowDeprecationWarning } from "./ClassicParameterFlowDeprecationWarning";
jest.mock("modules/navigation", () => ({
useLinks: () => () => "/mock-link",
linkToTemplate: () => "/mock-template-link",
}));
describe("ClassicParameterFlowDeprecationWarning", () => {
const defaultProps = {
organizationName: "test-org",
templateName: "test-template",
};
it("renders warning when enabled and user has template update permissions", () => {
render(
<ClassicParameterFlowDeprecationWarning
templateSettingsLink={`/templates/${defaultProps.organizationName}/${defaultProps.templateName}/settings`}
{...defaultProps}
isEnabled={true}
/>,
);
expect(screen.getByText("deprecated")).toBeInTheDocument();
expect(screen.getByText("Go to Template Settings")).toBeInTheDocument();
});
it("does not render when enabled is false", () => {
const { container } = render(
<ClassicParameterFlowDeprecationWarning
templateSettingsLink={`/templates/${defaultProps.organizationName}/${defaultProps.templateName}/settings`}
{...defaultProps}
isEnabled={false}
/>,
);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,38 @@
import { Alert } from "components/Alert/Alert";
import { Link } from "components/Link/Link";
import type { FC } from "react";
import { docs } from "utils/docs";
interface ClassicParameterFlowDeprecationWarningProps {
templateSettingsLink: string;
isEnabled: boolean;
}
export const ClassicParameterFlowDeprecationWarning: FC<
ClassicParameterFlowDeprecationWarningProps
> = ({ templateSettingsLink, isEnabled }) => {
if (!isEnabled) {
return null;
}
return (
<Alert severity="warning" className="mb-2">
<div>
This template is using the classic parameter flow, which will be{" "}
<strong>deprecated</strong> and removed in a future release. Please
migrate to{" "}
<a
href={docs("/admin/templates/extending-templates/dynamic-parameters")}
className="text-content-link"
>
dynamic parameters
</a>{" "}
on template settings for improved functionality.
</div>
<Link className="text-xs" href={templateSettingsLink}>
Go to Template Settings
</Link>
</Alert>
);
};
@@ -72,6 +72,20 @@ const CreateWorkspacePage: FC = () => {
}),
enabled: !!templateQuery.data,
});
const templatePermissionsQuery = useQuery({
...checkAuthorization({
checks: {
canUpdateTemplate: {
object: {
resource_type: "template",
resource_id: templateQuery.data?.id ?? "",
},
action: "update",
},
},
}),
enabled: !!templateQuery.data,
});
const realizedVersionId =
customVersionId ?? templateQuery.data?.active_version_id;
const organizationId = templateQuery.data?.organization_id;
@@ -93,9 +107,13 @@ const CreateWorkspacePage: FC = () => {
const isLoadingFormData =
templateQuery.isLoading ||
permissionsQuery.isLoading ||
templatePermissionsQuery.isLoading ||
richParametersQuery.isLoading;
const loadFormDataError =
templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error;
templateQuery.error ??
permissionsQuery.error ??
templatePermissionsQuery.error ??
richParametersQuery.error;
const title = autoCreateWorkspaceMutation.isPending
? "Creating workspace..."
@@ -211,7 +229,9 @@ const CreateWorkspacePage: FC = () => {
startPollingExternalAuth={startPollingExternalAuth}
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
permissions={permissionsQuery.data as CreateWorkspacePermissions}
canUpdateTemplate={permissionsQuery.data?.canUpdateTemplate}
templatePermissions={
templatePermissionsQuery.data as { canUpdateTemplate: boolean }
}
parameters={realizedParameters as TemplateVersionParameter[]}
presets={templateVersionPresetsQuery.data ?? []}
creatingWorkspace={createWorkspaceMutation.isPending}
@@ -11,6 +11,7 @@ import {
MockUserOwner,
mockApiError,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
const meta: Meta<typeof CreateWorkspacePageView> = {
@@ -31,7 +32,9 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
canUpdateTemplate: false,
},
onCancel: action("onCancel"),
templatePermissions: { canUpdateTemplate: true },
},
decorators: [withDashboardProvider],
};
export default meta;
@@ -28,6 +28,8 @@ import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { type FormikContextType, useFormik } from "formik";
import type { ExternalAuthPollingState } from "hooks/useExternalAuth";
import { ExternalLinkIcon } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation";
import { ClassicParameterFlowDeprecationWarning } from "modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
@@ -68,6 +70,7 @@ interface CreateWorkspacePageViewProps {
autofillParameters: AutofillBuildParameter[];
presets: TypesGen.Preset[];
permissions: CreateWorkspacePermissions;
templatePermissions: { canUpdateTemplate: boolean };
creatingWorkspace: boolean;
canUpdateTemplate?: boolean;
onCancel: () => void;
@@ -94,11 +97,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
autofillParameters,
presets = [],
permissions,
templatePermissions,
creatingWorkspace,
canUpdateTemplate,
onSubmit,
onCancel,
}) => {
const getLink = useLinks();
const [owner, setOwner] = useState(defaultOwner);
const [suggestedName, setSuggestedName] = useState(() =>
generateWorkspaceName(),
@@ -261,6 +266,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
</Stack>
</PageHeader>
<ClassicParameterFlowDeprecationWarning
templateSettingsLink={`${getLink(
linkToTemplate(template.organization_name, template.name),
)}/settings`}
isEnabled={templatePermissions.canUpdateTemplate}
/>
<HorizontalForm
name="create-workspace-form"
onSubmit={form.handleSubmit}
@@ -407,7 +407,7 @@ export const CreateWorkspacePageViewExperimental: FC<
<br />
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
@@ -555,7 +555,7 @@ export const CreateWorkspacePageViewExperimental: FC<
parameters cannot be modified once the workspace is created.
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
@@ -245,19 +245,20 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
label={
<StackLabel>
<span className="flex flex-row gap-2">
Enable dynamic parameters for workspace creation
Enable dynamic parameters for workspace creation (recommended)
</span>
<StackLabelHelperText>
<div>
The new workspace form allows you to design your template
with new form types and identity-aware conditional
parameters. The form will only present options that are
compatible and available.
The dynamic workspace form allows you to design your
template with additional form types and identity-aware
conditional parameters. This is the default option for new
templates. The classic workspace creation flow will be
deprecated in a future release.
</div>
<Link
className="text-xs"
href={docs(
"/admin/templates/extending-templates/parameters#dynamic-parameters-beta",
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
Learn more
@@ -14,6 +14,7 @@ import {
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { ClassicParameterFlowDeprecationWarning } from "modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning";
import type { FC } from "react";
import { getFormHelpers } from "utils/formUtils";
import {
@@ -33,6 +34,7 @@ interface WorkspaceParameterFormProps {
autofillParams: AutofillBuildParameter[];
isSubmitting: boolean;
canChangeVersions: boolean;
templatePermissions: { canUpdateTemplate: boolean } | undefined;
error: unknown;
onCancel: () => void;
onSubmit: (values: WorkspaceParametersFormValues) => void;
@@ -46,6 +48,7 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
autofillParams,
error,
canChangeVersions,
templatePermissions,
isSubmitting,
}) => {
const form = useFormik<WorkspaceParametersFormValues>({
@@ -81,12 +84,15 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
return (
<>
{disabled && (
<Alert severity="warning" css={{ marginBottom: 48 }}>
<Alert severity="warning">
The template for this workspace requires automatic updates. Update the
workspace to edit parameters.
</Alert>
)}
<ClassicParameterFlowDeprecationWarning
templateSettingsLink={`/templates/${workspace.organization_name}/${workspace.template_name}/settings`}
isEnabled={templatePermissions?.canUpdateTemplate ?? false}
/>
<HorizontalForm onSubmit={form.handleSubmit} data-testid="form">
{hasNonEphemeralParameters && (
<FormSection
@@ -51,6 +51,25 @@ const WorkspaceParametersPage: FC = () => {
const permissions = permissionsQuery.data as WorkspacePermissions | undefined;
const canChangeVersions = Boolean(permissions?.updateWorkspaceVersion);
const templatePermissionsQuery = useQuery({
...checkAuthorization({
checks: {
canUpdateTemplate: {
object: {
resource_type: "template",
resource_id: workspace.template_id,
},
action: "update",
},
},
}),
enabled: workspace !== undefined,
});
const templatePermissions = templatePermissionsQuery.data as
| { canUpdateTemplate: boolean }
| undefined;
return (
<>
<Helmet>
@@ -60,6 +79,7 @@ const WorkspaceParametersPage: FC = () => {
<WorkspaceParametersPageView
workspace={workspace}
canChangeVersions={canChangeVersions}
templatePermissions={templatePermissions}
data={parameters.data}
submitError={updateParameters.error}
isSubmitting={updateParameters.isPending}
@@ -94,6 +114,7 @@ const WorkspaceParametersPage: FC = () => {
type WorkspaceParametersPageViewProps = {
workspace: Workspace;
canChangeVersions: boolean;
templatePermissions: { canUpdateTemplate: boolean } | undefined;
data: Awaited<ReturnType<typeof API.getWorkspaceParameters>> | undefined;
submitError: unknown;
isSubmitting: boolean;
@@ -106,6 +127,7 @@ export const WorkspaceParametersPageView: FC<
> = ({
workspace,
canChangeVersions,
templatePermissions,
data,
submitError,
onSubmit,
@@ -129,6 +151,7 @@ export const WorkspaceParametersPageView: FC<
<WorkspaceParametersForm
workspace={workspace}
canChangeVersions={canChangeVersions}
templatePermissions={templatePermissions}
autofillParams={data.buildParameters.map((p) => ({
...p,
source: "active_build",
@@ -226,7 +226,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
<br />
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
@@ -261,7 +261,9 @@ const WorkspaceParametersPageExperimental: FC = () => {
message="This workspace has no parameters"
cta={
<Link
href={docs("/admin/templates/extending-templates/parameters")}
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
Learn more about parameters
</Link>
@@ -210,7 +210,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
parameters cannot be modified once the workspace is created.
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
+1 -1
View File
@@ -826,7 +826,7 @@ export const MockTemplate: TypesGen.Template = {
deprecated: false,
deprecation_message: "",
max_port_share_level: "public",
use_classic_parameter_flow: true,
use_classic_parameter_flow: false,
};
const MockTemplateVersionFiles: TemplateVersionFiles = {