Skip to content

feat: Preview can now show presets and validate them #149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cli/clidisplay/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,35 @@ func Parameters(writer io.Writer, params []types.Parameter, files map[string]*hc
_, _ = fmt.Fprintln(writer, tableWriter.Render())
}

func Presets(writer io.Writer, presets []types.Preset, files map[string]*hcl.File) {
tableWriter := table.NewWriter()
tableWriter.SetStyle(table.StyleLight)
tableWriter.Style().Options.SeparateColumns = false
row := table.Row{"Preset"}
tableWriter.AppendHeader(row)
for _, p := range presets {
tableWriter.AppendRow(table.Row{
fmt.Sprintf("%s\n%s", p.Name, formatPresetParameters(p.Parameters)),
})
if hcl.Diagnostics(p.Diagnostics).HasErrors() {
var out bytes.Buffer
WriteDiagnostics(&out, files, hcl.Diagnostics(p.Diagnostics))
tableWriter.AppendRow(table.Row{out.String()})
}

tableWriter.AppendSeparator()
}
_, _ = fmt.Fprintln(writer, tableWriter.Render())
}

func formatPresetParameters(presetParameters map[string]string) string {
var str strings.Builder
for presetParamName, PresetParamValue := range presetParameters {
_, _ = str.WriteString(fmt.Sprintf("%s = %s\n", presetParamName, PresetParamValue))
}
return str.String()
}

func formatOptions(selected []string, options []*types.ParameterOption) string {
var str strings.Builder
sep := ""
Expand Down
28 changes: 27 additions & 1 deletion cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"slices"
"strings"

"github.com/hashicorp/hcl/v2"
Expand All @@ -27,6 +28,7 @@ func (r *RootCmd) Root() *serpent.Command {
vars []string
groups []string
planJSON string
preset string
)
cmd := &serpent.Command{
Use: "codertf",
Expand Down Expand Up @@ -64,10 +66,26 @@ func (r *RootCmd) Root() *serpent.Command {
Default: "",
Value: serpent.StringArrayOf(&groups),
},
{
Name: "preset",
Description: "Name of the preset to define parameters. Run preview without this flag first to see a list of presets.",
Flag: "preset",
FlagShorthand: "s",
Default: "",
Value: serpent.StringOf(&preset),
},
},
Handler: func(i *serpent.Invocation) error {
dfs := os.DirFS(dir)

ctx := i.Context()

output, _ := preview.Preview(ctx, preview.Input{}, dfs)
presets := output.Presets
chosenPresetIndex := slices.IndexFunc(presets, func(p types.Preset) bool {
return p.Name == preset
})

rvars := make(map[string]string)
for _, val := range vars {
parts := strings.Split(val, "=")
Expand All @@ -76,6 +94,11 @@ func (r *RootCmd) Root() *serpent.Command {
}
rvars[parts[0]] = parts[1]
}
if chosenPresetIndex != -1 {
for paramName, paramValue := range presets[chosenPresetIndex].Parameters {
rvars[paramName] = paramValue
}
}

input := preview.Input{
PlanJSONPath: planJSON,
Expand All @@ -85,7 +108,6 @@ func (r *RootCmd) Root() *serpent.Command {
},
}

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

if chosenPresetIndex == -1 {
clidisplay.Presets(os.Stdout, presets, output.Files)
}

clidisplay.Parameters(os.Stdout, output.Parameters, output.Files)

if !output.ModuleOutput.IsNull() && !(output.ModuleOutput.Type().IsObjectType() && output.ModuleOutput.LengthInt() == 0) {
Expand Down
15 changes: 9 additions & 6 deletions extract/parameter.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,15 +274,18 @@ func requiredString(block *terraform.Block, key string) (string, *hcl.Diagnostic
}

diag := &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Invalid %q attribute for block %s", key, block.Label()),
Detail: fmt.Sprintf("Expected a string, got %q", typeName),
Subject: &(tyAttr.HCLAttribute().Range),
//Context: &(block.HCLBlock().DefRange),
Expression: tyAttr.HCLAttribute().Expr,
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Invalid %q attribute for block %s", key, block.Label()),
Detail: fmt.Sprintf("Expected a string, got %q", typeName),
EvalContext: block.Context().Inner(),
}

if tyAttr.IsNotNil() {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found a nilpointer here during testing.

diag.Subject = &(tyAttr.HCLAttribute().Range)
// diag.Context = &(block.HCLBlock().DefRange)
diag.Expression = tyAttr.HCLAttribute().Expr
}

if !tyVal.IsWhollyKnown() {
refs := hclext.ReferenceNames(tyAttr.HCLAttribute().Expr)
if len(refs) > 0 {
Expand Down
45 changes: 45 additions & 0 deletions extract/preset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package extract

import (
"github.com/aquasecurity/trivy/pkg/iac/terraform"
"github.com/coder/preview/types"
"github.com/hashicorp/hcl/v2"
)

func PresetFromBlock(block *terraform.Block) types.Preset {
p := types.Preset{
PresetData: types.PresetData{
Parameters: make(map[string]string),
},
Diagnostics: types.Diagnostics{},
}

if !block.IsResourceType(types.BlockTypePreset) {
p.Diagnostics = append(p.Diagnostics, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid Preset",
Detail: "Block is not a preset",
})
return p
}

pName, nameDiag := requiredString(block, "name")
if nameDiag != nil {
p.Diagnostics = append(p.Diagnostics, nameDiag)
}
p.Name = pName

// GetAttribute and AsMapValue both gracefully handle `nil`, `null` and `unknown` values.
// All of these return an empty map, which then makes the loop below a no-op.
params := block.GetAttribute("parameters").AsMapValue()
for presetParamName, presetParamValue := range params.Value() {
p.Parameters[presetParamName] = presetParamValue
}

defaultAttr := block.GetAttribute("default")
if defaultAttr != nil {
p.Default = defaultAttr.Value().True()
}

return p
}
58 changes: 58 additions & 0 deletions preset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package preview

import (
"fmt"
"slices"

"github.com/aquasecurity/trivy/pkg/iac/terraform"
"github.com/hashicorp/hcl/v2"

"github.com/coder/preview/extract"
"github.com/coder/preview/types"
)

// presets extracts all presets from the given modules. It then validates the name,
// parameters and default preset.
func presets(modules terraform.Modules, parameters []types.Parameter) []types.Preset {
foundPresets := make([]types.Preset, 0)
var defaultPreset *types.Preset

for _, mod := range modules {
blocks := mod.GetDatasByType(types.BlockTypePreset)
for _, block := range blocks {
preset := extract.PresetFromBlock(block)
switch true {
case defaultPreset != nil && preset.Default:
preset.Diagnostics = append(preset.Diagnostics, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Multiple default presets",
Detail: fmt.Sprintf("Only one preset can be marked as default. %q is already marked as default", defaultPreset.Name),
})
case defaultPreset == nil && preset.Default:
defaultPreset = &preset
}

for paramName, paramValue := range preset.Parameters {
templateParamIndex := slices.IndexFunc(parameters, func(p types.Parameter) bool {
return p.Name == paramName
})
if templateParamIndex == -1 {
preset.Diagnostics = append(preset.Diagnostics, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Undefined Parameter",
Detail: fmt.Sprintf("Preset parameter %q is not defined by the template.", paramName),
})
continue
}
templateParam := parameters[templateParamIndex]
for _, diag := range templateParam.Valid(types.StringLiteral(paramValue)) {
preset.Diagnostics = append(preset.Diagnostics, diag)
}
}

foundPresets = append(foundPresets, preset)
}
}

return foundPresets
}
3 changes: 3 additions & 0 deletions preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Output struct {

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

diags := make(hcl.Diagnostics, 0)
rp, rpDiags := parameters(modules)
presets := presets(modules, rp)
tags, tagDiags := workspaceTags(modules, p.Files())

// Add warnings
Expand All @@ -171,6 +173,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
ModuleOutput: outputs,
Parameters: rp,
WorkspaceTags: tags,
Presets: presets,
Files: p.Files(),
}, diags.Extend(rpDiags).Extend(tagDiags)
}
Expand Down
62 changes: 62 additions & 0 deletions preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func Test_Extract(t *testing.T) {
expTags map[string]string
unknownTags []string
params map[string]assertParam
presets func(t *testing.T, presets []types.Preset)
warnings []*regexp.Regexp
}{
{
Expand Down Expand Up @@ -242,6 +243,62 @@ func Test_Extract(t *testing.T) {
errorDiagnostics("Required"),
},
},
{
name: "invalid presets",
dir: "invalidpresets",
expTags: map[string]string{},
input: preview.Input{},
unknownTags: []string{},
params: map[string]assertParam{
"valid_parameter_name": ap().
optVals("valid_option_value"),
},
presets: func(t *testing.T, presets []types.Preset) {
presetMap := map[string]func(t *testing.T, preset types.Preset){
"empty_parameters": func(t *testing.T, preset types.Preset) {
require.Len(t, preset.Diagnostics, 0)
},
"no_parameters": func(t *testing.T, preset types.Preset) {
require.Len(t, preset.Diagnostics, 0)
},
"invalid_parameter_name": func(t *testing.T, preset types.Preset) {
require.Len(t, preset.Diagnostics, 1)
require.Equal(t, preset.Diagnostics[0].Summary, "Undefined Parameter")
require.Equal(t, preset.Diagnostics[0].Detail, "Preset parameter \"invalid_parameter_name\" is not defined by the template.")
},
"invalid_parameter_value": func(t *testing.T, preset types.Preset) {
require.Len(t, preset.Diagnostics, 1)
require.Equal(t, preset.Diagnostics[0].Summary, "Value must be a valid option")
require.Equal(t, preset.Diagnostics[0].Detail, "the value \"invalid_value\" must be defined as one of options")
},
"valid_preset": func(t *testing.T, preset types.Preset) {
require.Len(t, preset.Diagnostics, 0)
require.Equal(t, preset.Parameters, map[string]string{
"valid_parameter_name": "valid_option_value",
})
},
}

for _, preset := range presets {
if fn, ok := presetMap[preset.Name]; ok {
fn(t, preset)
}
}

var defaultPresetsWithError int
for _, preset := range presets {
if preset.Name == "default_preset" || preset.Name == "another_default_preset" {
for _, diag := range preset.Diagnostics {
if diag.Summary == "Multiple default presets" {
defaultPresetsWithError++
break
}
}
}
}
require.Equal(t, 1, defaultPresetsWithError, "exactly one default preset should have the multiple defaults error")
},
},
{
name: "required",
dir: "required",
Expand Down Expand Up @@ -543,6 +600,11 @@ func Test_Extract(t *testing.T) {
require.True(t, ok, "unknown parameter %s", param.Name)
check(t, param)
}

// Assert presets
if tc.presets != nil {
tc.presets(t, output.Presets)
}
})
}
}
Expand Down
Loading
Loading