diff --git a/command/build.go b/command/build.go index 3f3af9bf3..3f49634fb 100644 --- a/command/build.go +++ b/command/build.go @@ -150,6 +150,26 @@ func (c *BuildCommand) RunContext(buildCtx context.Context, cla *BuildArgs) int return ret } + // Fetch and inject enforced provisioners from HCP Packer (if configured) + if !cla.SkipEnforcement { + if err := hcpRegistry.FetchEnforcedBlocks(buildCtx); err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{ + &hcl.Diagnostic{ + Summary: "HCP: fetching enforced provisioners failed", + Severity: hcl.DiagError, + Detail: err.Error(), + }, + }) + } + + diags := hcpRegistry.InjectEnforcedProvisioners(builds) + if diags.HasErrors() { + return writeDiags(c.Ui, nil, diags) + } + } else { + c.Ui.Say("Skipping HCP Packer enforced provisioners (--skip-enforcement flag set)") + } + if cla.Debug { c.Ui.Say("Debug mode enabled. Builds will not be parallelized.") } @@ -456,6 +476,7 @@ Options: -warn-on-undeclared-var Display warnings for user variable files containing undeclared variables. -ignore-prerelease-plugins Disable the loading of prerelease plugin binaries (x.y.z-dev). -use-sequential-evaluation Fallback to using a sequential approach for local/datasource evaluation. + -skip-enforcement Skip injection of HCP Packer enforced provisioners. ` return strings.TrimSpace(helpText) diff --git a/command/build_test.go b/command/build_test.go index a9726be7b..70425c666 100644 --- a/command/build_test.go +++ b/command/build_test.go @@ -1131,6 +1131,16 @@ func TestBuildCommand_ParseArgs(t *testing.T) { }, 0, }, + {fields{defaultMeta}, + args{[]string{"-skip-enforcement", "file.json"}}, + &BuildArgs{ + MetaArgs: MetaArgs{Path: "file.json"}, + ParallelBuilds: math.MaxInt64, + Color: true, + SkipEnforcement: true, + }, + 0, + }, } for _, tt := range tests { t.Run(fmt.Sprintf("%s", tt.args.args), func(t *testing.T) { diff --git a/command/cli.go b/command/cli.go index 655ab434e..643862e54 100644 --- a/command/cli.go +++ b/command/cli.go @@ -101,6 +101,8 @@ func (ba *BuildArgs) AddFlagSets(flags *flag.FlagSet) { flags.BoolVar(&ba.ReleaseOnly, "ignore-prerelease-plugins", false, "Disable the loading of prerelease plugin binaries (x.y.z-dev).") + flags.BoolVar(&ba.SkipEnforcement, "skip-enforcement", false, "Skip injection of HCP Packer enforced provisioners. Requires admin privileges.") + ba.MetaArgs.AddFlagSets(flags) } @@ -136,6 +138,7 @@ type BuildArgs struct { ParallelBuilds int64 OnError string ReleaseOnly bool + SkipEnforcement bool } func (ia *InitArgs) AddFlagSets(flags *flag.FlagSet) { diff --git a/go.mod b/go.mod index 2b0d750f3..c0c938b76 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/hcl/v2 v2.24.0 - github.com/hashicorp/hcp-sdk-go v0.167.0 + github.com/hashicorp/hcp-sdk-go v0.172.0 github.com/hashicorp/packer-plugin-sdk v0.6.7 github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869 github.com/klauspost/compress v1.18.4 diff --git a/go.sum b/go.sum index 37d8ff617..f4f9b5b22 100644 --- a/go.sum +++ b/go.sum @@ -732,8 +732,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= -github.com/hashicorp/hcp-sdk-go v0.167.0 h1:t2v+mm3gN1z4qvdJ7g9RuDdXDvIExMtjV1Fvzn2LuVc= -github.com/hashicorp/hcp-sdk-go v0.167.0/go.mod h1:v2vbpNIrmgUTelW4Z+ur+aQuSPxeaVK3xytFdpEXvSg= +github.com/hashicorp/hcp-sdk-go v0.172.0 h1:j4VrSN2yd8prFb8Y0gQWQbTpsV5uVPgYEUozOGfPOOc= +github.com/hashicorp/hcp-sdk-go v0.172.0/go.mod h1:v2vbpNIrmgUTelW4Z+ur+aQuSPxeaVK3xytFdpEXvSg= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= diff --git a/hcl2template/enforced_provisioner.go b/hcl2template/enforced_provisioner.go new file mode 100644 index 000000000..73ade4a42 --- /dev/null +++ b/hcl2template/enforced_provisioner.go @@ -0,0 +1,81 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package hcl2template + +import ( + "fmt" + "strconv" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/packer/packer" +) + +// GetCoreBuildProvisionerFromBlock converts a ProvisionerBlock to a CoreBuildProvisioner. +// This is used for enforced provisioners that need to be injected into builds. +func (cfg *PackerConfig) GetCoreBuildProvisionerFromBlock(pb *ProvisionerBlock, buildName string) (packer.CoreBuildProvisioner, hcl.Diagnostics) { + var diags hcl.Diagnostics + + // Get the provisioner plugin + provisioner, err := cfg.parser.PluginConfig.Provisioners.Start(pb.PType) + if err != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to start enforced provisioner %q", pb.PType), + Detail: fmt.Sprintf("The provisioner plugin could not be loaded: %s", err.Error()), + }) + return packer.CoreBuildProvisioner{}, diags + } + + // Create basic builder variables + builderVars := map[string]interface{}{ + "packer_core_version": cfg.CorePackerVersionString, + "packer_debug": strconv.FormatBool(cfg.debug), + "packer_force": strconv.FormatBool(cfg.force), + "packer_on_error": cfg.onError, + "packer_sensitive_variables": cfg.sensitiveInputVariableKeys(), + } + + // Create evaluation context + ectx := cfg.EvalContext(BuildContext, nil) + + // Create the HCL2Provisioner wrapper + hclProvisioner := &HCL2Provisioner{ + Provisioner: provisioner, + provisionerBlock: pb, + evalContext: ectx, + builderVariables: builderVars, + } + + if pb.Override != nil { + if override, ok := pb.Override[buildName]; ok { + if typedOverride, ok := override.(map[string]interface{}); ok { + hclProvisioner.override = typedOverride + } + } + } + + // Prepare the provisioner + err = hclProvisioner.HCL2Prepare(nil) + if err != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to prepare enforced provisioner %q", pb.PType), + Detail: err.Error(), + }) + return packer.CoreBuildProvisioner{}, diags + } + + // Wrap provisioner with any special behavior (pause, timeout, retry) + wrappedProvisioner := packer.WrapProvisionerWithOptions(hclProvisioner, packer.ProvisionerWrapOptions{ + PauseBefore: pb.PauseBefore, + Timeout: pb.Timeout, + MaxRetries: pb.MaxRetries, + }) + + return packer.CoreBuildProvisioner{ + PType: pb.PType, + PName: pb.PName, + Provisioner: wrappedProvisioner, + }, diags +} diff --git a/hcl2template/enforced_provisioner_parser.go b/hcl2template/enforced_provisioner_parser.go new file mode 100644 index 000000000..9a5a071f7 --- /dev/null +++ b/hcl2template/enforced_provisioner_parser.go @@ -0,0 +1,146 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package hcl2template + +import ( + "encoding/json" + "log" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/zclconf/go-cty/cty" +) + +var standaloneProvisionerSchema = &hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + {Type: buildProvisionerLabel, LabelNames: []string{"type"}}, + }, +} + +// ParseProvisionerBlocks parses raw provisioner block content into ProvisionerBlocks. +// It accepts HCL, HCL JSON, and the legacy JSON payload used for enforced provisioners. +func ParseProvisionerBlocks(blockContent string) ([]*ProvisionerBlock, hcl.Diagnostics) { + parser := &Parser{Parser: hclparse.NewParser()} + return parser.parseProvisionerBlocks(blockContent) +} + +func (p *Parser) parseProvisionerBlocks(blockContent string) ([]*ProvisionerBlock, hcl.Diagnostics) { + hclParser := p.Parser + if hclParser == nil { + hclParser = hclparse.NewParser() + } + + log.Printf("[DEBUG] parsing provisioner block content as HCL") + + file, diags := hclParser.ParseHCL([]byte(blockContent), "provisioner.pkr.hcl") + if !diags.HasErrors() { + provisioners, provisionerDiags := p.parseProvisionerBlocksFromFile(file, diags) + if provisionerDiags.HasErrors() { + return nil, provisionerDiags + } + log.Printf("[DEBUG] parsed provisioner block content as HCL") + return provisioners, provisionerDiags + } + log.Printf("[DEBUG] failed to parse provisioner block content as HCL, trying JSON fallback") + + jsonFile, jsonDiags := hclParser.ParseJSON([]byte(blockContent), "provisioner.pkr.json") + if jsonDiags.HasErrors() { + log.Printf("[DEBUG] failed to parse provisioner block content as JSON") + return nil, append(diags, jsonDiags...) + } + + provisioners, provisionerDiags := p.parseProvisionerBlocksFromFile(jsonFile, jsonDiags) + if !provisionerDiags.HasErrors() && len(provisioners) > 0 { + log.Printf("[DEBUG] parsed provisioner block content as JSON") + return provisioners, provisionerDiags + } + + legacyJSON, ok, err := normalizeLegacyProvisionersJSON(blockContent) + if err == nil && ok { + legacyFile, legacyDiags := hclParser.ParseJSON([]byte(legacyJSON), "provisioner_legacy.pkr.json") + if !legacyDiags.HasErrors() { + legacyProvisioners, legacyProvisionerDiags := p.parseProvisionerBlocksFromFile(legacyFile, legacyDiags) + if !legacyProvisionerDiags.HasErrors() && len(legacyProvisioners) > 0 { + log.Printf("[DEBUG] parsed provisioner block content as legacy JSON") + return legacyProvisioners, legacyProvisionerDiags + } + } + } + + if provisionerDiags.HasErrors() { + return nil, provisionerDiags + } + log.Printf("[DEBUG] parsed provisioner block content as JSON but found no valid provisioner blocks") + return provisioners, provisionerDiags +} + +func normalizeLegacyProvisionersJSON(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 _, provisioner := range payload.Provisioners { + typeName, ok := provisioner["type"].(string) + if !ok || typeName == "" { + continue + } + + cfg := make(map[string]interface{}) + for key, value := range provisioner { + if key == "type" { + continue + } + cfg[key] = value + } + + normalized = append(normalized, map[string]interface{}{typeName: cfg}) + } + + if len(normalized) == 0 { + return "", false, nil + } + + out := map[string]interface{}{ + buildProvisionerLabel: normalized, + } + + b, err := json.Marshal(out) + if err != nil { + return "", false, err + } + + return string(b), true, nil +} + +func (p *Parser) parseProvisionerBlocksFromFile(file *hcl.File, diags hcl.Diagnostics) ([]*ProvisionerBlock, hcl.Diagnostics) { + content, moreDiags := file.Body.Content(standaloneProvisionerSchema) + 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 { + provisioner, moreDiags := p.decodeProvisioner(block, ectx) + diags = append(diags, moreDiags...) + if moreDiags.HasErrors() { + continue + } + provisioners = append(provisioners, provisioner) + } + + return provisioners, diags +} diff --git a/hcl2template/enforced_provisioner_test.go b/hcl2template/enforced_provisioner_test.go new file mode 100644 index 000000000..4c87fadac --- /dev/null +++ b/hcl2template/enforced_provisioner_test.go @@ -0,0 +1,496 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package hcl2template + +import ( + "testing" +) + +func TestGetCoreBuildProvisionerFromBlock_AppliesOverrideForBuild(t *testing.T) { + parser := getBasicParser() + cfg := &PackerConfig{ + parser: parser, + CorePackerVersionString: lockedVersion, + } + + blocks, diags := ParseProvisionerBlocks(` +provisioner "shell" { + 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)) + } + + coreProv, diags := cfg.GetCoreBuildProvisionerFromBlock(blocks[0], "amazon-ebs.ubuntu") + if diags.HasErrors() { + t.Fatalf("GetCoreBuildProvisionerFromBlock() unexpected error: %v", diags) + } + + hclProv, ok := coreProv.Provisioner.(*HCL2Provisioner) + if !ok { + t.Fatalf("expected *HCL2Provisioner, got %T", coreProv.Provisioner) + } + + if hclProv.override == nil { + t.Fatal("expected override to be applied, got nil") + } + + if got, ok := hclProv.override["bool"]; !ok || got != false { + t.Fatalf("expected override bool=false, got %#v", hclProv.override["bool"]) + } +} + +func TestGetCoreBuildProvisionerFromBlock_OverrideNotAppliedForOtherBuild(t *testing.T) { + parser := getBasicParser() + cfg := &PackerConfig{ + parser: parser, + CorePackerVersionString: lockedVersion, + } + + blocks, diags := ParseProvisionerBlocks(` +provisioner "shell" { + 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)) + } + + coreProv, diags := cfg.GetCoreBuildProvisionerFromBlock(blocks[0], "virtualbox-iso.base") + if diags.HasErrors() { + t.Fatalf("GetCoreBuildProvisionerFromBlock() unexpected error: %v", diags) + } + + hclProv, ok := coreProv.Provisioner.(*HCL2Provisioner) + if !ok { + t.Fatalf("expected *HCL2Provisioner, got %T", coreProv.Provisioner) + } + + if hclProv.override != nil { + t.Fatalf("expected no override to be applied, got %#v", hclProv.override) + } +} + +func TestGetCoreBuildProvisionerFromBlock_IncludesSensitiveVariables(t *testing.T) { + parser := getBasicParser() + cfg := &PackerConfig{ + parser: parser, + CorePackerVersionString: lockedVersion, + InputVariables: Variables{ + "visible": &Variable{Name: "visible"}, + "secret": &Variable{Name: "secret", Sensitive: true}, + }, + } + + blocks, diags := ParseProvisionerBlocks(` +provisioner "shell" { + override = { + "amazon-ebs.ubuntu" = { + bool = false + } + } +} +`) + if diags.HasErrors() { + t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags) + } + + coreProv, diags := cfg.GetCoreBuildProvisionerFromBlock(blocks[0], "amazon-ebs.ubuntu") + if diags.HasErrors() { + t.Fatalf("GetCoreBuildProvisionerFromBlock() unexpected error: %v", diags) + } + + hclProv, ok := coreProv.Provisioner.(*HCL2Provisioner) + if !ok { + t.Fatalf("expected *HCL2Provisioner, got %T", coreProv.Provisioner) + } + + sensitiveVars, ok := hclProv.builderVariables["packer_sensitive_variables"].([]string) + if !ok { + t.Fatalf("expected []string packer_sensitive_variables, got %T", hclProv.builderVariables["packer_sensitive_variables"]) + } + + if len(sensitiveVars) != 1 || sensitiveVars[0] != "secret" { + t.Fatalf("expected sensitive vars [secret], got %#v", sensitiveVars) + } +} + +func TestParseProvisionerBlocks(t *testing.T) { + tests := []struct { + name string + blockContent string + wantCount int + wantTypes []string + wantErr bool + }{ + { + name: "single shell provisioner", + blockContent: ` +provisioner "shell" { + inline = ["echo 'Hello from enforced provisioner'"] +} +`, + wantCount: 1, + wantTypes: []string{"shell"}, + wantErr: false, + }, + { + name: "multiple provisioners", + blockContent: ` +provisioner "shell" { + inline = ["echo 'First enforced provisioner'"] +} + +provisioner "shell" { + name = "security-scan" + inline = ["echo 'Security scan running...'"] +} +`, + wantCount: 2, + wantTypes: []string{"shell", "shell"}, + wantErr: false, + }, + { + name: "provisioner with pause_before", + blockContent: ` +provisioner "shell" { + pause_before = "10s" + inline = ["echo 'Waiting before execution'"] +} +`, + wantCount: 1, + wantTypes: []string{"shell"}, + wantErr: false, + }, + { + name: "provisioner with max_retries", + blockContent: ` +provisioner "shell" { + max_retries = 3 + inline = ["echo 'Retry test'"] +} +`, + wantCount: 1, + wantTypes: []string{"shell"}, + wantErr: false, + }, + { + name: "provisioner with only filter", + blockContent: ` +provisioner "shell" { + only = ["amazon-ebs.ubuntu"] + inline = ["echo 'Only for amazon-ebs.ubuntu'"] +} +`, + wantCount: 1, + wantTypes: []string{"shell"}, + wantErr: false, + }, + { + name: "provisioner with except filter", + blockContent: ` +provisioner "shell" { + except = ["null.test"] + inline = ["echo 'Except for null.test'"] +} +`, + wantCount: 1, + wantTypes: []string{"shell"}, + wantErr: false, + }, + { + name: "provisioner with both only and except", + blockContent: ` +provisioner "shell" { + only = ["amazon-ebs.ubuntu"] + except = ["null.test"] + inline = ["echo 'invalid filter combination'"] +} +`, + wantCount: 0, + wantTypes: nil, + wantErr: true, + }, + { + name: "empty block content", + blockContent: "", + wantCount: 0, + wantTypes: nil, + wantErr: false, + }, + { + name: "whitespace only block content", + blockContent: "\n\n\t \n", + wantCount: 0, + wantTypes: nil, + wantErr: false, + }, + { + name: "invalid HCL syntax", + blockContent: "this is not valid { hcl }}}", + wantCount: 0, + 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 provisioner with escaped newline in string value", + blockContent: `{ + "provisioner": [ + { + "shell": { + "inline": ["echo first line\necho second line"] + } + } + ] +}`, + 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, + }, + { + name: "legacy json provisioners with escaped newline and multiple types", + blockContent: `{ + "provisioners": [ + { + "type": "shell", + "inline": ["echo legacy line 1\necho legacy line 2"] + }, + { + "type": "file", + "source": "source.txt", + "destination": "destination.txt" + } + ] +}`, + wantCount: 2, + wantTypes: []string{"shell", "file"}, + wantErr: false, + }, + { + name: "hcl provisioners with windows newlines", + blockContent: "provisioner \"shell\" {\r\n inline = [\"echo first\"]\r\n}\r\n\r\nprovisioner \"file\" {\r\n source = \"source.txt\"\r\n destination = \"destination.txt\"\r\n}\r\n", + wantCount: 2, + wantTypes: []string{"shell", "file"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blocks, diags := ParseProvisionerBlocks(tt.blockContent) + + if tt.wantErr { + if !diags.HasErrors() { + t.Errorf("ParseProvisionerBlocks() expected error but got none") + } + return + } + + if diags.HasErrors() { + t.Errorf("ParseProvisionerBlocks() unexpected error: %v", diags) + return + } + + if len(blocks) != tt.wantCount { + t.Errorf("ParseProvisionerBlocks() got %d blocks, want %d", len(blocks), tt.wantCount) + return + } + + for i, wantType := range tt.wantTypes { + if blocks[i].PType != wantType { + t.Errorf("ParseProvisionerBlocks() block[%d].PType = %q, want %q", i, blocks[i].PType, wantType) + } + } + }) + } +} + +func TestParseProvisionerBlocksWithPauseBefore(t *testing.T) { + blockContent := ` +provisioner "shell" { + pause_before = "30s" + inline = ["echo '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)) + } + + // pause_before should be parsed as 30 seconds + if blocks[0].PauseBefore.Seconds() != 30 { + t.Errorf("Expected PauseBefore=30s, got %v", blocks[0].PauseBefore) + } +} + +func TestParseProvisionerBlocksWithMaxRetries(t *testing.T) { + blockContent := ` +provisioner "shell" { + max_retries = 5 + inline = ["echo '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].MaxRetries != 5 { + t.Errorf("Expected MaxRetries=5, got %d", blocks[0].MaxRetries) + } +} + +func TestParseProvisionerBlocksWithOnlyExcept(t *testing.T) { + blockContent := ` +provisioner "shell" { + only = ["amazon-ebs.ubuntu", "azure-arm.windows"] + inline = ["echo '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)) + } + + // Check only filter + if len(blocks[0].OnlyExcept.Only) != 2 { + t.Errorf("Expected 2 only values, got %d", len(blocks[0].OnlyExcept.Only)) + } + + // Skip should return true for sources not in the only list + if !blocks[0].OnlyExcept.Skip("null.test") { + t.Error("Skip() should return true for source not in only list") + } + + // Skip should return false for sources in the only list + if blocks[0].OnlyExcept.Skip("amazon-ebs.ubuntu") { + 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") + } +} diff --git a/hcl2template/types.build.provisioners.go b/hcl2template/types.build.provisioners.go index ce1db8d89..143080df3 100644 --- a/hcl2template/types.build.provisioners.go +++ b/hcl2template/types.build.provisioners.go @@ -185,16 +185,7 @@ func (cfg *PackerConfig) startProvisioner(source SourceUseBlock, pb *Provisioner builderVars["packer_debug"] = strconv.FormatBool(cfg.debug) builderVars["packer_force"] = strconv.FormatBool(cfg.force) builderVars["packer_on_error"] = cfg.onError - - sensitiveVars := make([]string, 0, len(cfg.InputVariables)) - - for key, variable := range cfg.InputVariables { - if variable.Sensitive { - sensitiveVars = append(sensitiveVars, key) - } - } - - builderVars["packer_sensitive_variables"] = sensitiveVars + builderVars["packer_sensitive_variables"] = cfg.sensitiveInputVariableKeys() hclProvisioner := &HCL2Provisioner{ Provisioner: provisioner, diff --git a/hcl2template/types.hcl_provisioner.go b/hcl2template/types.hcl_provisioner.go index a6108acd1..a30b2fd91 100644 --- a/hcl2template/types.hcl_provisioner.go +++ b/hcl2template/types.hcl_provisioner.go @@ -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) diff --git a/hcl2template/types.packer_config.go b/hcl2template/types.packer_config.go index 6a97065fe..d170649b9 100644 --- a/hcl2template/types.packer_config.go +++ b/hcl2template/types.packer_config.go @@ -827,14 +827,8 @@ func (cfg *PackerConfig) GetBuilds(opts packer.GetBuildsOptions) ([]*packer.Core pcb.Provisioners = provisioners pcb.PostProcessors = pps pcb.Prepared = true - - pcb.SensitiveVars = make([]string, 0, len(cfg.InputVariables)) - - for key, variable := range cfg.InputVariables { - if variable.Sensitive { - pcb.SensitiveVars = append(pcb.SensitiveVars, key) - } - } + pcb.SetGeneratedVars(generatedVars) + pcb.SensitiveVars = cfg.sensitiveInputVariableKeys() // Prepare just sets the "prepareCalled" flag on CoreBuild, since // we did all the prep here. @@ -926,6 +920,18 @@ func (p *PackerConfig) printVariables() string { return out.String() } +func (cfg *PackerConfig) sensitiveInputVariableKeys() []string { + sensitiveVars := make([]string, 0, len(cfg.InputVariables)) + + for key, variable := range cfg.InputVariables { + if variable.Sensitive { + sensitiveVars = append(sensitiveVars, key) + } + } + + return sensitiveVars +} + func (p *PackerConfig) printBuilds() string { out := &strings.Builder{} out.WriteString("> builds:\n") diff --git a/internal/hcp/api/mock_service.go b/internal/hcp/api/mock_service.go index 4c75f1ebe..062e53403 100644 --- a/internal/hcp/api/mock_service.go +++ b/internal/hcp/api/mock_service.go @@ -25,6 +25,9 @@ type MockPackerClientService struct { UpdateChannelCalled bool TrackCalledServiceMethods bool + // Enforced block tracking + GetEnforcedBlocksByBucketCalled bool + // Mock Creates CreateBucketResp *hcpPackerModels.HashicorpCloudPacker20230101CreateBucketResponse CreateVersionResp *hcpPackerModels.HashicorpCloudPacker20230101CreateVersionResponse @@ -33,6 +36,10 @@ type MockPackerClientService struct { // Mock Gets GetVersionResp *hcpPackerModels.HashicorpCloudPacker20230101GetVersionResponse + // Mock enforced blocks + GetEnforcedBlocksByBucketResp *hcpPackerModels.HashicorpCloudPacker20230101GetEnforcedBlocksByBucketResponse + GetEnforcedBlocksByBucketErr error + ExistingBuilds []string ExistingBuildLabels map[string]string @@ -321,3 +328,28 @@ func (svc *MockPackerClientService) PackerServiceUpdateChannel( return ok, nil } + +func (svc *MockPackerClientService) PackerServiceGetEnforcedBlocksByBucket( + params *hcpPackerService.PackerServiceGetEnforcedBlocksByBucketParams, _ runtime.ClientAuthInfoWriter, + opts ...hcpPackerService.ClientOption, +) (*hcpPackerService.PackerServiceGetEnforcedBlocksByBucketOK, error) { + + if svc.TrackCalledServiceMethods { + svc.GetEnforcedBlocksByBucketCalled = true + } + + if svc.GetEnforcedBlocksByBucketErr != nil { + return nil, svc.GetEnforcedBlocksByBucketErr + } + + ok := &hcpPackerService.PackerServiceGetEnforcedBlocksByBucketOK{} + if svc.GetEnforcedBlocksByBucketResp != nil { + ok.Payload = svc.GetEnforcedBlocksByBucketResp + } else { + ok.Payload = &hcpPackerModels.HashicorpCloudPacker20230101GetEnforcedBlocksByBucketResponse{ + EnforcedBlockDetail: []*hcpPackerModels.HashicorpCloudPacker20230101EnforcedBlockDetail{}, + } + } + + return ok, nil +} diff --git a/internal/hcp/api/service_enforced_provisioner.go b/internal/hcp/api/service_enforced_provisioner.go new file mode 100644 index 000000000..2aebf1dc7 --- /dev/null +++ b/internal/hcp/api/service_enforced_provisioner.go @@ -0,0 +1,33 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package api + +import ( + "context" + + hcpPackerService "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/client/packer_service" + hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" +) + +// GetEnforcedBlocksForBucket fetches all enforced blocks linked to a bucket. +// This is the key method used during packer build to auto-inject provisioners. +// The response includes EnforcedBlockDetail entries each with an active version +// containing the raw HCL block_content to be parsed and injected. +func (c *Client) GetEnforcedBlocksForBucket( + ctx context.Context, + bucketName string, +) (*hcpPackerModels.HashicorpCloudPacker20230101GetEnforcedBlocksByBucketResponse, error) { + + params := hcpPackerService.NewPackerServiceGetEnforcedBlocksByBucketParamsWithContext(ctx) + params.LocationOrganizationID = c.OrganizationID + params.LocationProjectID = c.ProjectID + params.BucketName = bucketName + + resp, err := c.Packer.PackerServiceGetEnforcedBlocksByBucket(params, nil) + if err != nil { + return nil, err + } + + return resp.Payload, nil +} diff --git a/internal/hcp/registry/hcl.go b/internal/hcp/registry/hcl.go index 170371248..19eb14baa 100644 --- a/internal/hcp/registry/hcl.go +++ b/internal/hcp/registry/hcl.go @@ -91,6 +91,67 @@ func (h *HCLRegistry) VersionStatusSummary() { h.bucket.Version.statusSummary(h.ui) } +// FetchEnforcedBlocks fetches enforced provisioner blocks from HCP Packer +func (h *HCLRegistry) FetchEnforcedBlocks(ctx context.Context) error { + return h.bucket.FetchEnforcedBlocks(ctx) +} + +// InjectEnforcedProvisioners injects enforced provisioners into the builds +func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics { + enforcedBlocks := h.bucket.EnforcedBlocks + if len(enforcedBlocks) == 0 { + return nil + } + + var allDiags hcl.Diagnostics + + // Parse all enforced blocks into provisioner blocks + for _, eb := range enforcedBlocks { + if eb.BlockContent == "" { + continue + } + + provBlocks, diags := hcl2template.ParseProvisionerBlocks(eb.BlockContent) + if diags.HasErrors() { + allDiags = append(allDiags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + 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)) + } + + // Inject into each build + for _, build := range builds { + for _, pb := range provBlocks { + // Check if this provisioner should be skipped for this build + if pb.OnlyExcept.Skip(build.Type) { + log.Printf("[DEBUG] skipping enforced provisioner %q for build %q due to only/except rules", + pb.PType, build.Name()) + continue + } + + coreProv, moreDiags := h.configuration.GetCoreBuildProvisionerFromBlock(pb, build.Type) + if moreDiags.HasErrors() { + allDiags = append(allDiags, moreDiags...) + continue + } + + 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()) + } + } + } + + return allDiags +} + func NewHCLRegistry(config *hcl2template.PackerConfig, ui sdkpacker.Ui) (*HCLRegistry, hcl.Diagnostics) { var diags hcl.Diagnostics if len(config.Builds) > 1 { diff --git a/internal/hcp/registry/json.go b/internal/hcp/registry/json.go index a9f766e0b..9081e454b 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/hcl2template" "github.com/hashicorp/packer/packer" ) @@ -113,3 +114,85 @@ func (h *JSONRegistry) VersionStatusSummary() { func (h *JSONRegistry) Metadata() Metadata { return h.metadata } + +// FetchEnforcedBlocks fetches enforced provisioner blocks from HCP Packer +func (h *JSONRegistry) FetchEnforcedBlocks(ctx context.Context) error { + return h.bucket.FetchEnforcedBlocks(ctx) +} + +// InjectEnforcedProvisioners injects enforced provisioners into the builds +func (h *JSONRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics { + enforcedBlocks := h.bucket.EnforcedBlocks + if len(enforcedBlocks) == 0 { + return nil + } + + var allDiags hcl.Diagnostics + + for _, eb := range enforcedBlocks { + if eb.BlockContent == "" { + continue + } + + provBlocks, diags := hcl2template.ParseProvisionerBlocks(eb.BlockContent) + if diags.HasErrors() { + allDiags = append(allDiags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + 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 + injectedProvisioners := 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) + injectedProvisioners = append(injectedProvisioners, coreProv) + + log.Printf("[INFO] injected enforced provisioner %q from block %q into legacy JSON build %q", + pb.PType, eb.Name, build.Name()) + } + + if len(injectedProvisioners) == 0 { + continue + } + + if err := build.PrepareProvisioners(injectedProvisioners...); 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 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..904c3f04f --- /dev/null +++ b/internal/hcp/registry/json_enforced_test.go @@ -0,0 +1,122 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package registry + +import ( + "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() + + 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) + bucket := NewBucketWithVersion() + bucket.Name = "test-bucket" + + registry := &JSONRegistry{ + configuration: core, + bucket: bucket, + ui: packer.TestUi(t), + metadata: &MetadataStore{}, + } + + 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"]) + } +} diff --git a/internal/hcp/registry/null_registry.go b/internal/hcp/registry/null_registry.go index 6856dec52..285a1cd58 100644 --- a/internal/hcp/registry/null_registry.go +++ b/internal/hcp/registry/null_registry.go @@ -6,6 +6,7 @@ package registry import ( "context" + "github.com/hashicorp/hcl/v2" sdkpacker "github.com/hashicorp/packer-plugin-sdk/packer" "github.com/hashicorp/packer/packer" ) @@ -35,3 +36,11 @@ func (r nullRegistry) VersionStatusSummary() {} func (r nullRegistry) Metadata() Metadata { return NilMetadata{} } + +func (r nullRegistry) FetchEnforcedBlocks(ctx context.Context) error { + return nil +} + +func (r nullRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics { + return nil +} diff --git a/internal/hcp/registry/registry.go b/internal/hcp/registry/registry.go index e77ac55d7..43162bd73 100644 --- a/internal/hcp/registry/registry.go +++ b/internal/hcp/registry/registry.go @@ -20,6 +20,10 @@ type Registry interface { CompleteBuild(ctx context.Context, build *packer.CoreBuild, artifacts []sdkpacker.Artifact, buildErr error) ([]sdkpacker.Artifact, error) VersionStatusSummary() Metadata() Metadata + // FetchEnforcedBlocks fetches enforced provisioner blocks from HCP Packer + FetchEnforcedBlocks(ctx context.Context) error + // InjectEnforcedProvisioners injects enforced provisioners into the builds + InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics } // New instantiates the appropriate registry for the Packer configuration template type. diff --git a/internal/hcp/registry/types.bucket.go b/internal/hcp/registry/types.bucket.go index 26a54bc62..5195aef86 100644 --- a/internal/hcp/registry/types.bucket.go +++ b/internal/hcp/registry/types.bucket.go @@ -29,6 +29,16 @@ import ( // build is still alive. const HeartbeatPeriod = 2 * time.Minute +// EnforcedBlock represents an enforced provisioner block from HCP Packer +type EnforcedBlock struct { + ID string + Name string + BlockContent string // Raw HCL content containing provisioner blocks + VersionID string + Version string + TemplateType string +} + // Bucket represents a single bucket on the HCP Packer registry. type Bucket struct { Name string @@ -40,6 +50,7 @@ type Bucket struct { SourceExternalIdentifierToParentVersions map[string]ParentVersion RunningBuilds map[string]chan struct{} Version *Version + EnforcedBlocks []*EnforcedBlock client *hcpPackerAPI.Client } @@ -142,6 +153,63 @@ func (bucket *Bucket) Initialize( return bucket.initializeVersion(ctx, templateType) } +// FetchEnforcedBlocks retrieves all enforced blocks linked to this bucket from HCP Packer. +// These blocks contain provisioner configurations that should be automatically injected +// into builds for this bucket. +func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context) error { + if bucket.client == nil { + 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 hcpPackerAPI.CheckErrorCode(err, codes.NotFound) || hcpPackerAPI.CheckErrorCode(err, codes.Unimplemented) { + // If the API doesn't support enforced blocks yet or returns not found, continue silently. + log.Printf("[DEBUG] fetching enforced blocks for bucket %q: %v", bucket.Name, err) + return nil + } + + return fmt.Errorf("failed fetching enforced blocks for bucket %q: %w", bucket.Name, err) + } + + if resp == nil { + log.Printf("[INFO] no enforced blocks response returned for bucket %q", bucket.Name) + return nil + } + + bucket.EnforcedBlocks = make([]*EnforcedBlock, 0, len(resp.EnforcedBlockDetail)) + for _, detail := range resp.EnforcedBlockDetail { + if detail == nil || detail.Version == nil { + continue + } + + block := &EnforcedBlock{ + ID: detail.ID, + Name: detail.Name, + BlockContent: detail.Version.BlockContent, + 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) linked to bucket %q", len(bucket.EnforcedBlocks), bucket.Name) + return nil +} + func (bucket *Bucket) RegisterBuildForComponent(sourceName string) { if bucket == nil { return diff --git a/internal/hcp/registry/types.bucket_enforced_test.go b/internal/hcp/registry/types.bucket_enforced_test.go new file mode 100644 index 000000000..fa9852c67 --- /dev/null +++ b/internal/hcp/registry/types.bucket_enforced_test.go @@ -0,0 +1,117 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package registry + +import ( + "context" + "errors" + "fmt" + "testing" + + hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" + hcpPackerAPI "github.com/hashicorp/packer/internal/hcp/api" + "google.golang.org/grpc/codes" +) + +func TestBucket_FetchEnforcedBlocks_ReturnsAllBlocks(t *testing.T) { + hcl2Type := hcpPackerModels.HashicorpCloudPacker20230101TemplateTypeHCL2 + jsonType := hcpPackerModels.HashicorpCloudPacker20230101TemplateTypeJSON + + mockService := hcpPackerAPI.NewMockPackerClientService() + mockService.GetEnforcedBlocksByBucketResp = &hcpPackerModels.HashicorpCloudPacker20230101GetEnforcedBlocksByBucketResponse{ + EnforcedBlockDetail: []*hcpPackerModels.HashicorpCloudPacker20230101EnforcedBlockDetail{ + { + ID: "hcl-id", + Name: "hcl-block", + Version: &hcpPackerModels.HashicorpCloudPacker20230101EnforcedBlockVersion{ + ID: "hcl-v1", + Version: "1", + BlockContent: "provisioner \"shell\" {}", + TemplateType: &hcl2Type, + }, + }, + { + ID: "json-id", + Name: "json-block", + Version: &hcpPackerModels.HashicorpCloudPacker20230101EnforcedBlockVersion{ + ID: "json-v1", + Version: "1", + BlockContent: "{\"provisioner\":[{\"shell\":{}}]}", + TemplateType: &jsonType, + }, + }, + { + ID: "unset-id", + Name: "unset-block", + Version: &hcpPackerModels.HashicorpCloudPacker20230101EnforcedBlockVersion{ + ID: "unset-v1", + Version: "1", + BlockContent: "provisioner \"shell\" {}", + }, + }, + }, + } + + bucket := &Bucket{ + Name: "test-bucket", + client: &hcpPackerAPI.Client{ + Packer: mockService, + }, + } + + err := bucket.FetchEnforcedBlocks(context.Background()) + if err != nil { + t.Fatalf("FetchEnforcedBlocks() unexpected error: %v", err) + } + + if len(bucket.EnforcedBlocks) != 3 { + t.Fatalf("FetchEnforcedBlocks() got %d blocks, want 3", len(bucket.EnforcedBlocks)) + } + + if bucket.EnforcedBlocks[0].Name != "hcl-block" { + t.Fatalf("first block name = %q, want %q", bucket.EnforcedBlocks[0].Name, "hcl-block") + } + + if bucket.EnforcedBlocks[1].Name != "json-block" { + t.Fatalf("second block name = %q, want %q", bucket.EnforcedBlocks[1].Name, "json-block") + } + + if bucket.EnforcedBlocks[2].Name != "unset-block" { + t.Fatalf("third block name = %q, want %q", bucket.EnforcedBlocks[2].Name, "unset-block") + } +} + +func TestBucket_FetchEnforcedBlocks_ReturnsErrorOnServiceFailure(t *testing.T) { + mockService := hcpPackerAPI.NewMockPackerClientService() + mockService.GetEnforcedBlocksByBucketErr = errors.New("service unavailable") + + bucket := &Bucket{ + Name: "test-bucket", + client: &hcpPackerAPI.Client{ + Packer: mockService, + }, + } + + err := bucket.FetchEnforcedBlocks(context.Background()) + if err == nil { + t.Fatal("FetchEnforcedBlocks() expected error, got nil") + } +} + +func TestBucket_FetchEnforcedBlocks_NotFoundIsNonFatal(t *testing.T) { + mockService := hcpPackerAPI.NewMockPackerClientService() + mockService.GetEnforcedBlocksByBucketErr = fmt.Errorf("Code:%d %s", codes.NotFound, codes.NotFound.String()) + + bucket := &Bucket{ + Name: "test-bucket", + client: &hcpPackerAPI.Client{ + Packer: mockService, + }, + } + + err := bucket.FetchEnforcedBlocks(context.Background()) + if err != nil { + t.Fatalf("FetchEnforcedBlocks() expected nil error for NotFound, got: %v", err) + } +} diff --git a/packer/build.go b/packer/build.go index 27b8ae2e4..de476ec67 100644 --- a/packer/build.go +++ b/packer/build.go @@ -52,6 +52,7 @@ type CoreBuild struct { onError string l sync.Mutex prepareCalled bool + generatedVars []string SBOMs []SBOM } @@ -145,6 +146,63 @@ 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)+2) + copy(configs, coreProv.config) + configs = append(configs, packerConfig, 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 +} + +// SetGeneratedVars stores the builder-generated variables from the initial +// builder preparation so late-injected provisioners can reuse them without +// invoking Builder.Prepare again. +func (b *CoreBuild) SetGeneratedVars(generatedVars []string) { + b.generatedVars = append([]string(nil), generatedVars...) +} + +// PrepareProvisioners prepares provisioners injected after the build itself has already been prepared. +func (b *CoreBuild) PrepareProvisioners(provisioners ...CoreBuildProvisioner) error { + if !b.prepareCalled { + return fmt.Errorf("Prepare must be called first") + } + + packerConfig := b.packerConfig() + return b.prepareProvisioners(provisioners, packerConfig, b.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 +225,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) @@ -185,35 +233,19 @@ func (b *CoreBuild) Prepare() (warn []string, err error) { log.Printf("Build '%s' prepare failure: %s\n", b.Type, err) return } + b.SetGeneratedVars(generatedVars) - // 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 != "" { - configs := make([]interface{}, len(b.CleanupProvisioner.config), len(b.CleanupProvisioner.config)+1) + configs := make([]interface{}, len(b.CleanupProvisioner.config), len(b.CleanupProvisioner.config)+2) copy(configs, b.CleanupProvisioner.config) - configs = append(configs, packerConfig) - configs = append(configs, generatedPlaceholderMap) + configs = append(configs, packerConfig, generatedPlaceholderMap) err = b.CleanupProvisioner.Provisioner.Prepare(configs...) if err != nil { return diff --git a/packer/build_test.go b/packer/build_test.go index 572f9f848..8d1d89aae 100644 --- a/packer/build_test.go +++ b/packer/build_test.go @@ -230,6 +230,92 @@ func TestBuildPrepare_ProvisionerGetsGeneratedMap(t *testing.T) { } } +func TestBuild_PrepareProvisioners_ReusesStoredGeneratedVars(t *testing.T) { + packerConfig := testDefaultPackerConfig() + + build := testBuild() + builder := build.Builder.(*packersdk.MockBuilder) + builder.GeneratedVars = []string{"PartyVar"} + + if _, err := build.Prepare(); err != nil { + t.Fatalf("bad error: %s", err) + } + + builder.PrepareCalled = false + + lateProv := CoreBuildProvisioner{ + PType: "mock-provisioner", + Provisioner: &packersdk.MockProvisioner{}, + config: []interface{}{84}, + } + + if err := build.PrepareProvisioners(lateProv); err != nil { + t.Fatalf("bad error: %s", err) + } + + if builder.PrepareCalled { + t.Fatal("builder prepare should not be called again") + } + + prov := lateProv.Provisioner.(*packersdk.MockProvisioner) + generated := BasicPlaceholderData() + generated["PartyVar"] = "Build_PartyVar. " + packerbuilderdata.PlaceholderMsg + if !reflect.DeepEqual(prov.PrepConfigs, []interface{}{84, packerConfig, generated}) { + t.Fatalf("bad: %#v", prov.PrepConfigs) + } +} + +func TestBuild_PrepareProvisioners_ReusesStoredGeneratedVarsForPreparedBuild(t *testing.T) { + packerConfig := testDefaultPackerConfig() + + build := testBuild() + builder := build.Builder.(*packersdk.MockBuilder) + build.Prepared = true + build.SetGeneratedVars([]string{"PartyVar"}) + + if _, err := build.Prepare(); err != nil { + t.Fatalf("bad error: %s", err) + } + + lateProv := CoreBuildProvisioner{ + PType: "mock-provisioner", + Provisioner: &packersdk.MockProvisioner{}, + config: []interface{}{84}, + } + + if err := build.PrepareProvisioners(lateProv); err != nil { + t.Fatalf("bad error: %s", err) + } + + if builder.PrepareCalled { + t.Fatal("builder prepare should not be called for prepared builds") + } + + prov := lateProv.Provisioner.(*packersdk.MockProvisioner) + generated := BasicPlaceholderData() + generated["PartyVar"] = "Build_PartyVar. " + packerbuilderdata.PlaceholderMsg + if !reflect.DeepEqual(prov.PrepConfigs, []interface{}{84, packerConfig, generated}) { + t.Fatalf("bad: %#v", prov.PrepConfigs) + } +} + +func TestBuild_PrepareProvisioners_RequiresPrepare(t *testing.T) { + build := testBuild() + lateProv := CoreBuildProvisioner{ + PType: "mock-provisioner", + Provisioner: &packersdk.MockProvisioner{}, + config: []interface{}{84}, + } + + err := build.PrepareProvisioners(lateProv) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "Prepare must be called first" { + t.Fatalf("bad error: %s", err) + } +} + func TestBuild_Run(t *testing.T) { ui := testUi() diff --git a/packer/core.go b/packer/core.go index ad3fc7356..343e26c3b 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/packer/provisioner.go b/packer/provisioner.go index 7f243a880..0c5da2289 100644 --- a/packer/provisioner.go +++ b/packer/provisioner.go @@ -139,6 +139,43 @@ func (h *ProvisionHook) Run(ctx context.Context, name string, ui packersdk.Ui, c return nil } +// ProvisionerWrapOptions contains options for wrapping a provisioner with +// additional behavior like pausing, timeouts, and retries. +type ProvisionerWrapOptions struct { + PauseBefore time.Duration + Timeout time.Duration + MaxRetries int +} + +// WrapProvisionerWithOptions wraps a provisioner with additional behavior +// based on the provided options. +func WrapProvisionerWithOptions(provisioner packersdk.Provisioner, opts ProvisionerWrapOptions) packersdk.Provisioner { + wrapped := provisioner + + if opts.PauseBefore != 0 { + wrapped = &PausedProvisioner{ + PauseBefore: opts.PauseBefore, + Provisioner: wrapped, + } + } + + if opts.Timeout != 0 { + wrapped = &TimeoutProvisioner{ + Timeout: opts.Timeout, + Provisioner: wrapped, + } + } + + if opts.MaxRetries != 0 { + wrapped = &RetriedProvisioner{ + MaxRetries: opts.MaxRetries, + Provisioner: wrapped, + } + } + + return wrapped +} + // PausedProvisioner is a Provisioner implementation that pauses before // the provisioner is actually run. type PausedProvisioner struct {