Skip to content

Commit a737d47

Browse files
authored
feat: Preview can now show presets and validate them (#149)
This PR provides a mechanism to "lint" presets to ensure that they refer to parameters that actually exist and define values that are actually valid for those parameters. Relates to: coder/coder#17333 <img width="486" alt="Screenshot 2025-06-18 at 12 03 37" src="https://github.com/user-attachments/assets/0b95dd3a-25d4-427a-9905-8ebced5485fd" />
1 parent 99bbcdb commit a737d47

File tree

9 files changed

+314
-7
lines changed

9 files changed

+314
-7
lines changed

cli/clidisplay/resources.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ func Parameters(writer io.Writer, params []types.Parameter, files map[string]*hc
7474
_, _ = fmt.Fprintln(writer, tableWriter.Render())
7575
}
7676

77+
func Presets(writer io.Writer, presets []types.Preset, files map[string]*hcl.File) {
78+
tableWriter := table.NewWriter()
79+
tableWriter.SetStyle(table.StyleLight)
80+
tableWriter.Style().Options.SeparateColumns = false
81+
row := table.Row{"Preset"}
82+
tableWriter.AppendHeader(row)
83+
for _, p := range presets {
84+
tableWriter.AppendRow(table.Row{
85+
fmt.Sprintf("%s\n%s", p.Name, formatPresetParameters(p.Parameters)),
86+
})
87+
if hcl.Diagnostics(p.Diagnostics).HasErrors() {
88+
var out bytes.Buffer
89+
WriteDiagnostics(&out, files, hcl.Diagnostics(p.Diagnostics))
90+
tableWriter.AppendRow(table.Row{out.String()})
91+
}
92+
93+
tableWriter.AppendSeparator()
94+
}
95+
_, _ = fmt.Fprintln(writer, tableWriter.Render())
96+
}
97+
98+
func formatPresetParameters(presetParameters map[string]string) string {
99+
var str strings.Builder
100+
for presetParamName, PresetParamValue := range presetParameters {
101+
_, _ = str.WriteString(fmt.Sprintf("%s = %s\n", presetParamName, PresetParamValue))
102+
}
103+
return str.String()
104+
}
105+
77106
func formatOptions(selected []string, options []*types.ParameterOption) string {
78107
var str strings.Builder
79108
sep := ""

cli/root.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"slices"
89
"strings"
910

1011
"github.com/hashicorp/hcl/v2"
@@ -27,6 +28,7 @@ func (r *RootCmd) Root() *serpent.Command {
2728
vars []string
2829
groups []string
2930
planJSON string
31+
preset string
3032
)
3133
cmd := &serpent.Command{
3234
Use: "codertf",
@@ -64,10 +66,26 @@ func (r *RootCmd) Root() *serpent.Command {
6466
Default: "",
6567
Value: serpent.StringArrayOf(&groups),
6668
},
69+
{
70+
Name: "preset",
71+
Description: "Name of the preset to define parameters. Run preview without this flag first to see a list of presets.",
72+
Flag: "preset",
73+
FlagShorthand: "s",
74+
Default: "",
75+
Value: serpent.StringOf(&preset),
76+
},
6777
},
6878
Handler: func(i *serpent.Invocation) error {
6979
dfs := os.DirFS(dir)
7080

81+
ctx := i.Context()
82+
83+
output, _ := preview.Preview(ctx, preview.Input{}, dfs)
84+
presets := output.Presets
85+
chosenPresetIndex := slices.IndexFunc(presets, func(p types.Preset) bool {
86+
return p.Name == preset
87+
})
88+
7189
rvars := make(map[string]string)
7290
for _, val := range vars {
7391
parts := strings.Split(val, "=")
@@ -76,6 +94,11 @@ func (r *RootCmd) Root() *serpent.Command {
7694
}
7795
rvars[parts[0]] = parts[1]
7896
}
97+
if chosenPresetIndex != -1 {
98+
for paramName, paramValue := range presets[chosenPresetIndex].Parameters {
99+
rvars[paramName] = paramValue
100+
}
101+
}
79102

80103
input := preview.Input{
81104
PlanJSONPath: planJSON,
@@ -85,7 +108,6 @@ func (r *RootCmd) Root() *serpent.Command {
85108
},
86109
}
87110

88-
ctx := i.Context()
89111
output, diags := preview.Preview(ctx, input, dfs)
90112
if output == nil {
91113
return diags
@@ -103,6 +125,10 @@ func (r *RootCmd) Root() *serpent.Command {
103125
clidisplay.WriteDiagnostics(os.Stderr, output.Files, diags)
104126
}
105127

128+
if chosenPresetIndex == -1 {
129+
clidisplay.Presets(os.Stdout, presets, output.Files)
130+
}
131+
106132
clidisplay.Parameters(os.Stdout, output.Parameters, output.Files)
107133

108134
if !output.ModuleOutput.IsNull() && !(output.ModuleOutput.Type().IsObjectType() && output.ModuleOutput.LengthInt() == 0) {

extract/parameter.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,15 +274,18 @@ func requiredString(block *terraform.Block, key string) (string, *hcl.Diagnostic
274274
}
275275

276276
diag := &hcl.Diagnostic{
277-
Severity: hcl.DiagError,
278-
Summary: fmt.Sprintf("Invalid %q attribute for block %s", key, block.Label()),
279-
Detail: fmt.Sprintf("Expected a string, got %q", typeName),
280-
Subject: &(tyAttr.HCLAttribute().Range),
281-
//Context: &(block.HCLBlock().DefRange),
282-
Expression: tyAttr.HCLAttribute().Expr,
277+
Severity: hcl.DiagError,
278+
Summary: fmt.Sprintf("Invalid %q attribute for block %s", key, block.Label()),
279+
Detail: fmt.Sprintf("Expected a string, got %q", typeName),
283280
EvalContext: block.Context().Inner(),
284281
}
285282

283+
if tyAttr.IsNotNil() {
284+
diag.Subject = &(tyAttr.HCLAttribute().Range)
285+
// diag.Context = &(block.HCLBlock().DefRange)
286+
diag.Expression = tyAttr.HCLAttribute().Expr
287+
}
288+
286289
if !tyVal.IsWhollyKnown() {
287290
refs := hclext.ReferenceNames(tyAttr.HCLAttribute().Expr)
288291
if len(refs) > 0 {

extract/preset.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package extract
2+
3+
import (
4+
"github.com/aquasecurity/trivy/pkg/iac/terraform"
5+
"github.com/coder/preview/types"
6+
"github.com/hashicorp/hcl/v2"
7+
)
8+
9+
func PresetFromBlock(block *terraform.Block) types.Preset {
10+
p := types.Preset{
11+
PresetData: types.PresetData{
12+
Parameters: make(map[string]string),
13+
},
14+
Diagnostics: types.Diagnostics{},
15+
}
16+
17+
if !block.IsResourceType(types.BlockTypePreset) {
18+
p.Diagnostics = append(p.Diagnostics, &hcl.Diagnostic{
19+
Severity: hcl.DiagError,
20+
Summary: "Invalid Preset",
21+
Detail: "Block is not a preset",
22+
})
23+
return p
24+
}
25+
26+
pName, nameDiag := requiredString(block, "name")
27+
if nameDiag != nil {
28+
p.Diagnostics = append(p.Diagnostics, nameDiag)
29+
}
30+
p.Name = pName
31+
32+
// GetAttribute and AsMapValue both gracefully handle `nil`, `null` and `unknown` values.
33+
// All of these return an empty map, which then makes the loop below a no-op.
34+
params := block.GetAttribute("parameters").AsMapValue()
35+
for presetParamName, presetParamValue := range params.Value() {
36+
p.Parameters[presetParamName] = presetParamValue
37+
}
38+
39+
defaultAttr := block.GetAttribute("default")
40+
if defaultAttr != nil {
41+
p.Default = defaultAttr.Value().True()
42+
}
43+
44+
return p
45+
}

preset.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package preview
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
7+
"github.com/aquasecurity/trivy/pkg/iac/terraform"
8+
"github.com/hashicorp/hcl/v2"
9+
10+
"github.com/coder/preview/extract"
11+
"github.com/coder/preview/types"
12+
)
13+
14+
// presets extracts all presets from the given modules. It then validates the name,
15+
// parameters and default preset.
16+
func presets(modules terraform.Modules, parameters []types.Parameter) []types.Preset {
17+
foundPresets := make([]types.Preset, 0)
18+
var defaultPreset *types.Preset
19+
20+
for _, mod := range modules {
21+
blocks := mod.GetDatasByType(types.BlockTypePreset)
22+
for _, block := range blocks {
23+
preset := extract.PresetFromBlock(block)
24+
switch true {
25+
case defaultPreset != nil && preset.Default:
26+
preset.Diagnostics = append(preset.Diagnostics, &hcl.Diagnostic{
27+
Severity: hcl.DiagError,
28+
Summary: "Multiple default presets",
29+
Detail: fmt.Sprintf("Only one preset can be marked as default. %q is already marked as default", defaultPreset.Name),
30+
})
31+
case defaultPreset == nil && preset.Default:
32+
defaultPreset = &preset
33+
}
34+
35+
for paramName, paramValue := range preset.Parameters {
36+
templateParamIndex := slices.IndexFunc(parameters, func(p types.Parameter) bool {
37+
return p.Name == paramName
38+
})
39+
if templateParamIndex == -1 {
40+
preset.Diagnostics = append(preset.Diagnostics, &hcl.Diagnostic{
41+
Severity: hcl.DiagError,
42+
Summary: "Undefined Parameter",
43+
Detail: fmt.Sprintf("Preset parameter %q is not defined by the template.", paramName),
44+
})
45+
continue
46+
}
47+
templateParam := parameters[templateParamIndex]
48+
for _, diag := range templateParam.Valid(types.StringLiteral(paramValue)) {
49+
preset.Diagnostics = append(preset.Diagnostics, diag)
50+
}
51+
}
52+
53+
foundPresets = append(foundPresets, preset)
54+
}
55+
}
56+
57+
return foundPresets
58+
}

preview.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type Output struct {
4141

4242
Parameters []types.Parameter `json:"parameters"`
4343
WorkspaceTags types.TagBlocks `json:"workspace_tags"`
44+
Presets []types.Preset `json:"presets"`
4445
// Files is included for printing diagnostics.
4546
// They can be marshalled, but not unmarshalled. This is a limitation
4647
// of the HCL library.
@@ -178,6 +179,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
178179

179180
diags := make(hcl.Diagnostics, 0)
180181
rp, rpDiags := parameters(modules)
182+
presets := presets(modules, rp)
181183
tags, tagDiags := workspaceTags(modules, p.Files())
182184

183185
// Add warnings
@@ -187,6 +189,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
187189
ModuleOutput: outputs,
188190
Parameters: rp,
189191
WorkspaceTags: tags,
192+
Presets: presets,
190193
Files: p.Files(),
191194
}, diags.Extend(rpDiags).Extend(tagDiags)
192195
}

preview_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func Test_Extract(t *testing.T) {
4343
expTags map[string]string
4444
unknownTags []string
4545
params map[string]assertParam
46+
presets func(t *testing.T, presets []types.Preset)
4647
warnings []*regexp.Regexp
4748
}{
4849
{
@@ -243,6 +244,62 @@ func Test_Extract(t *testing.T) {
243244
errorDiagnostics("Required"),
244245
},
245246
},
247+
{
248+
name: "invalid presets",
249+
dir: "invalidpresets",
250+
expTags: map[string]string{},
251+
input: preview.Input{},
252+
unknownTags: []string{},
253+
params: map[string]assertParam{
254+
"valid_parameter_name": ap().
255+
optVals("valid_option_value"),
256+
},
257+
presets: func(t *testing.T, presets []types.Preset) {
258+
presetMap := map[string]func(t *testing.T, preset types.Preset){
259+
"empty_parameters": func(t *testing.T, preset types.Preset) {
260+
require.Len(t, preset.Diagnostics, 0)
261+
},
262+
"no_parameters": func(t *testing.T, preset types.Preset) {
263+
require.Len(t, preset.Diagnostics, 0)
264+
},
265+
"invalid_parameter_name": func(t *testing.T, preset types.Preset) {
266+
require.Len(t, preset.Diagnostics, 1)
267+
require.Equal(t, preset.Diagnostics[0].Summary, "Undefined Parameter")
268+
require.Equal(t, preset.Diagnostics[0].Detail, "Preset parameter \"invalid_parameter_name\" is not defined by the template.")
269+
},
270+
"invalid_parameter_value": func(t *testing.T, preset types.Preset) {
271+
require.Len(t, preset.Diagnostics, 1)
272+
require.Equal(t, preset.Diagnostics[0].Summary, "Value must be a valid option")
273+
require.Equal(t, preset.Diagnostics[0].Detail, "the value \"invalid_value\" must be defined as one of options")
274+
},
275+
"valid_preset": func(t *testing.T, preset types.Preset) {
276+
require.Len(t, preset.Diagnostics, 0)
277+
require.Equal(t, preset.Parameters, map[string]string{
278+
"valid_parameter_name": "valid_option_value",
279+
})
280+
},
281+
}
282+
283+
for _, preset := range presets {
284+
if fn, ok := presetMap[preset.Name]; ok {
285+
fn(t, preset)
286+
}
287+
}
288+
289+
var defaultPresetsWithError int
290+
for _, preset := range presets {
291+
if preset.Name == "default_preset" || preset.Name == "another_default_preset" {
292+
for _, diag := range preset.Diagnostics {
293+
if diag.Summary == "Multiple default presets" {
294+
defaultPresetsWithError++
295+
break
296+
}
297+
}
298+
}
299+
}
300+
require.Equal(t, 1, defaultPresetsWithError, "exactly one default preset should have the multiple defaults error")
301+
},
302+
},
246303
{
247304
name: "required",
248305
dir: "required",
@@ -575,6 +632,11 @@ func Test_Extract(t *testing.T) {
575632
require.True(t, ok, "unknown parameter %s", param.Name)
576633
check(t, param)
577634
}
635+
636+
// Assert presets
637+
if tc.presets != nil {
638+
tc.presets(t, output.Presets)
639+
}
578640
})
579641
}
580642
}

0 commit comments

Comments
 (0)