Enhance enforced provisioner parsing to support HCL and JSON formats

- Updated ParseProvisionerBlocks to handle both HCL and JSON syntax, including legacy JSON format.

- Added comprehensive test cases for JSON provisioner parsing.

- Improved ExtractBuildProvisionerHCL to merge inline commands from shell provisioners.

- Enhanced logging for enforced block operations in HCP Packer.
feature/enforcedProvisioner
Hari Om 2 months ago committed by Hari Om
parent 91cae751a6
commit 5b6835799b

@ -4,7 +4,9 @@
package hcl2template
import (
"encoding/json"
"fmt"
"log"
"strconv"
"github.com/hashicorp/hcl/v2"
@ -19,15 +21,104 @@ var enforcedProvisionerSchema = &hcl.BodySchema{
},
}
// ParseProvisionerBlocks parses a partial HCL string that contains only
// top-level provisioner blocks and returns the parsed ProvisionerBlock list.
// ParseProvisionerBlocks parses a string containing one or more top-level provisioner blocks
// in either HCL or JSON syntax, and returns a slice of parsed ProvisionerBlock objects along
// with any diagnostics encountered during parsing.
func ParseProvisionerBlocks(blockContent string) ([]*ProvisionerBlock, hcl.Diagnostics) {
parser := &Parser{Parser: hclparse.NewParser()}
log.Printf("[DEBUG] parsing enforced provisioner block content as HCL")
file, diags := parser.ParseHCL([]byte(blockContent), "enforced_provisioner.pkr.hcl")
if diags.HasErrors() {
return nil, diags
if !diags.HasErrors() {
log.Printf("[DEBUG] parsed enforced provisioner block content as HCL")
return parseProvisionerBlocksFromFile(parser, file, diags)
}
log.Printf("[DEBUG] failed to parse enforced provisioner block content as HCL, trying JSON fallback")
// Fallback to HCL-JSON for enforced block content authored in JSON syntax.
jsonFile, jsonDiags := parser.ParseJSON([]byte(blockContent), "enforced_provisioner.pkr.json")
if jsonDiags.HasErrors() {
log.Printf("[DEBUG] failed to parse enforced provisioner block content as JSON")
return nil, append(diags, jsonDiags...)
}
provisioners, provisionerDiags := parseProvisionerBlocksFromFile(parser, jsonFile, jsonDiags)
if !provisionerDiags.HasErrors() && len(provisioners) > 0 {
log.Printf("[DEBUG] parsed enforced provisioner block content as JSON")
return provisioners, provisionerDiags
}
// Backward compatibility fallback for legacy JSON shape:
// {"provisioners":[{"type":"shell", ...}]}
legacyJSON, ok, err := normalizeLegacyEnforcedProvisionersJSON(blockContent)
if err == nil && ok {
legacyFile, legacyDiags := parser.ParseJSON([]byte(legacyJSON), "enforced_provisioner_legacy.pkr.json")
if !legacyDiags.HasErrors() {
legacyProvisioners, legacyProvisionerDiags := parseProvisionerBlocksFromFile(parser, legacyFile, legacyDiags)
if !legacyProvisionerDiags.HasErrors() && len(legacyProvisioners) > 0 {
log.Printf("[DEBUG] parsed enforced provisioner block content as legacy JSON")
return legacyProvisioners, legacyProvisionerDiags
}
}
}
if provisionerDiags.HasErrors() {
return nil, provisionerDiags
}
log.Printf("[DEBUG] parsed enforced provisioner block content as JSON but found no valid provisioner blocks")
return provisioners, provisionerDiags
}
func normalizeLegacyEnforcedProvisionersJSON(blockContent string) (string, bool, error) {
type legacyPayload struct {
Provisioners []map[string]interface{} `json:"provisioners"`
}
var payload legacyPayload
if err := json.Unmarshal([]byte(blockContent), &payload); err != nil {
return "", false, err
}
if len(payload.Provisioners) == 0 {
return "", false, nil
}
normalized := make([]map[string]interface{}, 0, len(payload.Provisioners))
for _, p := range payload.Provisioners {
typeName, ok := p["type"].(string)
if !ok || typeName == "" {
continue
}
cfg := make(map[string]interface{})
for k, v := range p {
if k == "type" {
continue
}
cfg[k] = v
}
normalized = append(normalized, map[string]interface{}{typeName: cfg})
}
if len(normalized) == 0 {
return "", false, nil
}
out := map[string]interface{}{
"provisioner": normalized,
}
b, err := json.Marshal(out)
if err != nil {
return "", false, err
}
return string(b), true, nil
}
func parseProvisionerBlocksFromFile(parser *Parser, file *hcl.File, diags hcl.Diagnostics) ([]*ProvisionerBlock, hcl.Diagnostics) {
content, moreDiags := file.Body.Content(enforcedProvisionerSchema)
diags = append(diags, moreDiags...)
if diags.HasErrors() {

@ -104,6 +104,63 @@ provisioner "shell" {
wantTypes: nil,
wantErr: true,
},
{
name: "json single shell provisioner",
blockContent: `{
"provisioner": [
{
"shell": {
"inline": ["echo 'Hello from enforced provisioner JSON'"]
}
}
]
}`,
wantCount: 1,
wantTypes: []string{"shell"},
wantErr: false,
},
{
name: "json multiple provisioners",
blockContent: `{
"provisioner": [
{
"shell": {
"inline": ["echo 'first'"]
}
},
{
"shell": {
"name": "security-scan",
"inline": ["echo 'second'"]
}
}
]
}`,
wantCount: 2,
wantTypes: []string{"shell", "shell"},
wantErr: false,
},
{
name: "invalid json syntax",
blockContent: `{"provisioner": [ { "shell": { "inline": ["test"] } ] }`,
wantCount: 0,
wantTypes: nil,
wantErr: true,
},
{
name: "legacy json provisioners format",
blockContent: `{
"provisioners": [
{
"type": "shell",
"inline": ["echo legacy json format"]
}
]
}`,
wantCount: 1,
wantTypes: []string{"shell"},
wantErr: false,
},
}
for _, tt := range tests {
@ -210,3 +267,43 @@ provisioner "shell" {
t.Error("Skip() should return false for source in only list")
}
}
func TestParseProvisionerBlocksJSONWithOptions(t *testing.T) {
blockContent := `{
"provisioner": [
{
"shell": {
"pause_before": "15s",
"max_retries": 2,
"only": ["docker.ubuntu"],
"inline": ["echo 'json test'"]
}
}
]
}`
blocks, diags := ParseProvisionerBlocks(blockContent)
if diags.HasErrors() {
t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags)
}
if len(blocks) != 1 {
t.Fatalf("Expected 1 block, got %d", len(blocks))
}
if blocks[0].PauseBefore.Seconds() != 15 {
t.Errorf("Expected PauseBefore=15s, got %v", blocks[0].PauseBefore)
}
if blocks[0].MaxRetries != 2 {
t.Errorf("Expected MaxRetries=2, got %d", blocks[0].MaxRetries)
}
if blocks[0].OnlyExcept.Skip("docker.ubuntu") {
t.Error("Skip() should return false for source in only list")
}
if !blocks[0].OnlyExcept.Skip("null.test") {
t.Error("Skip() should return true for source not in only list")
}
}

@ -10,7 +10,8 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/zclconf/go-cty/cty"
)
type HCPPackerRegistryBlock struct {
@ -100,42 +101,108 @@ func (p *Parser) decodeHCPRegistry(block *hcl.Block, cfg *PackerConfig) (*HCPPac
return par, diags
}
// ExtractBuildProvisionerHCL extracts all provisioner blocks from the build
// blocks in the configuration and returns them as raw HCL content.
// This is used to publish provisioner configurations as enforced blocks
// to HCP Packer, so that other builds against the same bucket will
// automatically have these provisioners injected.
// ExtractBuildProvisionerHCL extracts inline commands from all shell
// provisioner blocks across every build block and merges them into a single
// provisioner "shell" block. This merged block is what gets published to
// HCP Packer as an enforced block so that other builds against the same
// bucket automatically run these commands.
func (cfg *PackerConfig) ExtractBuildProvisionerHCL() (string, error) {
sourceFiles := cfg.parser.Files()
var buf strings.Builder
// Re-parse source files with a fresh parser so the bodies are unconsumed.
freshParser := hclparse.NewParser()
buildSchema := &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: buildLabel},
},
}
provisionerSchema := &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: buildProvisionerLabel, LabelNames: []string{"type"}},
},
}
inlineSchema := &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "inline"},
},
}
var allCommands []string
for filename, file := range sourceFiles {
// hclwrite only supports HCL native syntax, skip JSON and variable files
if !strings.HasSuffix(filename, hcl2FileExt) {
continue
}
wf, diags := hclwrite.ParseConfig(file.Bytes, filename, hcl.Pos{Line: 1, Column: 1})
f, diags := freshParser.ParseHCL(file.Bytes, filename)
if diags.HasErrors() {
continue
}
for _, block := range wf.Body().Blocks() {
if block.Type() != buildLabel {
content, _, diags := f.Body.PartialContent(buildSchema)
if diags.HasErrors() {
continue
}
for _, buildBlock := range content.Blocks {
innerContent, _, diags := buildBlock.Body.PartialContent(provisionerSchema)
if diags.HasErrors() {
continue
}
for _, inner := range block.Body().Blocks() {
if inner.Type() != buildProvisionerLabel {
for _, provBlock := range innerContent.Blocks {
if provBlock.Labels[0] != "shell" {
continue
}
buf.Write(inner.BuildTokens(nil).Bytes())
buf.WriteString("\n")
attrContent, _, diags := provBlock.Body.PartialContent(inlineSchema)
if diags.HasErrors() {
continue
}
inlineAttr, ok := attrContent.Attributes["inline"]
if !ok {
continue
}
val, diags := inlineAttr.Expr.Value(nil)
if diags.HasErrors() {
continue
}
if !val.CanIterateElements() {
continue
}
it := val.ElementIterator()
for it.Next() {
_, v := it.Element()
if v.Type() == cty.String {
allCommands = append(allCommands, v.AsString())
}
}
}
}
}
return strings.TrimSpace(buf.String()), nil
if len(allCommands) == 0 {
return "", nil
}
// Build a single merged provisioner "shell" block with all commands.
var buf strings.Builder
buf.WriteString(`provisioner "shell" {` + "\n")
buf.WriteString(" inline = [\n")
for i, cmd := range allCommands {
buf.WriteString(fmt.Sprintf(" %q", cmd))
if i < len(allCommands)-1 {
buf.WriteString(",")
}
buf.WriteString("\n")
}
buf.WriteString(" ]\n")
buf.WriteString("}\n")
return buf.String(), nil
}

@ -36,7 +36,9 @@ func (p *HCL2Provisioner) HCL2Prepare(buildVars map[string]interface{}) error {
ectx = p.evalContext.NewChild()
buildValues := map[string]cty.Value{}
if !p.evalContext.Variables[buildAccessor].IsNull() {
buildValues = p.evalContext.Variables[buildAccessor].AsValueMap()
for k, v := range p.evalContext.Variables[buildAccessor].AsValueMap() {
buildValues[k] = v
}
}
for k, v := range buildVars {
val, err := ConvertPluginConfigValueToHCLValue(v)

@ -43,21 +43,6 @@ func (h *HCLRegistry) PopulateVersion(ctx context.Context) error {
return err
}
// Extract provisioner blocks from the build and publish them as enforced
// blocks to HCP Packer, so other builds against the same bucket will
// automatically have these provisioners injected.
blockContent, err := h.configuration.ExtractBuildProvisionerHCL()
if err != nil {
log.Printf("[WARN] failed to extract provisioner blocks for enforced publishing: %v", err)
} else if blockContent != "" {
blockName := h.bucket.Name + "-provisioners"
if pubErr := h.bucket.PublishEnforcedBlocks(
ctx, blockName, blockContent, hcpPackerModels.HashicorpCloudPacker20230101TemplateTypeHCL2,
); pubErr != nil {
log.Printf("[WARN] failed to publish enforced blocks for bucket %q: %v", h.bucket.Name, pubErr)
}
}
err = h.bucket.populateVersion(ctx)
if err != nil {
return err
@ -137,7 +122,7 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl
}
if len(provBlocks) > 0 {
h.ui.Say(fmt.Sprintf("Loaded %d enforced provisioner(s) from HCP block %q", len(provBlocks), eb.Name))
h.ui.Say(fmt.Sprintf("Loaded %d enforced provisioner(s) from HCP block %q and template type %q", len(provBlocks), eb.Name, eb.TemplateType))
}
// Inject into each build
@ -156,10 +141,10 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl
continue
}
log.Printf("[INFO] injecting enforced provisioner %q from block %q into build %q",
pb.PType, eb.Name, build.Name())
build.Provisioners = append(build.Provisioners, coreProv)
log.Printf("[INFO] injected enforced provisioner %q from block %q into build %q",
pb.PType, eb.Name, build.Name())
}
}
}

@ -36,6 +36,7 @@ type EnforcedBlock struct {
BlockContent string // Raw HCL content containing provisioner blocks
VersionID string
Version string
TemplateType string
}
// Bucket represents a single bucket on the HCP Packer registry.
@ -160,6 +161,8 @@ func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context) error {
return errors.New("bucket client not initialized, call Initialize first")
}
log.Printf("[INFO] fetching enforced blocks linked to bucket %q", bucket.Name)
resp, err := bucket.client.GetEnforcedBlocksForBucket(ctx, bucket.Name)
if err != nil {
// If the API doesn't support enforced blocks yet or returns not found, continue silently
@ -168,6 +171,7 @@ func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context) error {
}
if resp == nil {
log.Printf("[INFO] no enforced blocks response returned for bucket %q", bucket.Name)
return nil
}
@ -184,10 +188,20 @@ func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context) error {
VersionID: detail.Version.ID,
Version: detail.Version.Version,
}
if detail.Version.TemplateType != nil {
block.TemplateType = string(*detail.Version.TemplateType)
}
bucket.EnforcedBlocks = append(bucket.EnforcedBlocks, block)
log.Printf("[INFO] linked enforced block found for bucket %q: name=%q id=%q version=%q",
bucket.Name, block.Name, block.ID, block.Version)
}
if len(bucket.EnforcedBlocks) == 0 {
log.Printf("[INFO] no enforced provisioner blocks linked to bucket %q", bucket.Name)
}
log.Printf("[INFO] fetched %d enforced block(s) for bucket %q", len(bucket.EnforcedBlocks), bucket.Name)
log.Printf("[INFO] fetched %d enforced block(s) linked to bucket %q", len(bucket.EnforcedBlocks), bucket.Name)
return nil
}
@ -247,15 +261,8 @@ func (bucket *Bucket) PublishEnforcedBlocks(
}
log.Printf("[INFO] created new version for enforced block %q", blockName)
} else {
// Create new enforced block
log.Printf("[INFO] creating enforced block %q for bucket %q", blockName, bucket.Name)
_, err := bucket.client.CreateEnforcedBlock(
ctx, blockName, blockContent, version, templateType, "", nil,
)
if err != nil {
return fmt.Errorf("failed to create enforced block %q: %w", blockName, err)
}
log.Printf("[INFO] created enforced block %q", blockName)
// Requirement: do not create enforced blocks from CLI.
log.Printf("[INFO] enforced block %q does not exist; skipping creation for bucket %q", blockName, bucket.Name)
}
return nil

Loading…
Cancel
Save