diff --git a/hcl2template/enforced_provisioner.go b/hcl2template/enforced_provisioner.go index 2695784d0..41831074d 100644 --- a/hcl2template/enforced_provisioner.go +++ b/hcl2template/enforced_provisioner.go @@ -4,140 +4,39 @@ package hcl2template import ( - "encoding/json" "fmt" - "log" "strconv" "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/packer/internal/enforcedparser" "github.com/hashicorp/packer/packer" - "github.com/zclconf/go-cty/cty" ) -var enforcedProvisionerSchema = &hcl.BodySchema{ - Blocks: []hcl.BlockHeaderSchema{ - {Type: buildProvisionerLabel, LabelNames: []string{"type"}}, - }, -} - -// 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() { - 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 +func provisionerBlockFromEnforced(pb *enforcedparser.ProvisionerBlock) *ProvisionerBlock { + return &ProvisionerBlock{ + PType: pb.PType, + PName: pb.PName, + PauseBefore: pb.PauseBefore, + MaxRetries: pb.MaxRetries, + Timeout: pb.Timeout, + Override: pb.Override, + OnlyExcept: OnlyExcept{ + Only: pb.OnlyExcept.Only, + Except: pb.OnlyExcept.Except, + }, + HCL2Ref: HCL2Ref{ + DefRange: pb.DefRange, + TypeRange: pb.TypeRange, + LabelsRanges: pb.LabelsRange, + Rest: pb.Rest, + }, } - - 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() { - 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 := parser.decodeProvisioner(block, ectx) - diags = append(diags, moreDiags...) - if moreDiags.HasErrors() { - continue - } - provisioners = append(provisioners, prov) - } - - return provisioners, diags +// GetCoreBuildProvisionerFromEnforcedBlock converts a shared enforced provisioner block +// into a CoreBuildProvisioner using HCL runtime semantics. +func (cfg *PackerConfig) GetCoreBuildProvisionerFromEnforcedBlock(pb *enforcedparser.ProvisionerBlock, buildName string) (packer.CoreBuildProvisioner, hcl.Diagnostics) { + return cfg.GetCoreBuildProvisionerFromBlock(provisionerBlockFromEnforced(pb), buildName) } // GetCoreBuildProvisionerFromBlock converts a ProvisionerBlock to a CoreBuildProvisioner. diff --git a/hcl2template/enforced_provisioner_test.go b/hcl2template/enforced_provisioner_test.go index 4df1d7ba1..b0fa533d8 100644 --- a/hcl2template/enforced_provisioner_test.go +++ b/hcl2template/enforced_provisioner_test.go @@ -5,6 +5,8 @@ package hcl2template import ( "testing" + + "github.com/hashicorp/packer/internal/enforcedparser" ) func TestGetCoreBuildProvisionerFromBlock_AppliesOverrideForBuild(t *testing.T) { @@ -14,7 +16,7 @@ func TestGetCoreBuildProvisionerFromBlock_AppliesOverrideForBuild(t *testing.T) CorePackerVersionString: lockedVersion, } - blocks, diags := ParseProvisionerBlocks(` + blocks, diags := enforcedparser.ParseProvisionerBlocks(` provisioner "shell" { override = { "amazon-ebs.ubuntu" = { @@ -31,7 +33,7 @@ provisioner "shell" { t.Fatalf("expected 1 block, got %d", len(blocks)) } - coreProv, diags := cfg.GetCoreBuildProvisionerFromBlock(blocks[0], "amazon-ebs.ubuntu") + coreProv, diags := cfg.GetCoreBuildProvisionerFromEnforcedBlock(blocks[0], "amazon-ebs.ubuntu") if diags.HasErrors() { t.Fatalf("GetCoreBuildProvisionerFromBlock() unexpected error: %v", diags) } @@ -57,7 +59,7 @@ func TestGetCoreBuildProvisionerFromBlock_OverrideNotAppliedForOtherBuild(t *tes CorePackerVersionString: lockedVersion, } - blocks, diags := ParseProvisionerBlocks(` + blocks, diags := enforcedparser.ParseProvisionerBlocks(` provisioner "shell" { override = { "amazon-ebs.ubuntu" = { @@ -74,7 +76,7 @@ provisioner "shell" { t.Fatalf("expected 1 block, got %d", len(blocks)) } - coreProv, diags := cfg.GetCoreBuildProvisionerFromBlock(blocks[0], "virtualbox-iso.base") + coreProv, diags := cfg.GetCoreBuildProvisionerFromEnforcedBlock(blocks[0], "virtualbox-iso.base") if diags.HasErrors() { t.Fatalf("GetCoreBuildProvisionerFromBlock() unexpected error: %v", diags) } @@ -247,7 +249,7 @@ provisioner "shell" { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - blocks, diags := ParseProvisionerBlocks(tt.blockContent) + blocks, diags := enforcedparser.ParseProvisionerBlocks(tt.blockContent) if tt.wantErr { if !diags.HasErrors() { @@ -282,7 +284,7 @@ provisioner "shell" { inline = ["echo 'test'"] } ` - blocks, diags := ParseProvisionerBlocks(blockContent) + blocks, diags := enforcedparser.ParseProvisionerBlocks(blockContent) if diags.HasErrors() { t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags) } @@ -304,7 +306,7 @@ provisioner "shell" { inline = ["echo 'test'"] } ` - blocks, diags := ParseProvisionerBlocks(blockContent) + blocks, diags := enforcedparser.ParseProvisionerBlocks(blockContent) if diags.HasErrors() { t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags) } @@ -325,7 +327,7 @@ provisioner "shell" { inline = ["echo 'test'"] } ` - blocks, diags := ParseProvisionerBlocks(blockContent) + blocks, diags := enforcedparser.ParseProvisionerBlocks(blockContent) if diags.HasErrors() { t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags) } @@ -364,7 +366,7 @@ func TestParseProvisionerBlocksJSONWithOptions(t *testing.T) { ] }` - blocks, diags := ParseProvisionerBlocks(blockContent) + blocks, diags := enforcedparser.ParseProvisionerBlocks(blockContent) if diags.HasErrors() { t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags) } diff --git a/internal/enforcedparser/parser.go b/internal/enforcedparser/parser.go new file mode 100644 index 000000000..88d0c7247 --- /dev/null +++ b/internal/enforcedparser/parser.go @@ -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 +} \ No newline at end of file diff --git a/internal/enforcedparser/parser_test.go b/internal/enforcedparser/parser_test.go new file mode 100644 index 000000000..c9d83b330 --- /dev/null +++ b/internal/enforcedparser/parser_test.go @@ -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") + } +} diff --git a/internal/hcp/registry/hcl.go b/internal/hcp/registry/hcl.go index 19eb14baa..63c64c895 100644 --- a/internal/hcp/registry/hcl.go +++ b/internal/hcp/registry/hcl.go @@ -12,6 +12,7 @@ import ( hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" sdkpacker "github.com/hashicorp/packer-plugin-sdk/packer" "github.com/hashicorp/packer/hcl2template" + "github.com/hashicorp/packer/internal/enforcedparser" "github.com/hashicorp/packer/packer" "github.com/zclconf/go-cty/cty" ) @@ -111,7 +112,7 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl continue } - provBlocks, diags := hcl2template.ParseProvisionerBlocks(eb.BlockContent) + provBlocks, diags := enforcedparser.ParseProvisionerBlocks(eb.BlockContent) if diags.HasErrors() { allDiags = append(allDiags, &hcl.Diagnostic{ Severity: hcl.DiagError, @@ -135,7 +136,7 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl continue } - coreProv, moreDiags := h.configuration.GetCoreBuildProvisionerFromBlock(pb, build.Type) + coreProv, moreDiags := h.configuration.GetCoreBuildProvisionerFromEnforcedBlock(pb, build.Type) if moreDiags.HasErrors() { allDiags = append(allDiags, moreDiags...) continue diff --git a/internal/hcp/registry/json.go b/internal/hcp/registry/json.go index b3e782ab1..5cd25b33f 100644 --- a/internal/hcp/registry/json.go +++ b/internal/hcp/registry/json.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/hcl/v2" hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" sdkpacker "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/hashicorp/packer/internal/enforcedparser" "github.com/hashicorp/packer/packer" ) @@ -120,16 +121,78 @@ func (h *JSONRegistry) FetchEnforcedBlocks(ctx context.Context) error { } // InjectEnforcedProvisioners injects enforced provisioners into the builds -// Note: JSON templates don't support enforced provisioners as they are a legacy format func (h *JSONRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics { - if len(h.bucket.EnforcedBlocks) > 0 { - return hcl.Diagnostics{ - &hcl.Diagnostic{ + enforcedBlocks := h.bucket.EnforcedBlocks + if len(enforcedBlocks) == 0 { + return nil + } + + var allDiags hcl.Diagnostics + + for _, eb := range enforcedBlocks { + if eb.BlockContent == "" { + continue + } + + provBlocks, diags := enforcedparser.ParseProvisionerBlocks(eb.BlockContent) + if diags.HasErrors() { + allDiags = append(allDiags, &hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: "Enforced provisioners are not supported for legacy JSON templates", - Detail: "Linked enforced blocks were found for this bucket, but the current build is a legacy JSON template.", - }, + Summary: fmt.Sprintf("Failed to parse enforced block %q", eb.Name), + Detail: diags.Error(), + }) + continue + } + + if len(provBlocks) > 0 { + h.ui.Say(fmt.Sprintf("Loaded %d enforced provisioner(s) from HCP block %q and template type %q", len(provBlocks), eb.Name, eb.TemplateType)) + } + + for _, build := range builds { + buildName := build.Type + injected := make([]packer.CoreBuildProvisioner, 0, len(provBlocks)) + + for _, pb := range provBlocks { + if pb.OnlyExcept.Skip(buildName) { + log.Printf("[DEBUG] skipping enforced provisioner %q for legacy JSON build %q due to only/except rules", + pb.PType, build.Name()) + continue + } + + coreProv, moreDiags := h.configuration.GenerateCoreBuildProvisionerFromHCLBody( + pb.PType, + pb.Rest, + pb.Override, + pb.PauseBefore, + pb.MaxRetries, + pb.Timeout, + buildName, + ) + if moreDiags.HasErrors() { + allDiags = append(allDiags, moreDiags...) + continue + } + + build.Provisioners = append(build.Provisioners, coreProv) + injected = append(injected, coreProv) + + log.Printf("[INFO] injected enforced provisioner %q from block %q into legacy JSON build %q", + pb.PType, eb.Name, build.Name()) + } + + if len(injected) == 0 { + continue + } + + if err := build.PrepareProvisioners(injected...); err != nil { + allDiags = append(allDiags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to prepare enforced provisioners for legacy JSON build %q", build.Name()), + Detail: err.Error(), + }) + } } } - return nil + + return allDiags } diff --git a/internal/hcp/registry/json_enforced_test.go b/internal/hcp/registry/json_enforced_test.go new file mode 100644 index 000000000..4ad468b2c --- /dev/null +++ b/internal/hcp/registry/json_enforced_test.go @@ -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"]) + } +} \ No newline at end of file diff --git a/packer/build.go b/packer/build.go index 27b8ae2e4..e2054afe7 100644 --- a/packer/build.go +++ b/packer/build.go @@ -145,6 +145,58 @@ func (b *CoreBuild) Name() string { return b.Type } +func (b *CoreBuild) packerConfig() map[string]interface{} { + return map[string]interface{}{ + common.BuildNameConfigKey: b.Type, + common.BuilderTypeConfigKey: b.BuilderType, + common.CoreVersionConfigKey: version.FormattedVersion(), + common.DebugConfigKey: b.debug, + common.ForceConfigKey: b.force, + common.OnErrorConfigKey: b.onError, + common.TemplatePathKey: b.TemplatePath, + common.UserVariablesConfigKey: b.Variables, + common.SensitiveVarsConfigKey: b.SensitiveVars, + } +} + +func (b *CoreBuild) prepareProvisioners(provisioners []CoreBuildProvisioner, packerConfig map[string]interface{}, generatedVars []string) error { + generatedPlaceholderMap := placeholderDataFromGeneratedVars(generatedVars) + + for _, coreProv := range provisioners { + configs := make([]interface{}, len(coreProv.config), len(coreProv.config)+1) + copy(configs, coreProv.config) + configs = append(configs, packerConfig) + configs = append(configs, generatedPlaceholderMap) + + if err := coreProv.Provisioner.Prepare(configs...); err != nil { + return err + } + } + + return nil +} + +func placeholderDataFromGeneratedVars(generatedVars []string) map[string]string { + generatedPlaceholderMap := BasicPlaceholderData() + for _, k := range generatedVars { + generatedPlaceholderMap[k] = fmt.Sprintf("Build_%s. "+ + packerbuilderdata.PlaceholderMsg, k) + } + + return generatedPlaceholderMap +} + +// PrepareProvisioners prepares provisioners injected after the build itself has already been prepared. +func (b *CoreBuild) PrepareProvisioners(provisioners ...CoreBuildProvisioner) error { + packerConfig := b.packerConfig() + generatedVars, _, err := b.Builder.Prepare(b.BuilderConfig, packerConfig) + if err != nil { + return err + } + + return b.prepareProvisioners(provisioners, packerConfig, generatedVars) +} + // Prepare prepares the build by doing some initialization for the builder // and any hooks. This _must_ be called prior to Run. The parameter is the // overrides for the variables within the template (if any). @@ -167,17 +219,7 @@ func (b *CoreBuild) Prepare() (warn []string, err error) { // a custom json area instead of just aborting early for HCL. b.prepareCalled = true - packerConfig := map[string]interface{}{ - common.BuildNameConfigKey: b.Type, - common.BuilderTypeConfigKey: b.BuilderType, - common.CoreVersionConfigKey: version.FormattedVersion(), - common.DebugConfigKey: b.debug, - common.ForceConfigKey: b.force, - common.OnErrorConfigKey: b.onError, - common.TemplatePathKey: b.TemplatePath, - common.UserVariablesConfigKey: b.Variables, - common.SensitiveVarsConfigKey: b.SensitiveVars, - } + packerConfig := b.packerConfig() // Prepare the builder generatedVars, warn, err := b.Builder.Prepare(b.BuilderConfig, packerConfig) @@ -186,27 +228,11 @@ func (b *CoreBuild) Prepare() (warn []string, err error) { return } - // If the builder has provided a list of to-be-generated variables that - // should be made accessible to provisioners, pass that list into - // the provisioner prepare() so that the provisioner can appropriately - // validate user input against what will become available. - generatedPlaceholderMap := BasicPlaceholderData() - for _, k := range generatedVars { - generatedPlaceholderMap[k] = fmt.Sprintf("Build_%s. "+ - packerbuilderdata.PlaceholderMsg, k) + if err = b.prepareProvisioners(b.Provisioners, packerConfig, generatedVars); err != nil { + return } - // Prepare the provisioners - for _, coreProv := range b.Provisioners { - configs := make([]interface{}, len(coreProv.config), len(coreProv.config)+1) - copy(configs, coreProv.config) - configs = append(configs, packerConfig) - configs = append(configs, generatedPlaceholderMap) - - if err = coreProv.Provisioner.Prepare(configs...); err != nil { - return - } - } + generatedPlaceholderMap := placeholderDataFromGeneratedVars(generatedVars) // Prepare the on-error-cleanup provisioner if b.CleanupProvisioner.PType != "" { diff --git a/packer/core.go b/packer/core.go index d0f7501a6..2ea0eaa97 100644 --- a/packer/core.go +++ b/packer/core.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "strings" + "time" ttmp "text/template" @@ -18,12 +19,15 @@ import ( multierror "github.com/hashicorp/go-multierror" version "github.com/hashicorp/go-version" hcl "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/packer-plugin-sdk/didyoumean" packersdk "github.com/hashicorp/packer-plugin-sdk/packer" "github.com/hashicorp/packer-plugin-sdk/template" "github.com/hashicorp/packer-plugin-sdk/template/interpolate" + hcl2shim "github.com/hashicorp/packer/hcl2template/shim" plugingetter "github.com/hashicorp/packer/packer/plugin-getter" packerversion "github.com/hashicorp/packer/version" + "github.com/zclconf/go-cty/cty" ) // Core is the main executor of Packer. If Packer is being used as a @@ -259,6 +263,12 @@ func (c *Core) generateCoreBuildProvisioner(rawP *template.Provisioner, rawName "provisioner failed to be started and did not error: %s", rawP.Type) } + return c.generateCoreBuildProvisionerWithProvisioner(rawP, rawName, provisioner) +} + +func (c *Core) generateCoreBuildProvisionerWithProvisioner(rawP *template.Provisioner, rawName string, provisioner packersdk.Provisioner) (CoreBuildProvisioner, error) { + cbp := CoreBuildProvisioner{} + // Get the configuration config := make([]interface{}, 1, 2) config[0] = rawP.Config @@ -312,6 +322,79 @@ func (c *Core) generateCoreBuildProvisioner(rawP *template.Provisioner, rawName return cbp, nil } +// GenerateCoreBuildProvisionerFromHCLBody converts a parsed enforced provisioner body into +// a legacy JSON core build provisioner, using the same runtime behavior as normal JSON templates. +func (c *Core) GenerateCoreBuildProvisionerFromHCLBody( + provisionerType string, + configBody hcl.Body, + override map[string]interface{}, + pauseBefore time.Duration, + maxRetries int, + timeout time.Duration, + rawName string, +) (CoreBuildProvisioner, hcl.Diagnostics) { + var diags hcl.Diagnostics + + if !c.components.PluginConfig.Provisioners.Has(provisionerType) { + return CoreBuildProvisioner{}, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to start enforced provisioner %q", provisionerType), + Detail: fmt.Sprintf("The provisioner plugin %q could not be loaded.", provisionerType), + }} + } + + provisioner, err := c.components.PluginConfig.Provisioners.Start(provisionerType) + if err != nil { + return CoreBuildProvisioner{}, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to start enforced provisioner %q", provisionerType), + Detail: fmt.Sprintf("The provisioner plugin could not be loaded: %s", err.Error()), + }} + } + if provisioner == nil { + return CoreBuildProvisioner{}, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to start enforced provisioner %q", provisionerType), + Detail: "The provisioner failed to start and returned no instance.", + }} + } + + flatProvisionerCfg, moreDiags := hcldec.Decode(configBody, provisioner.ConfigSpec(), &hcl.EvalContext{Variables: map[string]cty.Value{}}) + diags = append(diags, moreDiags...) + if diags.HasErrors() { + return CoreBuildProvisioner{}, diags + } + + flatProvisionerCfg = hcl2shim.WriteUnknownPlaceholderValues(flatProvisionerCfg) + decodedConfig := hcl2shim.ConfigValueFromHCL2(flatProvisionerCfg) + configMap, _ := decodedConfig.(map[string]interface{}) + if configMap == nil { + configMap = make(map[string]interface{}) + } + + rawProvisioner := &template.Provisioner{ + Type: provisionerType, + Config: configMap, + Override: override, + PauseBefore: pauseBefore, + Timeout: timeout, + } + if maxRetries > 0 { + rawProvisioner.MaxRetries = strconv.Itoa(maxRetries) + } + + coreProvisioner, err := c.generateCoreBuildProvisionerWithProvisioner(rawProvisioner, rawName, provisioner) + if err != nil { + return CoreBuildProvisioner{}, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to prepare enforced provisioner %q", provisionerType), + Detail: err.Error(), + }} + } + + return coreProvisioner, diags +} + // This is used for json templates to launch the build plugins. // They will be prepared via b.Prepare() later. func (c *Core) GetBuilds(opts GetBuildsOptions) ([]*CoreBuild, hcl.Diagnostics) { diff --git a/test.pkr.hcl b/test.pkr.hcl deleted file mode 100644 index 97ee42d88..000000000 --- a/test.pkr.hcl +++ /dev/null @@ -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!'" - ] - } -}