diff --git a/command/build.go b/command/build.go index 72c3e3512..3eda31e91 100644 --- a/command/build.go +++ b/command/build.go @@ -58,14 +58,13 @@ func (c *BuildCommand) ParseArgs(args []string) (*BuildArgs, int) { return &cfg, 0 } -func (m *Meta) GetConfigFromHCL(cla *MetaArgs) (packer.BuildGetter, int) { +func (m *Meta) GetConfigFromHCL(cla *MetaArgs) (*hcl2template.PackerConfig, int) { parser := &hcl2template.Parser{ Parser: hclparse.NewParser(), BuilderSchemas: m.CoreConfig.Components.BuilderStore, ProvisionersSchemas: m.CoreConfig.Components.ProvisionerStore, PostProcessorsSchemas: m.CoreConfig.Components.PostProcessorStore, } - cfg, diags := parser.Parse(cla.Path, cla.VarFiles, cla.Vars) return cfg, writeDiags(m.Ui, parser.Files(), diags) } @@ -88,15 +87,15 @@ func writeDiags(ui packer.Ui, files map[string]*hcl.File, diags hcl.Diagnostics) return 0 } -func (m *Meta) GetConfig(cla *MetaArgs) (packer.BuildGetter, int) { - cfgType, err := ConfigType(cla.Path) +func (m *Meta) GetConfig(cla *MetaArgs) (packer.Handler, int) { + cfgType, err := cla.GetConfigType() if err != nil { - m.Ui.Error(fmt.Sprintf("could not tell config type: %s", err)) + m.Ui.Error(fmt.Sprintf("%q: %s", cla.Path, err)) return nil, 1 } switch cfgType { - case "hcl": + case ConfigTypeHCL2: // TODO(azr): allow to pass a slice of files here. return m.GetConfigFromHCL(cla) default: @@ -110,9 +109,18 @@ func (m *Meta) GetConfig(cla *MetaArgs) (packer.BuildGetter, int) { } } -func (m *Meta) GetConfigFromJSON(cla *MetaArgs) (packer.BuildGetter, int) { +func (m *Meta) GetConfigFromJSON(cla *MetaArgs) (*packer.Core, int) { // Parse the template - tpl, err := template.ParseFile(cla.Path) + var tpl *template.Template + var err error + if cla.Path == "" { + // here cla validation passed so this means we want a default builder + // and we probably are in the console command + tpl, err = template.Parse(TiniestBuilder) + } else { + tpl, err = template.ParseFile(cla.Path) + } + if err != nil { m.Ui.Error(fmt.Sprintf("Failed to parse template: %s", err)) return nil, 1 diff --git a/command/cli.go b/command/cli.go index bbd555d18..4393624a6 100644 --- a/command/cli.go +++ b/command/cli.go @@ -2,7 +2,6 @@ package command import ( "flag" - "fmt" "strings" "github.com/hashicorp/packer/helper/enumflag" @@ -10,32 +9,44 @@ import ( sliceflag "github.com/hashicorp/packer/helper/flag-slice" ) +//go:generate enumer -type configType -trimprefix ConfigType -transform snake +type configType int + +const ( + ConfigTypeJSON configType = iota // default config type + ConfigTypeHCL2 +) + +func (c *configType) Set(value string) error { + v, err := configTypeString(value) + if err == nil { + *c = v + } + return err +} + // ConfigType tells what type of config we should use, it can return values // like "hcl" or "json". // Make sure Args was correctly set before. -func ConfigType(args ...string) (string, error) { - switch len(args) { - // TODO(azr): in the future, I want to allow passing multiple arguments to - // merge HCL confs together; but this will probably need an RFC first. - case 1: - name := args[0] - if name == "-" { - // TODO(azr): To allow piping HCL2 confs (when args is "-"), we probably - // will need to add a setting that says "this is an HCL config". - return "json", nil - } - if strings.HasSuffix(name, ".pkr.hcl") || - strings.HasSuffix(name, ".pkr.json") { - return "hcl", nil - } - isDir, err := isDir(name) - if isDir { - return "hcl", err - } - return "json", err - default: - return "", fmt.Errorf("packer only takes one argument: %q", args) +func (ma *MetaArgs) GetConfigType() (configType, error) { + if ma.Path == "" { + return ma.ConfigType, nil } + name := ma.Path + if name == "-" { + // TODO(azr): To allow piping HCL2 confs (when args is "-"), we probably + // will need to add a setting that says "this is an HCL config". + return ma.ConfigType, nil + } + if strings.HasSuffix(name, ".pkr.hcl") || + strings.HasSuffix(name, ".pkr.json") { + return ConfigTypeHCL2, nil + } + isDir, err := isDir(name) + if isDir { + return ConfigTypeHCL2, err + } + return ma.ConfigType, err } // NewMetaArgs parses cli args and put possible values @@ -44,14 +55,19 @@ func (ma *MetaArgs) AddFlagSets(fs *flag.FlagSet) { fs.Var((*sliceflag.StringFlag)(&ma.Except), "except", "") fs.Var((*kvflag.Flag)(&ma.Vars), "var", "") fs.Var((*kvflag.StringSlice)(&ma.VarFiles), "var-file", "") + fs.Var(&ma.ConfigType, "config-type", "set to 'hcl2' to run in hcl2 mode when no file is passed.") } // MetaArgs defines commonalities between all comands type MetaArgs struct { + // TODO(azr): in the future, I want to allow passing multiple path to + // merge HCL confs together; but this will probably need an RFC first. Path string Only, Except []string Vars map[string]string VarFiles []string + // set to "hcl2" to force hcl2 mode + ConfigType configType } func (ba *BuildArgs) AddFlagSets(flags *flag.FlagSet) { @@ -78,7 +94,9 @@ type BuildArgs struct { } // ConsoleArgs represents a parsed cli line for a `packer console` -type ConsoleArgs struct{ MetaArgs } +type ConsoleArgs struct { + MetaArgs +} func (fa *FixArgs) AddFlagSets(flags *flag.FlagSet) { flags.BoolVar(&fa.Validate, "validate", true, "") diff --git a/command/configtype_enumer.go b/command/configtype_enumer.go new file mode 100644 index 000000000..5d02f0358 --- /dev/null +++ b/command/configtype_enumer.go @@ -0,0 +1,50 @@ +// Code generated by "enumer -type configType -trimprefix ConfigType -transform snake"; DO NOT EDIT. + +// +package command + +import ( + "fmt" +) + +const _configTypeName = "jsonhcl2" + +var _configTypeIndex = [...]uint8{0, 4, 8} + +func (i configType) String() string { + if i < 0 || i >= configType(len(_configTypeIndex)-1) { + return fmt.Sprintf("configType(%d)", i) + } + return _configTypeName[_configTypeIndex[i]:_configTypeIndex[i+1]] +} + +var _configTypeValues = []configType{0, 1} + +var _configTypeNameToValueMap = map[string]configType{ + _configTypeName[0:4]: 0, + _configTypeName[4:8]: 1, +} + +// configTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func configTypeString(s string) (configType, error) { + if val, ok := _configTypeNameToValueMap[s]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to configType values", s) +} + +// configTypeValues returns all values of the enum +func configTypeValues() []configType { + return _configTypeValues +} + +// IsAconfigType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i configType) IsAconfigType() bool { + for _, v := range _configTypeValues { + if i == v { + return true + } + } + return false +} diff --git a/command/console.go b/command/console.go index 79609520f..2bdec1042 100644 --- a/command/console.go +++ b/command/console.go @@ -3,7 +3,6 @@ package command import ( "bufio" "context" - "errors" "fmt" "io" "strings" @@ -12,19 +11,17 @@ import ( "github.com/hashicorp/packer/helper/wrappedreadline" "github.com/hashicorp/packer/helper/wrappedstreams" "github.com/hashicorp/packer/packer" - "github.com/hashicorp/packer/template" - "github.com/hashicorp/packer/template/interpolate" "github.com/posener/complete" ) -const TiniestBuilder = `{ +var TiniestBuilder = strings.NewReader(`{ "builders": [ { "type":"null", "communicator": "none" } ] -}` +}`) type ConsoleCommand struct { Meta @@ -51,49 +48,24 @@ func (c *ConsoleCommand) ParseArgs(args []string) (*ConsoleArgs, int) { } args = flags.Args() + if len(args) == 1 { + cfg.Path = args[0] + } return &cfg, 0 } func (c *ConsoleCommand) RunContext(ctx context.Context, cla *ConsoleArgs) int { - - var templ *template.Template - if cla.Path == "" { - // If user has not defined a builder, create a tiny null placeholder - // builder so that we can properly initialize the core - tpl, err := template.Parse(strings.NewReader(TiniestBuilder)) - if err != nil { - c.Ui.Error(fmt.Sprintf("Failed to generate placeholder template: %s", err)) - return 1 - } - templ = tpl - } else { - // Parse the provided template - tpl, err := template.ParseFile(cla.Path) - if err != nil { - c.Ui.Error(fmt.Sprintf("Failed to parse template: %s", err)) - return 1 - } - templ = tpl - } - - // Get the core - core, err := c.Meta.Core(templ, &cla.MetaArgs) - if err != nil { - c.Ui.Error(err.Error()) - return 1 - } - - // IO Loop - session := &REPLSession{ - Core: core, + packerStarter, ret := c.GetConfig(&cla.MetaArgs) + if ret != 0 { + return ret } // Determine if stdin is a pipe. If so, we evaluate directly. if c.StdinPiped() { - return c.modePiped(session) + return c.modePiped(packerStarter) } - return c.modeInteractive(session) + return c.modeInteractive(packerStarter) } func (*ConsoleCommand) Help() string { @@ -128,13 +100,14 @@ func (*ConsoleCommand) AutocompleteFlags() complete.Flags { } } -func (c *ConsoleCommand) modePiped(session *REPLSession) int { +func (c *ConsoleCommand) modePiped(cfg packer.Evaluator) int { var lastResult string scanner := bufio.NewScanner(wrappedstreams.Stdin()) + ret := 0 for scanner.Scan() { - result, err := session.Handle(strings.TrimSpace(scanner.Text())) - if err != nil { - return 0 + result, _, diags := cfg.EvaluateExpression(strings.TrimSpace(scanner.Text())) + if len(diags) > 0 { + ret = writeDiags(c.Ui, nil, diags) } // Store the last result lastResult = result @@ -142,10 +115,11 @@ func (c *ConsoleCommand) modePiped(session *REPLSession) int { // Output the final result c.Ui.Message(lastResult) - return 0 + return ret } -func (c *ConsoleCommand) modeInteractive(session *REPLSession) int { // Setup the UI so we can output directly to stdout +func (c *ConsoleCommand) modeInteractive(cfg packer.Evaluator) int { + // Setup the UI so we can output directly to stdout l, err := readline.NewEx(wrappedreadline.Override(&readline.Config{ Prompt: "> ", InterruptPrompt: "^C", @@ -170,76 +144,16 @@ func (c *ConsoleCommand) modeInteractive(session *REPLSession) int { // Setup th } else if err == io.EOF { break } - out, err := session.Handle(line) - if err == ErrSessionExit { - break + out, exit, diags := cfg.EvaluateExpression(line) + ret := writeDiags(c.Ui, nil, diags) + if exit { + return ret } - if err != nil { - c.Ui.Error(err.Error()) - continue - } - c.Ui.Say(out) + if exit { + return ret + } } return 0 } - -// ErrSessionExit is a special error result that should be checked for -// from Handle to signal a graceful exit. -var ErrSessionExit = errors.New("Session exit") - -// Session represents the state for a single Read-Evaluate-Print-Loop (REPL) session. -type REPLSession struct { - // Core is used for constructing interpolations based off packer templates - Core *packer.Core -} - -// Handle a single line of input from the REPL. -// -// The return value is the output and the error to show. -func (s *REPLSession) Handle(line string) (string, error) { - switch { - case strings.TrimSpace(line) == "": - return "", nil - case strings.TrimSpace(line) == "exit": - return "", ErrSessionExit - case strings.TrimSpace(line) == "help": - return s.handleHelp() - case strings.TrimSpace(line) == "variables": - return s.handleVariables() - default: - return s.handleEval(line) - } -} - -func (s *REPLSession) handleEval(line string) (string, error) { - ctx := s.Core.Context() - rendered, err := interpolate.Render(line, ctx) - if err != nil { - return "", fmt.Errorf("Error interpolating: %s", err) - } - return rendered, nil -} - -func (s *REPLSession) handleVariables() (string, error) { - varsstring := "\n" - for k, v := range s.Core.Context().UserVariables { - varsstring += fmt.Sprintf("%s: %+v,\n", k, v) - } - - return varsstring, nil -} - -func (s *REPLSession) handleHelp() (string, error) { - text := ` -The Packer console allows you to experiment with Packer interpolations. -You may access variables in the Packer config you called the console with. - -Type in the interpolation to test and hit to see the result. - -To exit the console, type "exit" and hit , or use Control-C. -` - - return strings.TrimSpace(text), nil -} diff --git a/command/console_test.go b/command/console_test.go new file mode 100644 index 000000000..0cfc07e21 --- /dev/null +++ b/command/console_test.go @@ -0,0 +1,42 @@ +package command + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/packer/hcl2template" + "github.com/hashicorp/packer/packer" + "github.com/stretchr/testify/assert" +) + +func Test_piping(t *testing.T) { + + tc := []struct { + piped string + command []string + env []string + expected string + }{ + {"help", []string{"console"}, nil, packer.ConsoleHelp + "\n"}, + {"help", []string{"console", "--config-type=hcl2"}, nil, hcl2template.PackerConsoleHelp + "\n"}, + {"var.fruit", []string{"console", filepath.Join(testFixture("var-arg"), "fruit_builder.pkr.hcl")}, []string{"PKR_VAR_fruit=potato"}, "potato\n"}, + {"upper(var.fruit)", []string{"console", filepath.Join(testFixture("var-arg"), "fruit_builder.pkr.hcl")}, []string{"PKR_VAR_fruit=potato"}, "POTATO\n"}, + {"1 + 5", []string{"console", "--config-type=hcl2"}, nil, "6\n"}, + {"var.images", []string{"console", filepath.Join(testFixture("var-arg"), "map.pkr.hcl")}, nil, "{\n" + ` "key" = "value"` + "\n}\n"}, + } + + for _, tc := range tc { + t.Run(fmt.Sprintf("echo %q | packer %s", tc.piped, tc.command), func(t *testing.T) { + p := helperCommand(t, tc.command...) + p.Stdin = strings.NewReader(tc.piped) + p.Env = append(p.Env, tc.env...) + bs, err := p.Output() + if err != nil { + t.Fatalf("%v: %s", err, bs) + } + assert.Equal(t, tc.expected, string(bs)) + }) + } +} diff --git a/command/exec_test.go b/command/exec_test.go new file mode 100644 index 000000000..2ffc7fefa --- /dev/null +++ b/command/exec_test.go @@ -0,0 +1,127 @@ +package command + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "testing" + + "github.com/hashicorp/packer/builder/file" + "github.com/hashicorp/packer/builder/null" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/post-processor/manifest" + shell_local_pp "github.com/hashicorp/packer/post-processor/shell-local" + filep "github.com/hashicorp/packer/provisioner/file" + "github.com/hashicorp/packer/provisioner/shell" + shell_local "github.com/hashicorp/packer/provisioner/shell-local" + "github.com/hashicorp/packer/version" +) + +// HasExec reports whether the current system can start new processes +// using os.StartProcess or (more commonly) exec.Command. +func HasExec() bool { + switch runtime.GOOS { + case "js": + return false + case "darwin": + if runtime.GOARCH == "arm64" { + return false + } + } + return true +} + +// MustHaveExec checks that the current system can start new processes +// using os.StartProcess or (more commonly) exec.Command. +// If not, MustHaveExec calls t.Skip with an explanation. +func MustHaveExec(t testing.TB) { + if !HasExec() { + t.Skipf("skipping test: cannot exec subprocess on %s/%s", runtime.GOOS, runtime.GOARCH) + } +} + +func helperCommandContext(t *testing.T, ctx context.Context, s ...string) (cmd *exec.Cmd) { + MustHaveExec(t) + + cs := []string{"-test.run=TestHelperProcess", "--"} + cs = append(cs, s...) + if ctx != nil { + cmd = exec.CommandContext(ctx, os.Args[0], cs...) + } else { + cmd = exec.Command(os.Args[0], cs...) + } + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + return cmd +} + +func helperCommand(t *testing.T, s ...string) *exec.Cmd { + return helperCommandContext(t, nil, s...) +} + +// TestHelperProcess isn't a real test. It's used as a helper process +// for TestParameterRun. +func TestHelperProcess(*testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + defer os.Exit(0) + + args := os.Args + for len(args) > 0 { + if args[0] == "--" { + args = args[1:] + break + } + args = args[1:] + } + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "No command\n") + os.Exit(2) + } + + cmd, args := args[0], args[1:] + switch cmd { + case "console": + os.Exit((&ConsoleCommand{Meta: commandMeta()}).Run(args)) + default: + fmt.Fprintf(os.Stderr, "Unknown command %q\n", cmd) + os.Exit(2) + } +} + +func commandMeta() Meta { + basicUi := &packer.BasicUi{ + Reader: os.Stdin, + Writer: os.Stdout, + ErrorWriter: os.Stdout, + } + + CommandMeta := Meta{ + CoreConfig: &packer.CoreConfig{ + Components: getBareComponentFinder(), + Version: version.Version, + }, + Ui: basicUi, + } + return CommandMeta +} + +func getBareComponentFinder() packer.ComponentFinder { + return packer.ComponentFinder{ + BuilderStore: packer.MapOfBuilder{ + "file": func() (packer.Builder, error) { return &file.Builder{}, nil }, + "null": func() (packer.Builder, error) { return &null.Builder{}, nil }, + }, + ProvisionerStore: packer.MapOfProvisioner{ + "shell-local": func() (packer.Provisioner, error) { return &shell_local.Provisioner{}, nil }, + "shell": func() (packer.Provisioner, error) { return &shell.Provisioner{}, nil }, + "file": func() (packer.Provisioner, error) { return &filep.Provisioner{}, nil }, + }, + PostProcessorStore: packer.MapOfPostProcessor{ + "shell-local": func() (packer.PostProcessor, error) { return &shell_local_pp.PostProcessor{}, nil }, + "manifest": func() (packer.PostProcessor, error) { return &manifest.PostProcessor{}, nil }, + }, + } +} diff --git a/command/test-fixtures/var-arg/map.pkr.hcl b/command/test-fixtures/var-arg/map.pkr.hcl new file mode 100644 index 000000000..5e72ec156 --- /dev/null +++ b/command/test-fixtures/var-arg/map.pkr.hcl @@ -0,0 +1,7 @@ + +variable "images" { + type = map(string) + default = { + key = "value" + } +} \ No newline at end of file diff --git a/hcl2template/parser.go b/hcl2template/parser.go index 5aab7d5d8..f251d6305 100644 --- a/hcl2template/parser.go +++ b/hcl2template/parser.go @@ -66,7 +66,7 @@ func (p *Parser) Parse(filename string, varFiles []string, argVars map[string]st var diags hcl.Diagnostics // parse config files - { + if filename != "" { hclFiles, jsonFiles, moreDiags := GetHCL2Files(filename, hcl2FileExt, hcl2JsonFileExt) diags = append(diags, moreDiags...) if len(hclFiles)+len(jsonFiles) == 0 { diff --git a/hcl2template/repl/format.go b/hcl2template/repl/format.go new file mode 100644 index 000000000..e302b093e --- /dev/null +++ b/hcl2template/repl/format.go @@ -0,0 +1,106 @@ +package repl + +import ( + "bufio" + "bytes" + "fmt" + "sort" + "strconv" + "strings" +) + +// FormatResult formats the given result value for human-readable output. +// +// The value must currently be a string, list, map, and any nested values +// with those same types. +func FormatResult(value interface{}) string { + return formatResult(value, false) +} + +func formatResult(value interface{}, nested bool) string { + if value == nil { + return "null" + } + switch output := value.(type) { + case string: + if nested { + return fmt.Sprintf("%q", output) + } + return output + case int: + return strconv.Itoa(output) + case float64: + return fmt.Sprintf("%g", output) + case bool: + switch { + case output == true: + return "true" + default: + return "false" + } + case []interface{}: + return formatListResult(output) + case map[string]interface{}: + return formatMapResult(output) + default: + return "" + } +} + +func formatListResult(value []interface{}) string { + var outputBuf bytes.Buffer + outputBuf.WriteString("[") + if len(value) > 0 { + outputBuf.WriteString("\n") + } + + for _, v := range value { + raw := formatResult(v, true) + outputBuf.WriteString(indent(raw)) + outputBuf.WriteString(",\n") + } + + outputBuf.WriteString("]") + return outputBuf.String() +} + +func formatMapResult(value map[string]interface{}) string { + ks := make([]string, 0, len(value)) + for k := range value { + ks = append(ks, k) + } + sort.Strings(ks) + + var outputBuf bytes.Buffer + outputBuf.WriteString("{") + if len(value) > 0 { + outputBuf.WriteString("\n") + } + + for _, k := range ks { + v := value[k] + rawK := formatResult(k, true) + rawV := formatResult(v, true) + + outputBuf.WriteString(indent(fmt.Sprintf("%s = %s", rawK, rawV))) + outputBuf.WriteString("\n") + } + + outputBuf.WriteString("}") + return outputBuf.String() +} + +func indent(value string) string { + var outputBuf bytes.Buffer + s := bufio.NewScanner(strings.NewReader(value)) + newline := false + for s.Scan() { + if newline { + outputBuf.WriteByte('\n') + } + outputBuf.WriteString(" " + s.Text()) + newline = true + } + + return outputBuf.String() +} diff --git a/hcl2template/repl/repl.go b/hcl2template/repl/repl.go new file mode 100644 index 000000000..d6aed8b46 --- /dev/null +++ b/hcl2template/repl/repl.go @@ -0,0 +1,4 @@ +// Package repl provides the structs and functions necessary to run REPL for +// HCL2. The REPL allows experimentation of HCL2 interpolations without having +// to run a HCL2 configuration. +package repl diff --git a/hcl2template/shim/values.go b/hcl2template/shim/values.go new file mode 100644 index 000000000..ab963fad8 --- /dev/null +++ b/hcl2template/shim/values.go @@ -0,0 +1,87 @@ +package hcl2shim + +import ( + "fmt" + "math/big" + + "github.com/zclconf/go-cty/cty" +) + +// UnknownVariableValue is a sentinel value that can be used +// to denote that the value of a variable is unknown at this time. +// RawConfig uses this information to build up data about +// unknown keys. +const UnknownVariableValue = "74D93920-ED26-11E3-AC10-0800200C9A66" + +// ConfigValueFromHCL2 converts a value from HCL2 (really, from the cty dynamic +// types library that HCL2 uses) to a value type that matches what would've +// been produced from the HCL-based interpolator for an equivalent structure. +// +// This function will transform a cty null value into a Go nil value, which +// isn't a possible outcome of the HCL/HIL-based decoder and so callers may +// need to detect and reject any null values. +func ConfigValueFromHCL2(v cty.Value) interface{} { + if !v.IsKnown() { + return UnknownVariableValue + } + if v.IsNull() { + return nil + } + + switch v.Type() { + case cty.Bool: + return v.True() // like HCL.BOOL + case cty.String: + return v.AsString() // like HCL token.STRING or token.HEREDOC + case cty.Number: + // We can't match HCL _exactly_ here because it distinguishes between + // int and float values, but we'll get as close as we can by using + // an int if the number is exactly representable, and a float if not. + // The conversion to float will force precision to that of a float64, + // which is potentially losing information from the specific number + // given, but no worse than what HCL would've done in its own conversion + // to float. + + f := v.AsBigFloat() + if i, acc := f.Int64(); acc == big.Exact { + // if we're on a 32-bit system and the number is too big for 32-bit + // int then we'll fall through here and use a float64. + const MaxInt = int(^uint(0) >> 1) + const MinInt = -MaxInt - 1 + if i <= int64(MaxInt) && i >= int64(MinInt) { + return int(i) // Like HCL token.NUMBER + } + } + + f64, _ := f.Float64() + return f64 // like HCL token.FLOAT + } + + if v.Type().IsListType() || v.Type().IsSetType() || v.Type().IsTupleType() { + l := make([]interface{}, 0, v.LengthInt()) + it := v.ElementIterator() + for it.Next() { + _, ev := it.Element() + l = append(l, ConfigValueFromHCL2(ev)) + } + return l + } + + if v.Type().IsMapType() || v.Type().IsObjectType() { + l := make(map[string]interface{}) + it := v.ElementIterator() + for it.Next() { + ek, ev := it.Element() + cv := ConfigValueFromHCL2(ev) + if cv != nil { + l[ek.AsString()] = cv + } + } + return l + } + + // If we fall out here then we have some weird type that we haven't + // accounted for. This should never happen unless the caller is using + // capsule types, and we don't currently have any such types defined. + panic(fmt.Errorf("can't convert %#v to config value", v)) +} diff --git a/hcl2template/shim/values_test.go b/hcl2template/shim/values_test.go new file mode 100644 index 000000000..ddc841617 --- /dev/null +++ b/hcl2template/shim/values_test.go @@ -0,0 +1,96 @@ +package hcl2shim + +import ( + "fmt" + "reflect" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestConfigValueFromHCL2(t *testing.T) { + tests := []struct { + Input cty.Value + Want interface{} + }{ + { + cty.True, + true, + }, + { + cty.False, + false, + }, + { + cty.NumberIntVal(12), + int(12), + }, + { + cty.NumberFloatVal(12.5), + float64(12.5), + }, + { + cty.StringVal("hello world"), + "hello world", + }, + { + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Ermintrude"), + "age": cty.NumberIntVal(19), + "address": cty.ObjectVal(map[string]cty.Value{ + "street": cty.ListVal([]cty.Value{cty.StringVal("421 Shoreham Loop")}), + "city": cty.StringVal("Fridgewater"), + "state": cty.StringVal("MA"), + "zip": cty.StringVal("91037"), + }), + }), + map[string]interface{}{ + "name": "Ermintrude", + "age": int(19), + "address": map[string]interface{}{ + "street": []interface{}{"421 Shoreham Loop"}, + "city": "Fridgewater", + "state": "MA", + "zip": "91037", + }, + }, + }, + { + cty.MapVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + "bar": cty.StringVal("baz"), + }), + map[string]interface{}{ + "foo": "bar", + "bar": "baz", + }, + }, + { + cty.TupleVal([]cty.Value{ + cty.StringVal("foo"), + cty.True, + }), + []interface{}{ + "foo", + true, + }, + }, + { + cty.NullVal(cty.String), + nil, + }, + { + cty.UnknownVal(cty.String), + UnknownVariableValue, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%#v", test.Input), func(t *testing.T) { + got := ConfigValueFromHCL2(test.Input) + if !reflect.DeepEqual(got, test.Want) { + t.Errorf("wrong result\ninput: %#v\ngot: %#v\nwant: %#v", test.Input, got, test.Want) + } + }) + } +} diff --git a/hcl2template/types.packer_config.go b/hcl2template/types.packer_config.go index 55954401f..ef8f601b5 100644 --- a/hcl2template/types.packer_config.go +++ b/hcl2template/types.packer_config.go @@ -2,8 +2,10 @@ package hcl2template import ( "fmt" + "strings" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/packer/helper/common" "github.com/hashicorp/packer/packer" "github.com/zclconf/go-cty/cty" @@ -12,8 +14,6 @@ import ( // PackerConfig represents a loaded Packer HCL config. It will contain // references to all possible blocks of the allowed configuration. type PackerConfig struct { - // parser *Parser - // Directory where the config files are defined Basedir string @@ -386,3 +386,67 @@ func (cfg *PackerConfig) GetBuilds(opts packer.GetBuildsOptions) ([]packer.Build } return res, diags } + +var PackerConsoleHelp = strings.TrimSpace(` +Packer console HCL2 Mode. +The Packer console allows you to experiment with Packer interpolations. +You may access variables and functions in the Packer config you called the +console with. + +Type in the interpolation to test and hit to see the result. + +"upper(var.foo.id)" would evaluate to the ID of "foo" and uppercase is, if it +exists in your config file. + +"variables" will dump all available variables and their values. + +To exit the console, type "exit" and hit , or use Control-C. + +/!\ It is not possible to use go templating interpolation like "{{timestamp}}" +with in HCL2 mode. +`) + +func (p *PackerConfig) EvaluateExpression(line string) (out string, exit bool, diags hcl.Diagnostics) { + switch { + case line == "": + return "", false, nil + case line == "exit": + return "", true, nil + case line == "help": + return PackerConsoleHelp, false, nil + case line == "variables": + out := &strings.Builder{} + out.WriteString("> input-variables:\n\n") + for _, v := range p.InputVariables { + val, _ := v.Value() + fmt.Fprintf(out, "var.%s: %q [debug: %#v]\n", v.Name, PrintableCtyValue(val), v) + } + out.WriteString("\n> local-variables:\n\n") + for _, v := range p.LocalVariables { + val, _ := v.Value() + fmt.Fprintf(out, "local.%s: %q\n", v.Name, PrintableCtyValue(val)) + } + + return out.String(), false, nil + default: + return p.handleEval(line) + } +} + +func (p *PackerConfig) handleEval(line string) (out string, exit bool, diags hcl.Diagnostics) { + + // Parse the given line as an expression + expr, parseDiags := hclsyntax.ParseExpression([]byte(line), "", hcl.Pos{Line: 1, Column: 1}) + diags = append(diags, parseDiags...) + if parseDiags.HasErrors() { + return "", false, diags + } + + val, valueDiags := expr.Value(p.EvalContext(nil)) + diags = append(diags, valueDiags...) + if valueDiags.HasErrors() { + return "", false, diags + } + + return PrintableCtyValue(val), false, diags +} diff --git a/hcl2template/types.variables.go b/hcl2template/types.variables.go index 24d52069c..e97266d6f 100644 --- a/hcl2template/types.variables.go +++ b/hcl2template/types.variables.go @@ -51,8 +51,13 @@ type Variable struct { } func (v *Variable) GoString() string { - return fmt.Sprintf("{Type:%q,CmdValue:%q,VarfileValue:%q,EnvValue:%q,DefaultValue:%q}", - v.Type.GoString(), v.CmdValue.GoString(), v.VarfileValue.GoString(), v.EnvValue.GoString(), v.DefaultValue.GoString()) + return fmt.Sprintf("{Type:%s,CmdValue:%s,VarfileValue:%s,EnvValue:%s,DefaultValue:%s}", + v.Type.GoString(), + PrintableCtyValue(v.CmdValue), + PrintableCtyValue(v.VarfileValue), + PrintableCtyValue(v.EnvValue), + PrintableCtyValue(v.DefaultValue), + ) } func (v *Variable) Value() (cty.Value, *hcl.Diagnostic) { diff --git a/hcl2template/utils.go b/hcl2template/utils.go index 06aa60864..02430a86a 100644 --- a/hcl2template/utils.go +++ b/hcl2template/utils.go @@ -9,6 +9,9 @@ import ( "github.com/gobwas/glob" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/packer/hcl2template/repl" + hcl2shim "github.com/hashicorp/packer/hcl2template/shim" + "github.com/zclconf/go-cty/cty" ) func warningErrorsToDiags(block *hcl.Block, warnings []string, err error) hcl.Diagnostics { @@ -47,6 +50,9 @@ func isDir(name string) (bool, error) { // returned. Otherwise if filename references a file and filename matches one // of the suffixes it is returned in the according slice. func GetHCL2Files(filename, hclSuffix, jsonSuffix string) (hclFiles, jsonFiles []string, diags hcl.Diagnostics) { + if filename == "" { + return + } isDir, err := isDir(filename) if err != nil { diags = append(diags, &hcl.Diagnostic{ @@ -109,3 +115,12 @@ func convertFilterOption(patterns []string, optionName string) ([]glob.Glob, hcl return globs, diags } + +func PrintableCtyValue(v cty.Value) string { + if !v.IsWhollyKnown() { + return "" + } + gval := hcl2shim.ConfigValueFromHCL2(v) + str := repl.FormatResult(gval) + return str +} diff --git a/packer/core.go b/packer/core.go index fcbc3904a..3e58ca591 100644 --- a/packer/core.go +++ b/packer/core.go @@ -374,6 +374,52 @@ func (c *Core) Context() *interpolate.Context { } } +var ConsoleHelp = strings.TrimSpace(` +Packer console JSON Mode. +The Packer console allows you to experiment with Packer interpolations. +You may access variables in the Packer config you called the console with. + +Type in the interpolation to test and hit to see the result. + +"variables" will dump all available variables and their values. + +"{{timestamp}}" will output the timestamp, for example "1559855090". + +To exit the console, type "exit" and hit , or use Control-C. + +/!\ If you would like to start console in hcl2 mode without a config you can +use the --config-type=hcl2 option. +`) + +func (c *Core) EvaluateExpression(line string) (string, bool, hcl.Diagnostics) { + switch { + case line == "": + return "", false, nil + case line == "exit": + return "", true, nil + case line == "help": + return ConsoleHelp, false, nil + case line == "variables": + varsstring := "\n" + for k, v := range c.Context().UserVariables { + varsstring += fmt.Sprintf("%s: %+v,\n", k, v) + } + + return varsstring, false, nil + default: + ctx := c.Context() + rendered, err := interpolate.Render(line, ctx) + var diags hcl.Diagnostics + if err != nil { + diags = append(diags, &hcl.Diagnostic{ + Summary: "Interpolation error", + Detail: err.Error(), + }) + } + return rendered, false, diags + } +} + // validate does a full validation of the template. // // This will automatically call template.validate() in addition to doing diff --git a/packer/run_interfaces.go b/packer/run_interfaces.go index 02048f6df..048b6ff0e 100644 --- a/packer/run_interfaces.go +++ b/packer/run_interfaces.go @@ -17,6 +17,19 @@ type BuildGetter interface { GetBuilds(GetBuildsOptions) ([]Build, hcl.Diagnostics) } +type Evaluator interface { + // EvaluateExpression is meant to be used in the `packer console` command. + // It parses the input string and returns what needs to be displayed. In + // case of an error the error should be displayed. + EvaluateExpression(expr string) (output string, exit bool, diags hcl.Diagnostics) +} + +// The packer.Handler handles all Packer things. +type Handler interface { + Evaluator + BuildGetter +} + //go:generate enumer -type FixConfigMode type FixConfigMode int diff --git a/website/pages/docs/commands/console.mdx b/website/pages/docs/commands/console.mdx index cca4229f9..eb8fdfae5 100644 --- a/website/pages/docs/commands/console.mdx +++ b/website/pages/docs/commands/console.mdx @@ -16,9 +16,13 @@ console with, or provide variables when you call console using the -var or ~> **Note:** `console` is available from version 1.4.2 and above. -Type in the interpolation to test and hit \ to see the result. +~> **Note:** For HCL2 `console` is available from version 1.6.0 and above, use +`packer console --config-type=hcl2` to try it without a config file. Go +templating ( or `{{..}}` calls ) will not work in HCL2 mode. -To exit the console, type "exit" and hit \, or use Control-C. +Type in the interpolation to test and hit `` to see the result. + +To exit the console, type "exit" and hit ``, or use Control-C. ```shell-session $ packer console my_template.json @@ -45,7 +49,7 @@ help output, which can be seen via `packer console -h`. - `variables` - prints a list of all variables read into the console from the `-var` option, `-var-files` option, and template. -## Usage Examples +## Usage Examples - repl session ( JSON ) Let's say you launch a console using a Packer template `example_template.json`: @@ -69,14 +73,14 @@ myvar: ```shell-session > {{user `myvar`}} -> asdfasdf +asdfasdf ``` From there you can test more complicated interpolations: ```shell-session > {{user `myvar`}}-{{timestamp}} -> asdfasdf-1559854396 +asdfasdf-1559854396 ``` And when you're done using the console, just type "exit" or CTRL-C @@ -96,6 +100,8 @@ If you don't have specific variables or var files you want to test, and just want to experiment with a particular template engine, you can do so by simply calling `packer console` without a template file. +## Usage Examples - piped commands ( JSON ) + If you'd like to just see a specific single interpolation without launching the REPL, you can do so by echoing and piping the string into the console command: @@ -104,3 +110,49 @@ command: $ echo {{timestamp}} | packer console 1559855090 ``` + +## Usage Examples - repl session ( HCL2 ) + +~> **Note:** For HCL2 `console` is available from version 1.6.0 and above, use +`packer console --config-type=hcl2` to try it without a config file. Go +templating ( or `{{..}}` calls ) will not work in HCL2 mode. + +Without a config file, `packer console` can be used to experiment with the +expression syntax and [built-in functions](/docs/from-1.5/functions). + +### Starting + +To start a session on a folder containing HCL2 config files, run: + +```shell-session +packer console folder/ +``` + +Because `folder/` is a folder Packer will start in HCL2 mode, you can also +directly pass an HCL2 formatted config file: + +```shell-session +packer console file.pkr.hcl +``` + +Because the file is suffixed with `.pkr.hcl` Packer will start in HCL2 mode. + +When you just want to play arround without a config file you can set the +`--config-type=hcl2` option and Packer will start in HCL2 mode: + +```shell-session +packer console --config-type=hcl2 +``` + +### Scripting + +The `packer console` command can be used in non-interactive scripts by piping +newline-separated commands to it. Only the output from the final command is +printed unless an error occurs earlier. + +For example: + +```shell-session +$ echo "1 + 5" | terraform console +6 +``` \ No newline at end of file