Files
Lucas Bajolet c1c1577073 hcl2template: rework parsing logic
In previous versions of Packer, the parser would load a HCL file (in
either HCL or JSON format), and process it in phases.
This process meant that if the file was not valid in the first place,
we'd have some duplicate errors first.

Another thing is that sources and build blocks were not being parsed at
the beginning of the parsing phase at all, but were discovered later in
the process, when the data sources have been executed and variables have
been evaluated.

This commit changes the parsing to happen all at once, so we parse the
files with the parser, which will fail should one file be malformatted,
then we visit the body once using the top-level schema for a Packer
build template, handling misplaced blocks once in the process.

Then, since the builds and sources may contain some dynamic blocks that
need to be expanded once their context is ready (i.e. the
variables/datasources they depend on), we can expand the build block for
them, and populate their respective objects.

This last step should also be adapted for datasources later, as they may
need to support dynamic blocks, and be expanded lazily.
2023-10-03 14:47:24 -04:00

586 lines
16 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package hcl2template
import (
"fmt"
"os"
"path/filepath"
"github.com/hashicorp/go-version"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/dynblock"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/hcl/v2/hclsyntax"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
pkrfunction "github.com/hashicorp/packer/hcl2template/function"
"github.com/hashicorp/packer/packer"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
)
const (
packerLabel = "packer"
sourceLabel = "source"
variablesLabel = "variables"
variableLabel = "variable"
localsLabel = "locals"
localLabel = "local"
dataSourceLabel = "data"
buildLabel = "build"
communicatorLabel = "communicator"
)
var configSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: packerLabel},
{Type: sourceLabel, LabelNames: []string{"type", "name"}},
{Type: variablesLabel},
{Type: variableLabel, LabelNames: []string{"name"}},
{Type: localsLabel},
{Type: localLabel, LabelNames: []string{"name"}},
{Type: dataSourceLabel, LabelNames: []string{"type", "name"}},
{Type: buildLabel},
{Type: communicatorLabel, LabelNames: []string{"type", "name"}},
},
}
// packerBlockSchema is the schema for a top-level "packer" block in
// a configuration file.
var packerBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "required_version"},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "required_plugins"},
},
}
// Parser helps you parse HCL folders. It will parse an hcl file or directory
// and start builders, provisioners and post-processors to configure them with
// the parsed HCL and then return a []packersdk.Build. Packer will use that list
// of Builds to run everything in order.
type Parser struct {
CorePackerVersion *version.Version
CorePackerVersionString string
PluginConfig *packer.PluginConfig
ValidationOptions
*hclparse.Parser
}
const (
hcl2FileExt = ".pkr.hcl"
hcl2JsonFileExt = ".pkr.json"
hcl2VarFileExt = ".pkrvars.hcl"
hcl2VarJsonFileExt = ".pkrvars.json"
hcl2AutoVarFileExt = ".auto.pkrvars.hcl"
hcl2AutoVarJsonFileExt = ".auto.pkrvars.json"
)
// Parse will Parse all HCL files in filename. Path can be a folder or a file.
//
// Parse will first Parse packer and variables blocks, omitting the rest, which
// can be expanded with dynamic blocks. We need to evaluate all variables for
// that, so that data sources can expand dynamic blocks too.
//
// Parse returns a PackerConfig that contains configuration layout of a packer
// build; sources(builders)/provisioners/posts-processors will not be started
// and their contents wont be verified; Most syntax errors will cause an error,
// init should be called next to expand dynamic blocks and verify that used
// things do exist.
func (p *Parser) Parse(filename string, varFiles []string, argVars map[string]string) (*PackerConfig, hcl.Diagnostics) {
var files []*hcl.File
var diags hcl.Diagnostics
// parse config files
if filename != "" {
hclFiles, jsonFiles, moreDiags := GetHCL2Files(filename, hcl2FileExt, hcl2JsonFileExt)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
// here this probably means that the file was not found, let's
// simply leave early.
return nil, diags
}
if len(hclFiles)+len(jsonFiles) == 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Could not find any config file in " + filename,
Detail: "A config file must be suffixed with `.pkr.hcl` or " +
"`.pkr.json`. A folder can be referenced.",
})
}
for _, filename := range hclFiles {
f, moreDiags := p.ParseHCLFile(filename)
diags = append(diags, moreDiags...)
files = append(files, f)
}
for _, filename := range jsonFiles {
f, moreDiags := p.ParseJSONFile(filename)
diags = append(diags, moreDiags...)
files = append(files, f)
}
if diags.HasErrors() {
return nil, diags
}
}
basedir := filename
if isDir, err := isDir(basedir); err == nil && !isDir {
basedir = filepath.Dir(basedir)
}
wd, err := os.Getwd()
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Could not find current working directory",
Detail: err.Error(),
})
}
cfg := &PackerConfig{
Basedir: basedir,
Cwd: wd,
CorePackerVersionString: p.CorePackerVersionString,
HCPVars: map[string]cty.Value{},
ValidationOptions: p.ValidationOptions,
parser: p,
files: files,
}
for _, file := range files {
coreVersionConstraints, moreDiags := sniffCoreVersionRequirements(file.Body)
cfg.Packer.VersionConstraints = append(cfg.Packer.VersionConstraints, coreVersionConstraints...)
diags = append(diags, moreDiags...)
}
// Before we go further, we'll check to make sure this version can read
// all files, so we can produce a version-related error message rather than
// potentially-confusing downstream errors.
versionDiags := cfg.CheckCoreVersionRequirements(p.CorePackerVersion)
diags = append(diags, versionDiags...)
if versionDiags.HasErrors() {
return cfg, diags
}
for _, file := range files {
diags = append(diags, cfg.decodeFile(file)...)
}
// parse var files
{
hclVarFiles, jsonVarFiles, moreDiags := GetHCL2Files(filename, hcl2AutoVarFileExt, hcl2AutoVarJsonFileExt)
diags = append(diags, moreDiags...)
for _, file := range varFiles {
switch filepath.Ext(file) {
case ".hcl":
hclVarFiles = append(hclVarFiles, file)
case ".json":
jsonVarFiles = append(jsonVarFiles, file)
default:
diags = append(moreDiags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Could not guess format of " + file,
Detail: "A var file must be suffixed with `.hcl` or `.json`.",
})
}
}
var varFiles []*hcl.File
for _, filename := range hclVarFiles {
f, moreDiags := p.ParseHCLFile(filename)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
continue
}
varFiles = append(varFiles, f)
}
for _, filename := range jsonVarFiles {
f, moreDiags := p.ParseJSONFile(filename)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
continue
}
varFiles = append(varFiles, f)
}
diags = append(diags, cfg.collectInputVariableValues(os.Environ(), varFiles, argVars)...)
}
return cfg, diags
}
// sniffCoreVersionRequirements does minimal parsing of the given body for
// "packer" blocks with "required_version" attributes, returning the
// requirements found.
//
// This is intended to maximize the chance that we'll be able to read the
// requirements (syntax errors notwithstanding) even if the config file contains
// constructs that might've been added in future versions
//
// This is a "best effort" sort of method which will return constraints it is
// able to find, but may return no constraints at all if the given body is
// so invalid that it cannot be decoded at all.
func sniffCoreVersionRequirements(body hcl.Body) ([]VersionConstraint, hcl.Diagnostics) {
var sniffRootSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: packerLabel,
},
},
}
rootContent, _, diags := body.PartialContent(sniffRootSchema)
var constraints []VersionConstraint
for _, block := range rootContent.Blocks {
content, blockDiags := block.Body.Content(packerBlockSchema)
diags = append(diags, blockDiags...)
attr, exists := content.Attributes["required_version"]
if !exists {
continue
}
constraint, constraintDiags := decodeVersionConstraint(attr)
diags = append(diags, constraintDiags...)
if !constraintDiags.HasErrors() {
constraints = append(constraints, constraint)
}
}
return constraints, diags
}
func filterVarsFromLogs(inputOrLocal Variables) {
for _, variable := range inputOrLocal {
if !variable.Sensitive {
continue
}
value := variable.Value()
_ = cty.Walk(value, func(_ cty.Path, nested cty.Value) (bool, error) {
if nested.IsWhollyKnown() && !nested.IsNull() && nested.Type().Equals(cty.String) {
packersdk.LogSecretFilter.Set(nested.AsString())
}
return true, nil
})
}
}
// decodeFile attempts to decode the configuration from a HCL file, and starts
// populating the config from it.
func (cfg *PackerConfig) decodeFile(file *hcl.File) hcl.Diagnostics {
var diags hcl.Diagnostics
content, moreDiags := file.Body.Content(configSchema)
diags = append(diags, moreDiags...)
// If basic parsing failed, we should not continue
if diags.HasErrors() {
return diags
}
for _, block := range content.Blocks {
diags = append(diags, cfg.decodeBlock(block)...)
}
return diags
}
func (cfg *PackerConfig) decodeBlock(block *hcl.Block) hcl.Diagnostics {
switch block.Type {
case packerLabel:
return cfg.decodePackerBlock(block)
case dataSourceLabel:
return cfg.decodeDatasource(block)
case variableLabel:
return cfg.decodeVariableBlock(block)
case variablesLabel:
return cfg.decodeVariablesBlock(block)
case localLabel:
return cfg.decodeLocalBlock(block)
case localsLabel:
return cfg.decodeLocalsBlock(block)
// NOTE: Both build and source blocks can be dynamically expanded.
//
// Because of that, we only populate the config's top-level build and
// source lists, but we don't attempt to dynamically expand them now,
// since dynamic blocks can depend on other resources that are executed
// later in the process.
//
// The source and builds are only ready to use when we have called the
// finalizeDecode/finalizeDecodeSource respectively on the blocks.
case buildLabel:
return cfg.decodeBuildBlock(block)
case sourceLabel:
return cfg.decodeSourceBlock(block)
}
return hcl.Diagnostics{
&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid block type",
Detail: fmt.Sprintf("The block %q is not a valid top-level block", block.Type),
Subject: &block.DefRange,
},
}
}
func (cfg *PackerConfig) decodePackerBlock(block *hcl.Block) hcl.Diagnostics {
var diags hcl.Diagnostics
content, contentDiags := block.Body.Content(packerBlockSchema)
diags = append(diags, contentDiags...)
// We ignore "packer_version"" here because
// sniffCoreVersionRequirements already dealt with that
for _, innerBlock := range content.Blocks {
switch innerBlock.Type {
case "required_plugins":
reqs, reqsDiags := decodeRequiredPluginsBlock(innerBlock)
diags = append(diags, reqsDiags...)
cfg.Packer.RequiredPlugins = append(cfg.Packer.RequiredPlugins, reqs)
default:
continue
}
}
return diags
}
func (cfg *PackerConfig) decodeDatasource(block *hcl.Block) hcl.Diagnostics {
datasource, diags := cfg.decodeDataBlock(block)
if diags.HasErrors() {
return diags
}
ref := datasource.Ref()
if existing, found := cfg.Datasources[ref]; found {
return append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate " + dataSourceLabel + " block",
Detail: fmt.Sprintf("This "+dataSourceLabel+" block has the "+
"same data type and name as a previous block declared "+
"at %s. Each "+dataSourceLabel+" must have a unique name per builder type.",
existing.block.DefRange.Ptr()),
Subject: datasource.block.DefRange.Ptr(),
})
}
if cfg.Datasources == nil {
cfg.Datasources = Datasources{}
}
cfg.Datasources[ref] = *datasource
return diags
}
func (cfg *PackerConfig) decodeDataBlock(block *hcl.Block) (*DatasourceBlock, hcl.Diagnostics) {
var diags hcl.Diagnostics
r := &DatasourceBlock{
Type: block.Labels[0],
Name: block.Labels[1],
block: block,
}
if !hclsyntax.ValidIdentifier(r.Type) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid data source name",
Detail: badIdentifierDetail,
Subject: &block.LabelRanges[0],
})
}
if !hclsyntax.ValidIdentifier(r.Name) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid data resource name",
Detail: badIdentifierDetail,
Subject: &block.LabelRanges[1],
})
}
return r, diags
}
func (cfg *PackerConfig) decodeLocalBlock(block *hcl.Block) hcl.Diagnostics {
name := block.Labels[0]
content, diags := block.Body.Content(localBlockSchema)
if !hclsyntax.ValidIdentifier(name) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid local name",
Detail: badIdentifierDetail,
Subject: &block.LabelRanges[0],
})
}
l := &LocalBlock{
Name: name,
}
if attr, exists := content.Attributes["sensitive"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &l.Sensitive)
diags = append(diags, valDiags...)
}
if def, ok := content.Attributes["expression"]; ok {
l.Expr = def.Expr
}
cfg.LocalBlocks = append(cfg.LocalBlocks, l)
return diags
}
func (cfg *PackerConfig) decodeLocalsBlock(block *hcl.Block) hcl.Diagnostics {
attrs, diags := block.Body.JustAttributes()
for name, attr := range attrs {
cfg.LocalBlocks = append(cfg.LocalBlocks, &LocalBlock{
Name: name,
Expr: attr.Expr,
})
}
return diags
}
func (cfg *PackerConfig) decodeVariableBlock(block *hcl.Block) hcl.Diagnostics {
// for input variables we allow to use env in the default value section.
ectx := &hcl.EvalContext{
Functions: map[string]function.Function{
"env": pkrfunction.EnvFunc,
},
}
return cfg.InputVariables.decodeVariableBlock(block, ectx)
}
func (cfg *PackerConfig) decodeVariablesBlock(block *hcl.Block) hcl.Diagnostics {
// for input variables we allow to use env in the default value section.
ectx := &hcl.EvalContext{
Functions: map[string]function.Function{
"env": pkrfunction.EnvFunc,
},
}
var diags hcl.Diagnostics
attrs, moreDiags := block.Body.JustAttributes()
diags = append(diags, moreDiags...)
for key, attr := range attrs {
moreDiags = cfg.InputVariables.decodeVariable(key, attr, ectx)
diags = append(diags, moreDiags...)
}
return diags
}
// decodeBuildBlock shallowly decodes a build block from the config.
//
// The final decoding step (which requires an up-to-date context) will be done
// when we need it.
func (cfg *PackerConfig) decodeBuildBlock(block *hcl.Block) hcl.Diagnostics {
build := &BuildBlock{
block: block,
}
cfg.Builds = append(cfg.Builds, build)
return nil
}
func (cfg *PackerConfig) decodeSourceBlock(block *hcl.Block) hcl.Diagnostics {
source, diags := cfg.decodeSource(block)
if diags.HasErrors() {
return diags
}
if cfg.Sources == nil {
cfg.Sources = map[SourceRef]SourceBlock{}
}
ref := source.Ref()
if existing, found := cfg.Sources[ref]; found {
return append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate " + sourceLabel + " block",
Detail: fmt.Sprintf("This "+sourceLabel+" block has the "+
"same builder type and name as a previous block declared "+
"at %s. Each "+sourceLabel+" must have a unique name per builder type.",
existing.block.DefRange.Ptr()),
Subject: source.block.DefRange.Ptr(),
})
}
cfg.Sources[ref] = source
return diags
}
// decodeBuildSource reads a used source block from a build:
//
// build {
// source "type.example" {
// name = "local_name"
// }
// }
func (cfg *PackerConfig) decodeBuildSource(block *hcl.Block) (SourceUseBlock, hcl.Diagnostics) {
ref := sourceRefFromString(block.Labels[0])
out := SourceUseBlock{SourceRef: ref}
var b struct {
Name string `hcl:"name,optional"`
Rest hcl.Body `hcl:",remain"`
}
diags := gohcl.DecodeBody(block.Body, nil, &b)
if diags.HasErrors() {
return out, diags
}
out.LocalName = b.Name
out.Body = b.Rest
return out, nil
}
func (source *SourceBlock) finalizeDecode(cfg *PackerConfig) hcl.Diagnostics {
if source.Ready {
return nil
}
dyn := dynblock.Expand(source.block.Body, cfg.EvalContext(DatasourceContext, nil))
// Expand without a base schema since nothing is known in advance for a
// source, but we still want to expand dynamic blocks if any
_, rem, diags := dyn.PartialContent(&hcl.BodySchema{})
// Only try to expand once, regardless of whether the source succeeded
// to expand dynamic data or not.
source.Ready = true
if diags.HasErrors() {
return diags
}
source.block = &hcl.Block{
Labels: []string{
source.Type,
source.Name,
},
Body: rem,
}
return diags
}
func (cfg *PackerConfig) decodeSource(block *hcl.Block) (SourceBlock, hcl.Diagnostics) {
source := SourceBlock{
Type: block.Labels[0],
Name: block.Labels[1],
block: block,
}
var diags hcl.Diagnostics
return source, diags
}