diff --git a/cli/clidisplay/resources.go b/cli/clidisplay/resources.go index f7eab6e..063086a 100644 --- a/cli/clidisplay/resources.go +++ b/cli/clidisplay/resources.go @@ -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 := "" diff --git a/cli/root.go b/cli/root.go index ab1afc5..0971018 100644 --- a/cli/root.go +++ b/cli/root.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "slices" "strings" "github.com/hashicorp/hcl/v2" @@ -27,6 +28,7 @@ func (r *RootCmd) Root() *serpent.Command { vars []string groups []string planJSON string + preset string ) cmd := &serpent.Command{ Use: "codertf", @@ -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, "=") @@ -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, @@ -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 @@ -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) { diff --git a/extract/parameter.go b/extract/parameter.go index 0d45a8b..d40a6cc 100644 --- a/extract/parameter.go +++ b/extract/parameter.go @@ -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() { + 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 { diff --git a/extract/preset.go b/extract/preset.go new file mode 100644 index 0000000..01c9310 --- /dev/null +++ b/extract/preset.go @@ -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 +} diff --git a/preset.go b/preset.go new file mode 100644 index 0000000..0e5ca74 --- /dev/null +++ b/preset.go @@ -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 +} diff --git a/preview.go b/preview.go index 62a94e6..8d041fa 100644 --- a/preview.go +++ b/preview.go @@ -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. @@ -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 @@ -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) } diff --git a/preview_test.go b/preview_test.go index 59a8761..177109b 100644 --- a/preview_test.go +++ b/preview_test.go @@ -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 }{ { @@ -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", @@ -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) + } }) } } diff --git a/testdata/invalidpresets/main.tf b/testdata/invalidpresets/main.tf new file mode 100644 index 0000000..d9abd56 --- /dev/null +++ b/testdata/invalidpresets/main.tf @@ -0,0 +1,63 @@ +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" "no_parameters" { + name = "no_parameters" +} + +data "coder_workspace_preset" "empty_parameters" { + name = "empty_parameters" + parameters = {} +} + +data "coder_workspace_preset" "invalid_parameter_name" { + name = "invalid_parameter_name" + parameters = { + "invalid_parameter_name" = "irrelevant_value" + } +} + +data "coder_workspace_preset" "invalid_parameter_value" { + name = "invalid_parameter_value" + parameters = { + "valid_parameter_name" = "invalid_value" + } +} + +data "coder_workspace_preset" "valid_preset" { + name = "valid_preset" + parameters = { + "valid_parameter_name" = "valid_option_value" + } +} + +data "coder_workspace_preset" "default_preset" { + name = "default_preset" + parameters = { + "valid_parameter_name" = "valid_option_value" + } + default = true +} + +data "coder_workspace_preset" "another_default_preset" { + name = "another_default_preset" + parameters = { + "valid_parameter_name" = "valid_option_value" + } + default = true +} \ No newline at end of file diff --git a/types/preset.go b/types/preset.go new file mode 100644 index 0000000..da05e41 --- /dev/null +++ b/types/preset.go @@ -0,0 +1,18 @@ +package types + +const ( + BlockTypePreset = "coder_workspace_preset" +) + +type Preset struct { + PresetData + // Diagnostics is used to store any errors that occur during parsing + // of the preset. + Diagnostics Diagnostics `json:"diagnostics"` +} + +type PresetData struct { + Name string `json:"name"` + Parameters map[string]string `json:"parameters"` + Default bool `json:"default"` +}