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 3b5f1d0..d40a6cc 100644 --- a/extract/parameter.go +++ b/extract/parameter.go @@ -252,7 +252,7 @@ func optionalStringEnum[T ~string](block *terraform.Block, key string, def T, va tyAttr := block.GetAttribute(key) return "", &hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: fmt.Sprintf("Invalid %q attribute", key), + Summary: fmt.Sprintf("Invalid %q attribute for block %s", key, block.Label()), Detail: err.Error(), Subject: &(tyAttr.HCLAttribute().Range), //Context: &(block.HCLBlock().DefRange), @@ -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", key), - 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 { @@ -393,7 +396,7 @@ func required(block *terraform.Block, keys ...string) hcl.Diagnostics { r := block.HCLBlock().Body.MissingItemRange() diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: fmt.Sprintf("Missing required attribute %q", key), + Summary: fmt.Sprintf("Missing required attribute %q for block %q", key, block.Label()), Detail: fmt.Sprintf("The %s attribute is required", key), Subject: &r, Extra: nil, 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/go.mod b/go.mod index 6db51d7..aae669c 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/hashicorp/go-cty v1.5.0 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/hc-install v0.9.2 - github.com/hashicorp/hcl/v2 v2.23.0 + github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform-exec v0.23.0 github.com/hashicorp/terraform-json v0.25.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 diff --git a/go.sum b/go.sum index b60776c..c0d28a2 100644 --- a/go.sum +++ b/go.sum @@ -963,8 +963,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= -github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= -github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I= diff --git a/init.go b/init.go new file mode 100644 index 0000000..86eaaad --- /dev/null +++ b/init.go @@ -0,0 +1,42 @@ +package preview + +import ( + "github.com/aquasecurity/trivy/pkg/iac/scanners/terraform/parser/funcs" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "golang.org/x/xerrors" +) + +// init intends to override some of the default functions afforded by terraform. +// Specifically, any functions that require the context of the host. +// +// This is really unfortunate, but all the functions are globals, and this +// is the only way to override them. +func init() { + // PathExpandFunc looks for references to a home directory on the host. The + // preview rendering should not have access to the host's home directory path, + // and will return an error if it is used. + funcs.PathExpandFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + // This code is taken directly from https://github.com/mitchellh/go-homedir/blob/af06845cf3004701891bf4fdb884bfe4920b3727/homedir.go#L58 + // The only change is that instead of expanding the path, we return an error + path := args[0].AsString() + if len(path) == 0 { + return cty.StringVal(path), nil + } + + if path[0] != '~' { + return cty.StringVal(path), nil + } + + return cty.NilVal, xerrors.Errorf("not allowed to expand paths starting with '~' in this context") + }, + }) +} diff --git a/internal/verify/exec.go b/internal/verify/exec.go index 1294025..2c64bfd 100644 --- a/internal/verify/exec.go +++ b/internal/verify/exec.go @@ -56,14 +56,15 @@ func (e WorkingExecutable) Init(ctx context.Context) error { return e.TF.Init(ctx, tfexec.Upgrade(true)) } -func (e WorkingExecutable) Plan(ctx context.Context, outPath string) (bool, error) { - changes, err := e.TF.Plan(ctx, tfexec.Out(outPath)) +func (e WorkingExecutable) Plan(ctx context.Context, outPath string, opts ...tfexec.PlanOption) (bool, error) { + opts = append(opts, tfexec.Out(outPath)) + changes, err := e.TF.Plan(ctx, opts...) return changes, err } -func (e WorkingExecutable) Apply(ctx context.Context) ([]byte, error) { +func (e WorkingExecutable) Apply(ctx context.Context, opts ...tfexec.ApplyOption) ([]byte, error) { var out bytes.Buffer - err := e.TF.ApplyJSON(ctx, &out) + err := e.TF.ApplyJSON(ctx, &out, opts...) return out.Bytes(), err } diff --git a/plan.go b/plan.go index bcf44fa..ef19c36 100644 --- a/plan.go +++ b/plan.go @@ -80,8 +80,15 @@ func planJSONHook(dfs fs.FS, input Input) (func(ctx *tfcontext.Context, blocks t // priorPlanModule returns the state data of the module a given block is in. func priorPlanModule(plan *tfjson.Plan, block *terraform.Block) *tfjson.StateModule { + if plan.PriorState == nil || plan.PriorState.Values == nil { + return nil // No root module available in the plan, nothing to do + } + + rootModule := plan.PriorState.Values.RootModule + if !block.InModule() { - return plan.PriorState.Values.RootModule + // If the block is not in a module, then we can just return the root module. + return rootModule } var modPath []string @@ -94,9 +101,12 @@ func priorPlanModule(plan *tfjson.Plan, block *terraform.Block) *tfjson.StateMod } } - current := plan.PriorState.Values.RootModule + current := rootModule for i := range modPath { idx := slices.IndexFunc(current.ChildModules, func(m *tfjson.StateModule) bool { + if m == nil { + return false + } return m.Address == strings.Join(modPath[:i+1], ".") }) if idx == -1 { 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 5635bf1..06db808 100644 --- a/preview.go +++ b/preview.go @@ -6,7 +6,6 @@ import ( "fmt" "io/fs" "log/slog" - "path/filepath" "github.com/aquasecurity/trivy/pkg/iac/scanners/terraform/parser" "github.com/hashicorp/hcl/v2" @@ -14,6 +13,7 @@ import ( ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/coder/preview/hclext" + "github.com/coder/preview/tfvars" "github.com/coder/preview/types" ) @@ -26,6 +26,9 @@ type Input struct { ParameterValues map[string]string Owner types.WorkspaceOwner Logger *slog.Logger + // TFVars will override any variables set in '.tfvars' files. + // The value set must be a cty.Value, as the type can be anything. + TFVars map[string]cty.Value } type Output struct { @@ -38,6 +41,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. @@ -80,12 +84,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn } }() - // TODO: Fix logging. There is no way to pass in an instanced logger to - // the parser. - // slog.SetLogLoggerLevel(slog.LevelDebug) - // slog.SetDefault(slog.New(log.NewHandler(os.Stderr, nil))) - - varFiles, err := tfVarFiles("", dir) + varFiles, err := tfvars.TFVarFiles("", dir) if err != nil { return nil, hcl.Diagnostics{ { @@ -96,6 +95,17 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn } } + variableValues, err := tfvars.LoadTFVars(dir, varFiles) + if err != nil { + return nil, hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Failed to load tfvars from files", + Detail: err.Error(), + }, + } + } + planHook, err := planJSONHook(dir, input) if err != nil { return nil, hcl.Diagnostics{ @@ -123,16 +133,24 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn logger = slog.New(slog.DiscardHandler) } + // Override with user-supplied variables + for k, v := range input.TFVars { + variableValues[k] = v + } + // 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...), parser.OptionWithEvalHook(planHook), parser.OptionWithEvalHook(ownerHook), + parser.OptionWithWorkingDirectoryPath("/"), parser.OptionWithEvalHook(parameterContextsEvalHook(input)), + // 'OptionsWithTfVars' cannot be set with 'OptionWithTFVarsPaths'. So load the + // tfvars from the files ourselves and merge with the user-supplied tf vars. + parser.OptionsWithTfVars(variableValues), ) err = p.ParseFS(ctx, ".") @@ -161,6 +179,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 @@ -170,6 +189,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) } @@ -178,33 +198,3 @@ func (i Input) RichParameterValue(key string) (string, bool) { p, ok := i.ParameterValues[key] return p, ok } - -// tfVarFiles extracts any .tfvars files from the given directory. -// TODO: Test nested directories and how that should behave. -func tfVarFiles(path string, dir fs.FS) ([]string, error) { - dp := "." - entries, err := fs.ReadDir(dir, dp) - if err != nil { - return nil, fmt.Errorf("read dir %q: %w", dp, err) - } - - files := make([]string, 0) - for _, entry := range entries { - if entry.IsDir() { - subD, err := fs.Sub(dir, entry.Name()) - if err != nil { - return nil, fmt.Errorf("sub dir %q: %w", entry.Name(), err) - } - newFiles, err := tfVarFiles(filepath.Join(path, entry.Name()), subD) - if err != nil { - return nil, err - } - files = append(files, newFiles...) - } - - if filepath.Ext(entry.Name()) == ".tfvars" { - files = append(files, filepath.Join(path, entry.Name())) - } - } - return files, nil -} diff --git a/preview_test.go b/preview_test.go index b0ea9b3..435fb93 100644 --- a/preview_test.go +++ b/preview_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zclconf/go-cty/cty" "github.com/coder/preview" "github.com/coder/preview/types" @@ -42,6 +43,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 }{ { @@ -49,13 +51,25 @@ func Test_Extract(t *testing.T) { dir: "badparam", failPreview: true, }, + { + name: "sometags", + dir: "sometags", + expTags: map[string]string{ + "string": "foo", + "number": "42", + "bool": "true", + // null tags are omitted + }, + unknownTags: []string{ + "complex", "map", "list", + }, + }, { name: "simple static values", dir: "static", expTags: map[string]string{ "zone": "developers", }, - unknownTags: []string{}, params: map[string]assertParam{ "region": ap().value("us"). def("us"). @@ -230,6 +244,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", @@ -459,6 +529,37 @@ func Test_Extract(t *testing.T) { optNames("GoLand 2024.3", "IntelliJ IDEA Ultimate 2024.3", "PyCharm Professional 2024.3"), }, }, + { + name: "tfvars_from_file", + dir: "tfvars", + expTags: map[string]string{}, + input: preview.Input{ + ParameterValues: map[string]string{}, + }, + unknownTags: []string{}, + params: map[string]assertParam{ + "variable_values": ap(). + def("alex").optVals("alex", "bob", "claire", "jason"), + }, + }, + { + name: "tfvars_from_input", + dir: "tfvars", + expTags: map[string]string{}, + input: preview.Input{ + ParameterValues: map[string]string{}, + TFVars: map[string]cty.Value{ + "one": cty.StringVal("andrew"), + "two": cty.StringVal("bill"), + "three": cty.StringVal("carter"), + }, + }, + unknownTags: []string{}, + params: map[string]assertParam{ + "variable_values": ap(). + def("andrew").optVals("andrew", "bill", "carter", "jason"), + }, + }, { name: "unknownoption", dir: "unknownoption", @@ -510,7 +611,18 @@ func Test_Extract(t *testing.T) { // Assert tags validTags := output.WorkspaceTags.Tags() - assert.Equal(t, tc.expTags, validTags) + for k, expected := range tc.expTags { + tag, ok := validTags[k] + if !ok { + t.Errorf("expected tag %q to be present in output, but it was not", k) + continue + } + if tag != expected { + assert.JSONEqf(t, expected, tag, "tag %q does not match expected, nor is it a json equivalent", k) + } + } + assert.Equal(t, len(tc.expTags), len(output.WorkspaceTags.Tags()), "unexpected number of tags in output") + assert.ElementsMatch(t, tc.unknownTags, output.WorkspaceTags.UnusableTags().SafeNames()) // Assert params @@ -520,6 +632,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/previewe2e_test.go b/previewe2e_test.go index 3a9adb2..bcfeb0b 100644 --- a/previewe2e_test.go +++ b/previewe2e_test.go @@ -10,10 +10,12 @@ import ( "testing" "time" + "github.com/hashicorp/terraform-exec/tfexec" "github.com/stretchr/testify/require" "github.com/coder/preview" "github.com/coder/preview/internal/verify" + "github.com/coder/preview/tfvars" "github.com/coder/preview/types" ) @@ -102,11 +104,11 @@ func Test_VerifyE2E(t *testing.T) { entryWrkPath := t.TempDir() - for _, tfexec := range tfexecs { - tfexec := tfexec + for _, tfexecutable := range tfexecs { + tfexecutable := tfexecutable - t.Run(tfexec.Version, func(t *testing.T) { - wp := filepath.Join(entryWrkPath, tfexec.Version) + t.Run(tfexecutable.Version, func(t *testing.T) { + wp := filepath.Join(entryWrkPath, tfexecutable.Version) err := os.MkdirAll(wp, 0755) require.NoError(t, err, "creating working dir") @@ -118,7 +120,7 @@ func Test_VerifyE2E(t *testing.T) { err = verify.CopyTFFS(wp, subFS) require.NoError(t, err, "copying test data to working dir") - exe, err := tfexec.WorkingDir(wp) + exe, err := tfexecutable.WorkingDir(wp) require.NoError(t, err, "creating working executable") ctx, cancel := context.WithTimeout(context.Background(), time.Minute*2) @@ -126,9 +128,19 @@ func Test_VerifyE2E(t *testing.T) { err = exe.Init(ctx) require.NoError(t, err, "terraform init") + tfVarFiles, err := tfvars.TFVarFiles("", subFS) + require.NoError(t, err, "loading tfvars files") + + planOpts := make([]tfexec.PlanOption, 0) + applyOpts := make([]tfexec.ApplyOption, 0) + for _, varFile := range tfVarFiles { + planOpts = append(planOpts, tfexec.VarFile(varFile)) + applyOpts = append(applyOpts, tfexec.VarFile(varFile)) + } + planOutFile := "tfplan" planOutPath := filepath.Join(wp, planOutFile) - _, err = exe.Plan(ctx, planOutPath) + _, err = exe.Plan(ctx, planOutPath, planOpts...) require.NoError(t, err, "terraform plan") plan, err := exe.ShowPlan(ctx, planOutPath) @@ -141,7 +153,7 @@ func Test_VerifyE2E(t *testing.T) { err = os.WriteFile(filepath.Join(wp, "plan.json"), pd, 0644) require.NoError(t, err, "writing plan.json") - _, err = exe.Apply(ctx) + _, err = exe.Apply(ctx, applyOpts...) require.NoError(t, err, "terraform apply") state, err := exe.Show(ctx) diff --git a/site/src/types/preview.ts b/site/src/types/preview.ts deleted file mode 100644 index d415468..0000000 --- a/site/src/types/preview.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Code generated by 'guts'. DO NOT EDIT. - -// From types/diagnostics.go -export const DiagnosticCodeRequired = "required"; - -// From types/diagnostics.go -export interface DiagnosticExtra { - readonly code: string; - // empty interface{} type, falling back to unknown - readonly Wrapped: unknown; -} - -// From types/diagnostics.go -export type DiagnosticSeverityString = "error" | "warning"; - -export const DiagnosticSeverityStrings: DiagnosticSeverityString[] = ["error", "warning"]; - -// From types/diagnostics.go -export type Diagnostics = readonly (FriendlyDiagnostic)[]; - -// From types/diagnostics.go -export interface FriendlyDiagnostic { - readonly severity: DiagnosticSeverityString; - readonly summary: string; - readonly detail: string; - readonly extra: DiagnosticExtra; -} - -// From types/value.go -export interface NullHCLString { - readonly value: string; - readonly valid: boolean; -} - -// From types/parameter.go -export interface Parameter extends ParameterData { - readonly value: NullHCLString; - readonly diagnostics: Diagnostics; -} - -// From types/parameter.go -export interface ParameterData { - readonly name: string; - readonly display_name: string; - readonly description: string; - readonly type: ParameterType; - // this is likely an enum in an external package "github.com/coder/terraform-provider-coder/v2/provider.ParameterFormType" - readonly form_type: string; - readonly styling: ParameterStyling; - readonly mutable: boolean; - readonly default_value: NullHCLString; - readonly icon: string; - readonly options: readonly (ParameterOption)[]; - readonly validations: readonly (ParameterValidation)[]; - readonly required: boolean; - readonly order: number; - readonly ephemeral: boolean; -} - -// From types/parameter.go -export interface ParameterOption { - readonly name: string; - readonly description: string; - readonly value: NullHCLString; - readonly icon: string; -} - -// From types/parameter.go -export interface ParameterStyling { - readonly placeholder?: string; - readonly disabled?: boolean; - readonly label?: string; -} - -// From types/enum.go -export type ParameterType = "bool" | "list(string)" | "number" | "string"; - -export const ParameterTypes: ParameterType[] = ["bool", "list(string)", "number", "string"]; - -// From types/parameter.go -export interface ParameterValidation { - readonly validation_error: string; - readonly validation_regex: string | null; - readonly validation_min: number | null; - readonly validation_max: number | null; - readonly validation_monotonic: string | null; -} - -// From web/session.go -export interface Request { - readonly id: number; - readonly inputs: Record; -} - -// From web/session.go -export interface Response { - readonly id: number; - readonly diagnostics: Diagnostics; - readonly parameters: readonly Parameter[]; -} - -// From web/session.go -export interface SessionInputs { - readonly PlanPath: string; - readonly User: WorkspaceOwner; -} - -// From types/value.go -export const UnknownStringValue = ""; - -// From types/parameter.go -export const ValidationMonotonicDecreasing = "decreasing"; - -// From types/parameter.go -export const ValidationMonotonicIncreasing = "increasing"; - -// From types/owner.go -export interface WorkspaceOwner { - readonly id: string; - readonly name: string; - readonly full_name: string; - readonly email: string; - readonly ssh_public_key: string; - readonly groups: readonly string[]; - readonly login_type: string; - readonly rbac_roles: readonly WorkspaceOwnerRBACRole[]; -} - -// From types/owner.go -export interface WorkspaceOwnerRBACRole { - readonly name: string; - readonly org_id: string; -} - 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/testdata/sometags/main.tf b/testdata/sometags/main.tf new file mode 100644 index 0000000..3bbdb93 --- /dev/null +++ b/testdata/sometags/main.tf @@ -0,0 +1,29 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.4.0-pre0" + } + } +} + +data "coder_workspace_tags" "custom_workspace_tags" { + tags = { + "string" = "foo" + "number" = 42 + "bool" = true + "list" = ["a", "b", "c"] + "map" = { + "key1" = "value1" + "key2" = "value2" + } + "complex" = { + "nested_list" = [1, 2, 3] + "nested" = { + "key" = "value" + } + } + "null" = null + } +} + diff --git a/testdata/sometags/skipe2e b/testdata/sometags/skipe2e new file mode 100644 index 0000000..8bcda2e --- /dev/null +++ b/testdata/sometags/skipe2e @@ -0,0 +1 @@ +Complex types are not supported by coder provider \ No newline at end of file diff --git a/testdata/static/main.tf b/testdata/static/main.tf index 8e67ac5..0260e72 100644 --- a/testdata/static/main.tf +++ b/testdata/static/main.tf @@ -15,6 +15,7 @@ terraform { data "coder_workspace_tags" "custom_workspace_tags" { tags = { "zone" = "developers" + "null" = null } } diff --git a/testdata/tfvars/.auto.tfvars.json b/testdata/tfvars/.auto.tfvars.json new file mode 100644 index 0000000..879a3b1 --- /dev/null +++ b/testdata/tfvars/.auto.tfvars.json @@ -0,0 +1 @@ +{"four":"jason"} diff --git a/testdata/tfvars/main.tf b/testdata/tfvars/main.tf new file mode 100644 index 0000000..1c950f4 --- /dev/null +++ b/testdata/tfvars/main.tf @@ -0,0 +1,61 @@ +// Base case for workspace tags + parameters. +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + version = "3.0.2" + } + } +} + +variable "one" { + default = "alice" + type = string +} + +variable "two" { + default = "bob" + type = string +} + +variable "three" { + default = "charlie" + type = string +} + +variable "four" { + default = "jack" + type = string +} + + +data "coder_parameter" "variable_values" { + name = "variable_values" + description = "Just to show the variable values" + type = "string" + default = var.one + + + option { + name = "one" + value = var.one + } + + option { + name = "two" + value = var.two + } + + option { + name = "three" + value = var.three + } + + option { + name = "four" + value = var.four + } +} diff --git a/testdata/tfvars/values.tfvars b/testdata/tfvars/values.tfvars new file mode 100644 index 0000000..83cabd4 --- /dev/null +++ b/testdata/tfvars/values.tfvars @@ -0,0 +1,2 @@ +one="alex" +three="claire" diff --git a/tfvars/load.go b/tfvars/load.go new file mode 100644 index 0000000..ad98456 --- /dev/null +++ b/tfvars/load.go @@ -0,0 +1,105 @@ +// Code taken from https://github.com/aquasecurity/trivy/blob/0449787eb52854cbdd7f4c5794adbf58965e60f8/pkg/iac/scanners/terraform/parser/load_vars.go +package tfvars + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + hcljson "github.com/hashicorp/hcl/v2/json" + "github.com/zclconf/go-cty/cty" +) + +// TFVarFiles extracts any .tfvars files from the given directory. +// TODO: Test nested directories and how that should behave. +func TFVarFiles(path string, dir fs.FS) ([]string, error) { + dp := "." + entries, err := fs.ReadDir(dir, dp) + if err != nil { + return nil, fmt.Errorf("read dir %q: %w", dp, err) + } + + files := make([]string, 0) + for _, entry := range entries { + if entry.IsDir() { + subD, err := fs.Sub(dir, entry.Name()) + if err != nil { + return nil, fmt.Errorf("sub dir %q: %w", entry.Name(), err) + } + newFiles, err := TFVarFiles(filepath.Join(path, entry.Name()), subD) + if err != nil { + return nil, err + } + files = append(files, newFiles...) + } + + if filepath.Ext(entry.Name()) == ".tfvars" || strings.HasSuffix(entry.Name(), ".tfvars.json") { + files = append(files, filepath.Join(path, entry.Name())) + } + } + return files, nil +} + +func LoadTFVars(srcFS fs.FS, filenames []string) (map[string]cty.Value, error) { + combinedVars := make(map[string]cty.Value) + + // Intentionally avoid loading terraform variables from the host environment. + // Trivy (and terraform) use os.Environ() to search for "TF_VAR_" prefixed + // environment variables. + // + // Preview should be sandboxed, so this code should not be included. + + for _, filename := range filenames { + vars, err := LoadTFVarsFile(srcFS, filename) + if err != nil { + return nil, fmt.Errorf("failed to load tfvars from %s: %w", filename, err) + } + for k, v := range vars { + combinedVars[k] = v + } + } + + return combinedVars, nil +} + +func LoadTFVarsFile(srcFS fs.FS, filename string) (map[string]cty.Value, error) { + inputVars := make(map[string]cty.Value) + if filename == "" { + return inputVars, nil + } + + src, err := fs.ReadFile(srcFS, filepath.ToSlash(filename)) + if err != nil { + return nil, err + } + + var attrs hcl.Attributes + if strings.HasSuffix(filename, ".json") { + variableFile, err := hcljson.Parse(src, filename) + if err != nil { + return nil, err + } + attrs, err = variableFile.Body.JustAttributes() + if err != nil { + return nil, err + } + } else { + variableFile, err := hclsyntax.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1}) + if err != nil { + return nil, err + } + attrs, err = variableFile.Body.JustAttributes() + if err != nil { + return nil, err + } + } + + for _, attr := range attrs { + inputVars[attr.Name], _ = attr.Expr.Value(&hcl.EvalContext{}) + } + + return inputVars, nil +} diff --git a/types/diagnostics.go b/types/diagnostics.go index 1884726..c13da4d 100644 --- a/types/diagnostics.go +++ b/types/diagnostics.go @@ -10,6 +10,11 @@ const ( // DiagnosticCodeRequired is used when a parameter value is `null`, but // the parameter is required. DiagnosticCodeRequired = "required" + + // DiagnosticModuleNotLoaded is used when a module block is present, but + // the mode failed to load. This can be because `.terraform/modules` is + // not present. + DiagnosticModuleNotLoaded = "module_not_loaded" ) type DiagnosticExtra struct { 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"` +} diff --git a/warnings.go b/warnings.go index 781f49a..12011f5 100644 --- a/warnings.go +++ b/warnings.go @@ -6,6 +6,8 @@ import ( "github.com/aquasecurity/trivy/pkg/iac/terraform" "github.com/hashicorp/hcl/v2" + + "github.com/coder/preview/types" ) func warnings(modules terraform.Modules) hcl.Diagnostics { @@ -53,12 +55,12 @@ func unresolvedModules(modules terraform.Modules) hcl.Diagnostics { label += " " + fmt.Sprintf("%q", l) } - diags = diags.Append(&hcl.Diagnostic{ + diags = diags.Append(types.DiagnosticCode(&hcl.Diagnostic{ Severity: hcl.DiagWarning, Summary: "Module not loaded. Did you run `terraform init`?", Detail: fmt.Sprintf("Module '%s' in file %q cannot be resolved. This module will be ignored.", label, block.HCLBlock().DefRange), Subject: &(block.HCLBlock().DefRange), - }) + }, types.DiagnosticModuleNotLoaded)) } } } diff --git a/workspacetags.go b/workspacetags.go index d3f6c76..4c4b0a7 100644 --- a/workspacetags.go +++ b/workspacetags.go @@ -17,18 +17,9 @@ func workspaceTags(modules terraform.Modules, files map[string]*hcl.File) (types for _, mod := range modules { blocks := mod.GetDatasByType("coder_workspace_tags") for _, block := range blocks { - evCtx := block.Context().Inner() - tagsAttr := block.GetAttribute("tags") if tagsAttr.IsNil() { - r := block.HCLBlock().Body.MissingItemRange() - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Missing required argument", - Detail: `"tags" attribute is required by coder_workspace_tags blocks`, - Subject: &r, - EvalContext: evCtx, - }) + // Nil tags block is valid, just skip it. continue } @@ -47,23 +38,14 @@ func workspaceTags(modules terraform.Modules, files map[string]*hcl.File) (types continue } - // tagsObj, ok := tagsAttr.HCLAttribute().Expr.(*hclsyntax.ObjectConsExpr) - // if !ok { - // diags = diags.Append(&hcl.Diagnostic{ - // Severity: hcl.DiagError, - // Summary: "Incorrect type for \"tags\" attribute", - // // TODO: better error message for types - // Detail: fmt.Sprintf(`"tags" attribute must be an 'ObjectConsExpr', but got %T`, tagsAttr.HCLAttribute().Expr), - // Subject: &tagsAttr.HCLAttribute().NameRange, - // Context: &tagsAttr.HCLAttribute().Range, - // Expression: tagsAttr.HCLAttribute().Expr, - // EvalContext: block.Context().Inner(), - // }) - // continue - //} - var tags []types.Tag tagsValue.ForEachElement(func(key cty.Value, val cty.Value) (stop bool) { + if val.IsNull() { + // null tags with null values are omitted + // This matches the behavior of `terraform apply`` + return false + } + r := tagsAttr.HCLAttribute().Expr.Range() tag, tagDiag := newTag(&r, files, key, val) if tagDiag != nil { @@ -75,15 +57,7 @@ func workspaceTags(modules terraform.Modules, files map[string]*hcl.File) (types return false }) - // for _, item := range tagsObj.Items { - // tag, tagDiag := newTag(tagsObj, files, item, evCtx) - // if tagDiag != nil { - // diags = diags.Append(tagDiag) - // continue - // } - // - // tags = append(tags, tag) - //} + tagBlocks = append(tagBlocks, types.TagBlock{ Tags: tags, Block: block, @@ -96,71 +70,41 @@ func workspaceTags(modules terraform.Modules, files map[string]*hcl.File) (types // newTag creates a workspace tag from its hcl expression. func newTag(srcRange *hcl.Range, _ map[string]*hcl.File, key, val cty.Value) (types.Tag, *hcl.Diagnostic) { - // key, kdiags := expr.KeyExpr.Value(evCtx) - // val, vdiags := expr.ValueExpr.Value(evCtx) - - // TODO: ??? - - // if kdiags.HasErrors() { - // key = cty.UnknownVal(cty.String) - //} - // if vdiags.HasErrors() { - // val = cty.UnknownVal(cty.String) - //} - if key.IsKnown() && key.Type() != cty.String { return types.Tag{}, &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid key type for tags", Detail: fmt.Sprintf("Key must be a string, but got %s", key.Type().FriendlyName()), - //Subject: &r, - Context: srcRange, - //Expression: expr.KeyExpr, - //EvalContext: evCtx, - } - } - - if val.IsKnown() && val.Type() != cty.String { - fr := "" - if !val.Type().Equals(cty.NilType) { - fr = val.Type().FriendlyName() - } - // r := expr.ValueExpr.Range() - return types.Tag{}, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid value type for tag", - Detail: fmt.Sprintf("Value must be a string, but got %s", fr), - //Subject: &r, - Context: srcRange, - //Expression: expr.ValueExpr, - //EvalContext: evCtx, + Context: srcRange, } } tag := types.Tag{ Key: types.HCLString{ Value: key, - //ValueDiags: kdiags, - //ValueExpr: expr.KeyExpr, }, Value: types.HCLString{ Value: val, - //ValueDiags: vdiags, - //ValueExpr: expr.ValueExpr, }, } - // ks, err := source(expr.KeyExpr.Range(), files) - // if err == nil { - // src := string(ks) - // tag.Key.Source = &src - //} - // - // vs, err := source(expr.ValueExpr.Range(), files) - // if err == nil { - // src := string(vs) - // tag.Value.Source = &src - //} + switch val.Type() { + case cty.String, cty.Bool, cty.Number: + // These types are supported and can be safely converted to a string. + default: + fr := "" + if !val.Type().Equals(cty.NilType) { + fr = val.Type().FriendlyName() + } + + // Unsupported types will be treated as errors. + tag.Value.ValueDiags = tag.Value.ValueDiags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Invalid value type for tag %q", tag.KeyString()), + Detail: fmt.Sprintf("Value must be a string, but got %s.", fr), + Context: srcRange, + }) + } return tag, nil }