mirror of https://github.com/hashicorp/packer
- Introduced a new package `enforcedparser` to handle parsing of enforced provisioner blocks from HCL and JSON formats. - Refactored existing code to utilize the new `ParseProvisionerBlocks` function from the `enforcedparser` package. - Updated `GetCoreBuildProvisionerFromEnforcedBlock` method to convert enforced provisioner blocks into core build provisioners. - Enhanced error handling and logging during the parsing process. - Added tests for the new parsing functionality and ensured existing tests were updated to reflect changes. - Modified `InjectEnforcedProvisioners` method in JSON registry to utilize the new parsing logic.feature/enforcedProvisioner
parent
7122c28125
commit
802199dde1
@ -0,0 +1,286 @@
|
||||
// Copyright IBM Corp. 2013, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package enforcedparser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/gohcl"
|
||||
"github.com/hashicorp/hcl/v2/hclparse"
|
||||
hcl2shim "github.com/hashicorp/packer/hcl2template/shim"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
const provisionerBlockLabel = "provisioner"
|
||||
|
||||
var enforcedProvisionerSchema = &hcl.BodySchema{
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{Type: provisionerBlockLabel, LabelNames: []string{"type"}},
|
||||
},
|
||||
}
|
||||
|
||||
type OnlyExcept struct {
|
||||
Only []string `json:"only,omitempty"`
|
||||
Except []string `json:"except,omitempty"`
|
||||
}
|
||||
|
||||
func (o *OnlyExcept) Skip(n string) bool {
|
||||
if len(o.Only) > 0 {
|
||||
for _, v := range o.Only {
|
||||
if v == n {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if len(o.Except) > 0 {
|
||||
for _, v := range o.Except {
|
||||
if v == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *OnlyExcept) Validate() hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
if len(o.Only) > 0 && len(o.Except) > 0 {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Summary: "only one of 'only' or 'except' may be specified",
|
||||
Severity: hcl.DiagError,
|
||||
})
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
type ProvisionerBlock struct {
|
||||
PType string
|
||||
PName string
|
||||
PauseBefore time.Duration
|
||||
MaxRetries int
|
||||
Timeout time.Duration
|
||||
Override map[string]interface{}
|
||||
OnlyExcept OnlyExcept
|
||||
DefRange hcl.Range
|
||||
TypeRange hcl.Range
|
||||
LabelsRange []hcl.Range
|
||||
Rest hcl.Body
|
||||
}
|
||||
|
||||
// ParseProvisionerBlocks parses raw enforced block content into a neutral provisioner model.
|
||||
func ParseProvisionerBlocks(blockContent string) ([]*ProvisionerBlock, hcl.Diagnostics) {
|
||||
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() {
|
||||
log.Printf("[DEBUG] parsed enforced provisioner block content as HCL")
|
||||
return parseProvisionerBlocksFromFile(file, diags)
|
||||
}
|
||||
log.Printf("[DEBUG] failed to parse enforced provisioner block content as HCL, trying JSON fallback")
|
||||
|
||||
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(jsonFile, jsonDiags)
|
||||
if !provisionerDiags.HasErrors() && len(provisioners) > 0 {
|
||||
log.Printf("[DEBUG] parsed enforced provisioner block content as JSON")
|
||||
return provisioners, provisionerDiags
|
||||
}
|
||||
|
||||
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(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(file *hcl.File, diags hcl.Diagnostics) ([]*ProvisionerBlock, hcl.Diagnostics) {
|
||||
content, moreDiags := file.Body.Content(enforcedProvisionerSchema)
|
||||
diags = append(diags, moreDiags...)
|
||||
if diags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
ectx := &hcl.EvalContext{Variables: map[string]cty.Value{}}
|
||||
provisioners := make([]*ProvisionerBlock, 0, len(content.Blocks))
|
||||
|
||||
for _, block := range content.Blocks {
|
||||
prov, moreDiags := decodeProvisioner(block, ectx)
|
||||
diags = append(diags, moreDiags...)
|
||||
if moreDiags.HasErrors() {
|
||||
continue
|
||||
}
|
||||
provisioners = append(provisioners, prov)
|
||||
}
|
||||
|
||||
return provisioners, diags
|
||||
}
|
||||
|
||||
func decodeProvisioner(block *hcl.Block, ectx *hcl.EvalContext) (*ProvisionerBlock, hcl.Diagnostics) {
|
||||
var b struct {
|
||||
Name string `hcl:"name,optional"`
|
||||
PauseBefore string `hcl:"pause_before,optional"`
|
||||
MaxRetries int `hcl:"max_retries,optional"`
|
||||
Timeout string `hcl:"timeout,optional"`
|
||||
Only []string `hcl:"only,optional"`
|
||||
Except []string `hcl:"except,optional"`
|
||||
Override cty.Value `hcl:"override,optional"`
|
||||
Rest hcl.Body `hcl:",remain"`
|
||||
}
|
||||
diags := gohcl.DecodeBody(block.Body, ectx, &b)
|
||||
if diags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
provisioner := &ProvisionerBlock{
|
||||
PType: block.Labels[0],
|
||||
PName: b.Name,
|
||||
MaxRetries: b.MaxRetries,
|
||||
OnlyExcept: OnlyExcept{Only: b.Only, Except: b.Except},
|
||||
DefRange: block.DefRange,
|
||||
TypeRange: block.TypeRange,
|
||||
LabelsRange: block.LabelRanges,
|
||||
Rest: b.Rest,
|
||||
}
|
||||
|
||||
diags = diags.Extend(provisioner.OnlyExcept.Validate())
|
||||
if diags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
if !b.Override.IsNull() {
|
||||
if !b.Override.Type().IsObjectType() {
|
||||
return nil, append(diags, &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "provisioner's override block must be an HCL object",
|
||||
Subject: block.DefRange.Ptr(),
|
||||
})
|
||||
}
|
||||
|
||||
override := make(map[string]interface{})
|
||||
for buildName, overrides := range b.Override.AsValueMap() {
|
||||
buildOverrides := make(map[string]interface{})
|
||||
|
||||
if !overrides.Type().IsObjectType() {
|
||||
return nil, append(diags, &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: fmt.Sprintf(
|
||||
"provisioner's override.'%s' block must be an HCL object",
|
||||
buildName),
|
||||
Subject: block.DefRange.Ptr(),
|
||||
})
|
||||
}
|
||||
|
||||
for option, value := range overrides.AsValueMap() {
|
||||
buildOverrides[option] = hcl2shim.ConfigValueFromHCL2(value)
|
||||
}
|
||||
override[buildName] = buildOverrides
|
||||
}
|
||||
provisioner.Override = override
|
||||
}
|
||||
|
||||
if b.PauseBefore != "" {
|
||||
pauseBefore, err := time.ParseDuration(b.PauseBefore)
|
||||
if err != nil {
|
||||
return nil, append(diags, &hcl.Diagnostic{
|
||||
Summary: "Failed to parse pause_before duration",
|
||||
Severity: hcl.DiagError,
|
||||
Detail: err.Error(),
|
||||
Subject: &block.DefRange,
|
||||
})
|
||||
}
|
||||
provisioner.PauseBefore = pauseBefore
|
||||
}
|
||||
|
||||
if b.Timeout != "" {
|
||||
timeout, err := time.ParseDuration(b.Timeout)
|
||||
if err != nil {
|
||||
return nil, append(diags, &hcl.Diagnostic{
|
||||
Summary: "Failed to parse timeout duration",
|
||||
Severity: hcl.DiagError,
|
||||
Detail: err.Error(),
|
||||
Subject: &block.DefRange,
|
||||
})
|
||||
}
|
||||
provisioner.Timeout = timeout
|
||||
}
|
||||
|
||||
return provisioner, diags
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
// Copyright IBM Corp. 2013, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package enforcedparser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseProvisionerBlocks_BasicFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
blockContent string
|
||||
wantCount int
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "hcl",
|
||||
blockContent: `
|
||||
provisioner "shell" {
|
||||
inline = ["echo hello"]
|
||||
}
|
||||
`,
|
||||
wantCount: 1,
|
||||
wantType: "shell",
|
||||
},
|
||||
{
|
||||
name: "hcl json",
|
||||
blockContent: `{
|
||||
"provisioner": [
|
||||
{
|
||||
"shell": {
|
||||
"inline": ["echo hello"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
wantCount: 1,
|
||||
wantType: "shell",
|
||||
},
|
||||
{
|
||||
name: "legacy json fallback",
|
||||
blockContent: `{
|
||||
"provisioners": [
|
||||
{
|
||||
"type": "shell",
|
||||
"inline": ["echo hello"]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
wantCount: 1,
|
||||
wantType: "shell",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
blocks, diags := ParseProvisionerBlocks(tt.blockContent)
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags)
|
||||
}
|
||||
if len(blocks) != tt.wantCount {
|
||||
t.Fatalf("ParseProvisionerBlocks() got %d blocks, want %d", len(blocks), tt.wantCount)
|
||||
}
|
||||
if blocks[0].PType != tt.wantType {
|
||||
t.Fatalf("first block type = %q, want %q", blocks[0].PType, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProvisionerBlocks_OverrideAndOnlyExcept(t *testing.T) {
|
||||
blocks, diags := ParseProvisionerBlocks(`
|
||||
provisioner "shell" {
|
||||
only = ["amazon-ebs.ubuntu"]
|
||||
override = {
|
||||
"amazon-ebs.ubuntu" = {
|
||||
bool = false
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags)
|
||||
}
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||||
}
|
||||
|
||||
pb := blocks[0]
|
||||
if pb.OnlyExcept.Skip("amazon-ebs.ubuntu") {
|
||||
t.Fatal("Skip() should return false for source in only list")
|
||||
}
|
||||
if !pb.OnlyExcept.Skip("null.test") {
|
||||
t.Fatal("Skip() should return true for source not in only list")
|
||||
}
|
||||
|
||||
rawOverride, ok := pb.Override["amazon-ebs.ubuntu"]
|
||||
if !ok {
|
||||
t.Fatal("expected override for amazon-ebs.ubuntu")
|
||||
}
|
||||
override, ok := rawOverride.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("override type = %T, want map[string]interface{}", rawOverride)
|
||||
}
|
||||
if got, ok := override["bool"]; !ok || got != false {
|
||||
t.Fatalf("override bool = %#v, want false", override["bool"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProvisionerBlocks_InvalidContent(t *testing.T) {
|
||||
_, diags := ParseProvisionerBlocks("this is not valid { hcl }}}")
|
||||
if !diags.HasErrors() {
|
||||
t.Fatal("expected parse error, got none")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
// Copyright IBM Corp. 2013, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
|
||||
packertemplate "github.com/hashicorp/packer-plugin-sdk/template"
|
||||
"github.com/hashicorp/packer/packer"
|
||||
)
|
||||
|
||||
func testJSONRegistryWithBuilds(t *testing.T, builderNames ...string) (*JSONRegistry, []*packer.CoreBuild, *packersdk.MockProvisioner) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.Setenv("HCP_PACKER_BUCKET_NAME", "test-bucket"); err != nil {
|
||||
t.Fatalf("Setenv() unexpected error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Unsetenv("HCP_PACKER_BUCKET_NAME")
|
||||
})
|
||||
|
||||
coreConfig := packer.TestCoreConfig(t)
|
||||
packer.TestBuilder(t, coreConfig, "test")
|
||||
provisioner := packer.TestProvisioner(t, coreConfig, "test")
|
||||
|
||||
builders := make(map[string]*packertemplate.Builder, len(builderNames))
|
||||
for _, name := range builderNames {
|
||||
builders[name] = &packertemplate.Builder{
|
||||
Name: name,
|
||||
Type: "test",
|
||||
Config: map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
coreConfig.Template = &packertemplate.Template{
|
||||
Path: "test.json",
|
||||
Builders: builders,
|
||||
}
|
||||
|
||||
core := packer.TestCore(t, coreConfig)
|
||||
registry, diags := NewJSONRegistry(core, packer.TestUi(t))
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("NewJSONRegistry() unexpected error: %v", diags)
|
||||
}
|
||||
|
||||
builds, diags := core.GetBuilds(packer.GetBuildsOptions{})
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("GetBuilds() unexpected error: %v", diags)
|
||||
}
|
||||
|
||||
return registry, builds, provisioner
|
||||
}
|
||||
|
||||
func TestJSONRegistry_InjectEnforcedProvisioners_AppliesOverride(t *testing.T) {
|
||||
registry, builds, provisioner := testJSONRegistryWithBuilds(t, "app")
|
||||
registry.bucket.EnforcedBlocks = []*EnforcedBlock{{
|
||||
Name: "enforced",
|
||||
BlockContent: `provisioner "test" {
|
||||
override = {
|
||||
app = {
|
||||
foo = "bar"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}}
|
||||
|
||||
diags := registry.InjectEnforcedProvisioners(builds)
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("InjectEnforcedProvisioners() unexpected error: %v", diags)
|
||||
}
|
||||
|
||||
if got := len(builds[0].Provisioners); got != 1 {
|
||||
t.Fatalf("build provisioner count = %d, want 1", got)
|
||||
}
|
||||
|
||||
if !provisioner.PrepCalled {
|
||||
t.Fatal("expected injected legacy JSON provisioner to be prepared")
|
||||
}
|
||||
|
||||
foundOverride := false
|
||||
for _, raw := range provisioner.PrepConfigs {
|
||||
config, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if value, ok := config["foo"]; ok && value == "bar" {
|
||||
foundOverride = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundOverride {
|
||||
t.Fatal("expected override config to be passed to injected provisioner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONRegistry_InjectEnforcedProvisioners_RespectsOnlyExcept(t *testing.T) {
|
||||
registry, builds, _ := testJSONRegistryWithBuilds(t, "app", "other")
|
||||
registry.bucket.EnforcedBlocks = []*EnforcedBlock{{
|
||||
Name: "enforced",
|
||||
BlockContent: `provisioner "test" {
|
||||
only = ["app"]
|
||||
}`,
|
||||
}}
|
||||
|
||||
diags := registry.InjectEnforcedProvisioners(builds)
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("InjectEnforcedProvisioners() unexpected error: %v", diags)
|
||||
}
|
||||
|
||||
provisionerCounts := make(map[string]int, len(builds))
|
||||
for _, build := range builds {
|
||||
provisionerCounts[build.Type] = len(build.Provisioners)
|
||||
}
|
||||
|
||||
if provisionerCounts["app"] != 1 {
|
||||
t.Fatalf("app build provisioner count = %d, want 1", provisionerCounts["app"])
|
||||
}
|
||||
|
||||
if provisionerCounts["other"] != 0 {
|
||||
t.Fatalf("other build provisioner count = %d, want 0", provisionerCounts["other"])
|
||||
}
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
packer {
|
||||
required_plugins {
|
||||
docker = {
|
||||
version = ">= 1.1.0"
|
||||
source = "github.com/hashicorp/docker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# HCP Packer registry — provisioner blocks below will be
|
||||
# automatically published as enforced blocks to this bucket.
|
||||
hcp_packer_registry {
|
||||
bucket_name = "ubuntu-test"
|
||||
description = "Test Ubuntu image with enforced provisioners"
|
||||
|
||||
bucket_labels = {
|
||||
"team" = "platform"
|
||||
"os" = "ubuntu"
|
||||
"purpose" = "testing"
|
||||
}
|
||||
}
|
||||
|
||||
source "docker" "ubuntu" {
|
||||
image = "ubuntu:22.04"
|
||||
commit = true
|
||||
}
|
||||
|
||||
build {
|
||||
name = "ubuntu-test"
|
||||
|
||||
sources = ["source.docker.ubuntu"]
|
||||
|
||||
provisioner "shell" {
|
||||
inline = [
|
||||
"apt-get update -y",
|
||||
"apt-get install -y curl wget jq"
|
||||
]
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
inline = [
|
||||
"echo 'Creating app user...'",
|
||||
"useradd -m -s /bin/bash appuser",
|
||||
"mkdir -p /opt/app",
|
||||
"chown appuser:appuser /opt/app"
|
||||
]
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
inline = [
|
||||
"echo 'Applying security hardening...'",
|
||||
"echo 'net.ipv4.ip_forward = 0' >> /etc/sysctl.conf",
|
||||
"echo 'Build complete!'"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue