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..9b27aa0 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,25 @@ 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() + + presets, _ := preview.PreviewPresets(ctx, dfs) + 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 +93,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 +107,6 @@ func (r *RootCmd) Root() *serpent.Command { }, } - ctx := i.Context() output, diags := preview.Preview(ctx, input, dfs) if output == nil { return diags @@ -103,6 +124,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/preset.go b/extract/preset.go new file mode 100644 index 0000000..32eec58 --- /dev/null +++ b/extract/preset.go @@ -0,0 +1,31 @@ +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, hcl.Diagnostics) { + var diags hcl.Diagnostics + + pName, nameDiag := requiredString(block, "name") + if nameDiag != nil { + diags = append(diags, nameDiag) + } + + p := types.Preset{ + PresetData: types.PresetData{ + Name: pName, + Parameters: make(map[string]string), + }, + Diagnostics: types.Diagnostics{}, + } + + params := block.GetAttribute("parameters").AsMapValue() + for presetParamName, presetParamValue := range params.Value() { + p.Parameters[presetParamName] = presetParamValue + } + + return &p, diags +} diff --git a/preset.go b/preset.go new file mode 100644 index 0000000..8a21e3b --- /dev/null +++ b/preset.go @@ -0,0 +1,53 @@ +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" +) + +func presets(modules terraform.Modules, parameters []types.Parameter) ([]types.Preset, hcl.Diagnostics) { + diags := make(hcl.Diagnostics, 0) + presets := make([]types.Preset, 0) + + for _, mod := range modules { + blocks := mod.GetDatasByType(types.BlockTypePreset) + for _, block := range blocks { + preset, pDiags := extract.PresetFromBlock(block) + if len(pDiags) > 0 { + diags = diags.Extend(pDiags) + } + + if preset == nil { + continue + } + + 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 %q requires parameter %q, but it is not defined by the template.", preset.Name, paramName), + }) + continue + } + templateParam := parameters[templateParamIndex] + for _, diag := range templateParam.Valid(types.StringLiteral(paramValue)) { + preset.Diagnostics = append(preset.Diagnostics, diag) + } + } + + presets = append(presets, *preset) + } + } + + return presets, diags +} diff --git a/preview.go b/preview.go index 5635bf1..0ffc49b 100644 --- a/preview.go +++ b/preview.go @@ -208,3 +208,70 @@ func tfVarFiles(path string, dir fs.FS) ([]string, error) { } return files, nil } + +func PreviewPresets(ctx context.Context, dir fs.FS) ([]types.Preset, hcl.Diagnostics) { + // The trivy package works with `github.com/zclconf/go-cty`. This package is + // similar to `reflect` in its usage. This package can panic if types are + // misused. To protect the caller, a general `recover` is used to catch any + // mistakes. If this happens, there is a developer bug that needs to be resolved. + var diagnostics hcl.Diagnostics + defer func() { + if r := recover(); r != nil { + diagnostics.Extend(hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Panic occurred in preview. This should not happen, please report this to Coder.", + Detail: fmt.Sprintf("panic in preview: %+v", r), + }, + }) + } + }() + + logger := slog.New(slog.DiscardHandler) + + varFiles, err := tfVarFiles("", dir) + if err != nil { + return nil, hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Files not found", + Detail: err.Error(), + }, + } + } + + // moduleSource is "" for a local module + p := parser.New(dir, "", + parser.OptionWithLogger(logger), + parser.OptionStopOnHCLError(false), + parser.OptionWithDownloads(false), + parser.OptionWithSkipCachedModules(true), + parser.OptionWithTFVarsPaths(varFiles...), + ) + + err = p.ParseFS(ctx, ".") + if err != nil { + return nil, hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Parse terraform files", + Detail: err.Error(), + }, + } + } + + modules, err := p.EvaluateAll(ctx) + if err != nil { + return nil, hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Evaluate terraform files", + Detail: err.Error(), + }, + } + } + + rp, rpDiags := parameters(modules) + presets, presetDiags := presets(modules, rp) + return presets, diagnostics.Extend(rpDiags).Extend(presetDiags) +} diff --git a/preview_test.go b/preview_test.go index b0ea9b3..e2e21db 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 []types.Preset warnings []*regexp.Regexp }{ { @@ -520,6 +521,9 @@ func Test_Extract(t *testing.T) { require.True(t, ok, "unknown parameter %s", param.Name) check(t, param) } + + presets, diags := preview.PreviewPresets(context.Background(), dirFs) + assert.ElementsMatch(t, tc.presets, presets) }) } } diff --git a/types/preset.go b/types/preset.go new file mode 100644 index 0000000..65f5d0a --- /dev/null +++ b/types/preset.go @@ -0,0 +1,17 @@ +package types + +const ( + BlockTypePreset = "coder_workspace_preset" +) + +type Preset struct { + PresetData + // Diagnostics is used to store any errors that occur during parsing + // of the parameter. + Diagnostics Diagnostics `json:"diagnostics"` +} + +type PresetData struct { + Name string `json:"name"` + Parameters map[string]string `json:"parameters"` +}