diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 509274dc25..55e2838d08 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1814,13 +1814,22 @@ func (api *API) dynamicTemplateVersionTags(ctx context.Context, rw http.Response tfVarValues[variable.Name] = cty.StringVal(variable.Value) } - output, diags := preview.Preview(ctx, preview.Input{ + input := preview.Input{ PlanJSON: nil, // Template versions are before `terraform plan` ParameterValues: nil, // No user-specified parameters Owner: *ownerData, Logger: stdslog.New(stdslog.DiscardHandler), TFVars: tfVarValues, - }, files) + } + output, diags := preview.Preview(ctx, input, files) + if output != nil { + // ValidatePrebuilds iterates through the presets and validate their values. This + // ensures the prebuild can actually succeed in a workspace build. The failure + // diagnostics are added to the existing presets, and checked by + // 'dynamicparameters.CheckPresets' + preview.ValidatePrebuilds(ctx, input, output.Presets, files) + } + tagErr := dynamicparameters.CheckTags(output, diags) if tagErr != nil { code, resp := tagErr.Response() diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index cf8ae4cb76..99c32c0d5c 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -700,6 +700,39 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { } `, }, + expectError: "", // Presets are not validated unless they are for a prebuild + }, + { + name: "invalid prebuild", + files: map[string]string{ + `main.tf`: ` + terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.8.0" + } + } + } + data "coder_parameter" "valid_parameter" { + name = "valid_parameter_name" + default = "valid_option_value" + option { + name = "valid_option_name" + value = "valid_option_value" + } + } + data "coder_workspace_preset" "invalid_parameter_name" { + name = "invalid_parameter_name" + parameters = { + "invalid_parameter_name" = "irrelevant_value" + } + prebuilds { + instances = 2 + } + } + `, + }, expectError: "Undefined Parameter", }, } { @@ -742,6 +775,123 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { }) } +// TestTemplateVersionPresetValidation validates that presets with prebuilds +// are validated dynamically. A preset that enables a conditional parameter +// but doesn't provide the required value for the newly-visible parameter +// should fail validation during template version import. +// +// Scenario: +// - Parameter A (use_custom_image): defaults to false +// - Parameter B (custom_image_url): only exists when A is true, has no default +// - Preset with prebuilds enables A but doesn't provide B +// +// Static validation passes because B doesn't exist when evaluated with default +// values. ValidatePrebuilds catches this by evaluating with the preset's +// parameter values. +func TestTemplateVersionPresetValidation(t *testing.T) { + t.Parallel() + + store, ps := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx := testutil.Context(t, testutil.WaitShort) + + tf := func(valid bool, prebuildCount int) string { + customImageURL := "" + if valid { + customImageURL = `custom_image_url = "ghcr.io/coder/example:latest"` + } + return fmt.Sprintf(` + terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.8.0" + } + } + } + + data "coder_parameter" "use_custom_image" { + name = "use_custom_image" + type = "bool" + default = "false" + } + + data "coder_parameter" "custom_image_url" { + count = data.coder_parameter.use_custom_image.value == "true" ? 1 : 0 + name = "custom_image_url" + type = "string" + # No default - required when shown + } + + data "coder_workspace_preset" "invalid" { + name = "Invalid Preset" + parameters = { + "use_custom_image" = "true" + %s + } + prebuilds { + instances = %d + } + } + `, customImageURL, prebuildCount) + } + + tarFile := testutil.CreateTar(t, map[string]string{ + `main.tf`: tf(false, 1), + }) + + fi, err := templateAdmin.Upload(ctx, "application/x-tar", bytes.NewReader(tarFile)) + require.NoError(t, err) + + _, err = templateAdmin.CreateTemplateVersion(ctx, owner.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: testutil.GetRandomNameHyphenated(t), + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeTerraform, + FileID: fi.ID, + }) + require.Error(t, err) + require.ErrorContains(t, err, "Parameter custom_image_url: Required parameter not provided; parameter value is null") + + // If the preset is not a prebuild, validation should pass. As presets can + // be partially applied, we test with a prebuild count of 0. + tarFile = testutil.CreateTar(t, map[string]string{ + `main.tf`: tf(false, 0), + }) + + fi, err = templateAdmin.Upload(ctx, "application/x-tar", bytes.NewReader(tarFile)) + require.NoError(t, err) + + _, err = templateAdmin.CreateTemplateVersion(ctx, owner.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: testutil.GetRandomNameHyphenated(t), + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeTerraform, + FileID: fi.ID, + }) + require.NoError(t, err) + + // The valid preset should pass + tarFile = testutil.CreateTar(t, map[string]string{ + `main.tf`: tf(true, 1), + }) + + fi, err = templateAdmin.Upload(ctx, "application/x-tar", bytes.NewReader(tarFile)) + require.NoError(t, err) + + _, err = templateAdmin.CreateTemplateVersion(ctx, owner.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: testutil.GetRandomNameHyphenated(t), + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeTerraform, + FileID: fi.ID, + }) + require.NoError(t, err) +} + func TestPatchCancelTemplateVersion(t *testing.T) { t.Parallel() t.Run("AlreadyCompleted", func(t *testing.T) { diff --git a/go.mod b/go.mod index 62214036b0..258b2e6774 100644 --- a/go.mod +++ b/go.mod @@ -484,7 +484,7 @@ require ( github.com/coder/aibridge v1.0.7 github.com/coder/aisdk-go v0.0.9 github.com/coder/boundary v0.8.3 - github.com/coder/preview v1.0.4 + github.com/coder/preview v1.0.7 github.com/danieljoos/wincred v1.2.3 github.com/dgraph-io/ristretto/v2 v2.4.0 github.com/elazarl/goproxy v1.8.0 diff --git a/go.sum b/go.sum index 006a1eed97..642658f4ab 100644 --- a/go.sum +++ b/go.sum @@ -337,8 +337,8 @@ github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7 github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= -github.com/coder/preview v1.0.4 h1:f506bnyhHtI3ICl/8Eb/gemcKvm/AGzQ91uyxjF+D9k= -github.com/coder/preview v1.0.4/go.mod h1:PpLayC3ngQQ0iUhW2yVRFszOooto4JrGGMomv1rqUvA= +github.com/coder/preview v1.0.7 h1:LF8WRYDcYyBUyfmlAaXD6hZOpBH+qDIxU9mcbmSRKxM= +github.com/coder/preview v1.0.7/go.mod h1:PpLayC3ngQQ0iUhW2yVRFszOooto4JrGGMomv1rqUvA= github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg= github.com/coder/quartz v0.3.0/go.mod h1:BgE7DOj/8NfvRgvKw0jPLDQH/2Lya2kxcTaNJ8X0rZk= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc=