diff --git a/hcl2template/enforced_provisioner.go b/hcl2template/enforced_provisioner.go index 9602257fa..d940a8ead 100644 --- a/hcl2template/enforced_provisioner.go +++ b/hcl2template/enforced_provisioner.go @@ -4,7 +4,9 @@ package hcl2template import ( + "encoding/json" "fmt" + "log" "strconv" "github.com/hashicorp/hcl/v2" @@ -19,15 +21,104 @@ var enforcedProvisionerSchema = &hcl.BodySchema{ }, } -// ParseProvisionerBlocks parses a partial HCL string that contains only -// top-level provisioner blocks and returns the parsed ProvisionerBlock list. +// ParseProvisionerBlocks parses a string containing one or more top-level provisioner blocks +// in either HCL or JSON syntax, and returns a slice of parsed ProvisionerBlock objects along +// with any diagnostics encountered during parsing. func ParseProvisionerBlocks(blockContent string) ([]*ProvisionerBlock, hcl.Diagnostics) { parser := &Parser{Parser: hclparse.NewParser()} + log.Printf("[DEBUG] parsing enforced provisioner block content as HCL") + file, diags := parser.ParseHCL([]byte(blockContent), "enforced_provisioner.pkr.hcl") - if diags.HasErrors() { - return nil, diags + if !diags.HasErrors() { + log.Printf("[DEBUG] parsed enforced provisioner block content as HCL") + return parseProvisionerBlocksFromFile(parser, file, diags) + } + log.Printf("[DEBUG] failed to parse enforced provisioner block content as HCL, trying JSON fallback") + + // Fallback to HCL-JSON for enforced block content authored in JSON syntax. + jsonFile, jsonDiags := parser.ParseJSON([]byte(blockContent), "enforced_provisioner.pkr.json") + if jsonDiags.HasErrors() { + log.Printf("[DEBUG] failed to parse enforced provisioner block content as JSON") + return nil, append(diags, jsonDiags...) + } + + provisioners, provisionerDiags := parseProvisionerBlocksFromFile(parser, jsonFile, jsonDiags) + if !provisionerDiags.HasErrors() && len(provisioners) > 0 { + log.Printf("[DEBUG] parsed enforced provisioner block content as JSON") + return provisioners, provisionerDiags + } + + // Backward compatibility fallback for legacy JSON shape: + // {"provisioners":[{"type":"shell", ...}]} + legacyJSON, ok, err := normalizeLegacyEnforcedProvisionersJSON(blockContent) + if err == nil && ok { + legacyFile, legacyDiags := parser.ParseJSON([]byte(legacyJSON), "enforced_provisioner_legacy.pkr.json") + if !legacyDiags.HasErrors() { + legacyProvisioners, legacyProvisionerDiags := parseProvisionerBlocksFromFile(parser, legacyFile, legacyDiags) + if !legacyProvisionerDiags.HasErrors() && len(legacyProvisioners) > 0 { + log.Printf("[DEBUG] parsed enforced provisioner block content as legacy JSON") + return legacyProvisioners, legacyProvisionerDiags + } + } + } + + if provisionerDiags.HasErrors() { + return nil, provisionerDiags + } + log.Printf("[DEBUG] parsed enforced provisioner block content as JSON but found no valid provisioner blocks") + return provisioners, provisionerDiags +} + +func normalizeLegacyEnforcedProvisionersJSON(blockContent string) (string, bool, error) { + type legacyPayload struct { + Provisioners []map[string]interface{} `json:"provisioners"` + } + + var payload legacyPayload + if err := json.Unmarshal([]byte(blockContent), &payload); err != nil { + return "", false, err + } + + if len(payload.Provisioners) == 0 { + return "", false, nil + } + + normalized := make([]map[string]interface{}, 0, len(payload.Provisioners)) + for _, p := range payload.Provisioners { + typeName, ok := p["type"].(string) + if !ok || typeName == "" { + continue + } + + cfg := make(map[string]interface{}) + for k, v := range p { + if k == "type" { + continue + } + cfg[k] = v + } + + normalized = append(normalized, map[string]interface{}{typeName: cfg}) + } + + if len(normalized) == 0 { + return "", false, nil + } + + out := map[string]interface{}{ + "provisioner": normalized, } + b, err := json.Marshal(out) + if err != nil { + return "", false, err + } + + return string(b), true, nil +} + +func parseProvisionerBlocksFromFile(parser *Parser, file *hcl.File, diags hcl.Diagnostics) ([]*ProvisionerBlock, hcl.Diagnostics) { + content, moreDiags := file.Body.Content(enforcedProvisionerSchema) diags = append(diags, moreDiags...) if diags.HasErrors() { diff --git a/hcl2template/enforced_provisioner_test.go b/hcl2template/enforced_provisioner_test.go index ed79b7480..7c2d81acf 100644 --- a/hcl2template/enforced_provisioner_test.go +++ b/hcl2template/enforced_provisioner_test.go @@ -104,6 +104,63 @@ provisioner "shell" { wantTypes: nil, wantErr: true, }, + { + name: "json single shell provisioner", + blockContent: `{ + "provisioner": [ + { + "shell": { + "inline": ["echo 'Hello from enforced provisioner JSON'"] + } + } + ] +}`, + wantCount: 1, + wantTypes: []string{"shell"}, + wantErr: false, + }, + { + name: "json multiple provisioners", + blockContent: `{ + "provisioner": [ + { + "shell": { + "inline": ["echo 'first'"] + } + }, + { + "shell": { + "name": "security-scan", + "inline": ["echo 'second'"] + } + } + ] +}`, + wantCount: 2, + wantTypes: []string{"shell", "shell"}, + wantErr: false, + }, + { + name: "invalid json syntax", + blockContent: `{"provisioner": [ { "shell": { "inline": ["test"] } ] }`, + wantCount: 0, + wantTypes: nil, + wantErr: true, + }, + { + name: "legacy json provisioners format", + blockContent: `{ + "provisioners": [ + { + "type": "shell", + "inline": ["echo legacy json format"] + } + ] +}`, + wantCount: 1, + wantTypes: []string{"shell"}, + wantErr: false, + }, } for _, tt := range tests { @@ -210,3 +267,43 @@ provisioner "shell" { t.Error("Skip() should return false for source in only list") } } + +func TestParseProvisionerBlocksJSONWithOptions(t *testing.T) { + blockContent := `{ + "provisioner": [ + { + "shell": { + "pause_before": "15s", + "max_retries": 2, + "only": ["docker.ubuntu"], + "inline": ["echo 'json test'"] + } + } + ] +}` + + blocks, diags := ParseProvisionerBlocks(blockContent) + if diags.HasErrors() { + t.Fatalf("ParseProvisionerBlocks() unexpected error: %v", diags) + } + + if len(blocks) != 1 { + t.Fatalf("Expected 1 block, got %d", len(blocks)) + } + + if blocks[0].PauseBefore.Seconds() != 15 { + t.Errorf("Expected PauseBefore=15s, got %v", blocks[0].PauseBefore) + } + + if blocks[0].MaxRetries != 2 { + t.Errorf("Expected MaxRetries=2, got %d", blocks[0].MaxRetries) + } + + if blocks[0].OnlyExcept.Skip("docker.ubuntu") { + t.Error("Skip() should return false for source in only list") + } + + if !blocks[0].OnlyExcept.Skip("null.test") { + t.Error("Skip() should return true for source not in only list") + } +} diff --git a/hcl2template/types.build.hcp_packer_registry.go b/hcl2template/types.build.hcp_packer_registry.go index 392aea935..f8339d8cc 100644 --- a/hcl2template/types.build.hcp_packer_registry.go +++ b/hcl2template/types.build.hcp_packer_registry.go @@ -10,7 +10,8 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" - "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/zclconf/go-cty/cty" ) type HCPPackerRegistryBlock struct { @@ -100,42 +101,108 @@ func (p *Parser) decodeHCPRegistry(block *hcl.Block, cfg *PackerConfig) (*HCPPac return par, diags } -// ExtractBuildProvisionerHCL extracts all provisioner blocks from the build -// blocks in the configuration and returns them as raw HCL content. -// This is used to publish provisioner configurations as enforced blocks -// to HCP Packer, so that other builds against the same bucket will -// automatically have these provisioners injected. +// ExtractBuildProvisionerHCL extracts inline commands from all shell +// provisioner blocks across every build block and merges them into a single +// provisioner "shell" block. This merged block is what gets published to +// HCP Packer as an enforced block so that other builds against the same +// bucket automatically run these commands. func (cfg *PackerConfig) ExtractBuildProvisionerHCL() (string, error) { sourceFiles := cfg.parser.Files() - var buf strings.Builder + // Re-parse source files with a fresh parser so the bodies are unconsumed. + freshParser := hclparse.NewParser() + + buildSchema := &hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + {Type: buildLabel}, + }, + } + provisionerSchema := &hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + {Type: buildProvisionerLabel, LabelNames: []string{"type"}}, + }, + } + inlineSchema := &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + {Name: "inline"}, + }, + } + + var allCommands []string for filename, file := range sourceFiles { - // hclwrite only supports HCL native syntax, skip JSON and variable files if !strings.HasSuffix(filename, hcl2FileExt) { continue } - wf, diags := hclwrite.ParseConfig(file.Bytes, filename, hcl.Pos{Line: 1, Column: 1}) + f, diags := freshParser.ParseHCL(file.Bytes, filename) if diags.HasErrors() { continue } - for _, block := range wf.Body().Blocks() { - if block.Type() != buildLabel { + content, _, diags := f.Body.PartialContent(buildSchema) + if diags.HasErrors() { + continue + } + + for _, buildBlock := range content.Blocks { + innerContent, _, diags := buildBlock.Body.PartialContent(provisionerSchema) + if diags.HasErrors() { continue } - for _, inner := range block.Body().Blocks() { - if inner.Type() != buildProvisionerLabel { + for _, provBlock := range innerContent.Blocks { + if provBlock.Labels[0] != "shell" { continue } - buf.Write(inner.BuildTokens(nil).Bytes()) - buf.WriteString("\n") + attrContent, _, diags := provBlock.Body.PartialContent(inlineSchema) + if diags.HasErrors() { + continue + } + + inlineAttr, ok := attrContent.Attributes["inline"] + if !ok { + continue + } + + val, diags := inlineAttr.Expr.Value(nil) + if diags.HasErrors() { + continue + } + + if !val.CanIterateElements() { + continue + } + + it := val.ElementIterator() + for it.Next() { + _, v := it.Element() + if v.Type() == cty.String { + allCommands = append(allCommands, v.AsString()) + } + } } } } - return strings.TrimSpace(buf.String()), nil + if len(allCommands) == 0 { + return "", nil + } + + // Build a single merged provisioner "shell" block with all commands. + var buf strings.Builder + buf.WriteString(`provisioner "shell" {` + "\n") + buf.WriteString(" inline = [\n") + for i, cmd := range allCommands { + buf.WriteString(fmt.Sprintf(" %q", cmd)) + if i < len(allCommands)-1 { + buf.WriteString(",") + } + buf.WriteString("\n") + } + buf.WriteString(" ]\n") + buf.WriteString("}\n") + + return buf.String(), nil } 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/internal/hcp/registry/hcl.go b/internal/hcp/registry/hcl.go index 8560090d2..de45e7a44 100644 --- a/internal/hcp/registry/hcl.go +++ b/internal/hcp/registry/hcl.go @@ -43,21 +43,6 @@ func (h *HCLRegistry) PopulateVersion(ctx context.Context) error { return err } - // Extract provisioner blocks from the build and publish them as enforced - // blocks to HCP Packer, so other builds against the same bucket will - // automatically have these provisioners injected. - blockContent, err := h.configuration.ExtractBuildProvisionerHCL() - if err != nil { - log.Printf("[WARN] failed to extract provisioner blocks for enforced publishing: %v", err) - } else if blockContent != "" { - blockName := h.bucket.Name + "-provisioners" - if pubErr := h.bucket.PublishEnforcedBlocks( - ctx, blockName, blockContent, hcpPackerModels.HashicorpCloudPacker20230101TemplateTypeHCL2, - ); pubErr != nil { - log.Printf("[WARN] failed to publish enforced blocks for bucket %q: %v", h.bucket.Name, pubErr) - } - } - err = h.bucket.populateVersion(ctx) if err != nil { return err @@ -137,7 +122,7 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl } if len(provBlocks) > 0 { - h.ui.Say(fmt.Sprintf("Loaded %d enforced provisioner(s) from HCP block %q", len(provBlocks), eb.Name)) + h.ui.Say(fmt.Sprintf("Loaded %d enforced provisioner(s) from HCP block %q and template type %q", len(provBlocks), eb.Name, eb.TemplateType)) } // Inject into each build @@ -156,10 +141,10 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl continue } - log.Printf("[INFO] injecting enforced provisioner %q from block %q into build %q", - pb.PType, eb.Name, build.Name()) - build.Provisioners = append(build.Provisioners, coreProv) + + log.Printf("[INFO] injected enforced provisioner %q from block %q into build %q", + pb.PType, eb.Name, build.Name()) } } } diff --git a/internal/hcp/registry/types.bucket.go b/internal/hcp/registry/types.bucket.go index 9e0fdb971..e755a396c 100644 --- a/internal/hcp/registry/types.bucket.go +++ b/internal/hcp/registry/types.bucket.go @@ -36,6 +36,7 @@ type EnforcedBlock struct { BlockContent string // Raw HCL content containing provisioner blocks VersionID string Version string + TemplateType string } // Bucket represents a single bucket on the HCP Packer registry. @@ -160,6 +161,8 @@ func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context) error { return errors.New("bucket client not initialized, call Initialize first") } + log.Printf("[INFO] fetching enforced blocks linked to bucket %q", bucket.Name) + resp, err := bucket.client.GetEnforcedBlocksForBucket(ctx, bucket.Name) if err != nil { // If the API doesn't support enforced blocks yet or returns not found, continue silently @@ -168,6 +171,7 @@ func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context) error { } if resp == nil { + log.Printf("[INFO] no enforced blocks response returned for bucket %q", bucket.Name) return nil } @@ -184,10 +188,20 @@ func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context) error { VersionID: detail.Version.ID, Version: detail.Version.Version, } + + if detail.Version.TemplateType != nil { + block.TemplateType = string(*detail.Version.TemplateType) + } bucket.EnforcedBlocks = append(bucket.EnforcedBlocks, block) + log.Printf("[INFO] linked enforced block found for bucket %q: name=%q id=%q version=%q", + bucket.Name, block.Name, block.ID, block.Version) + } + + if len(bucket.EnforcedBlocks) == 0 { + log.Printf("[INFO] no enforced provisioner blocks linked to bucket %q", bucket.Name) } - log.Printf("[INFO] fetched %d enforced block(s) for bucket %q", len(bucket.EnforcedBlocks), bucket.Name) + log.Printf("[INFO] fetched %d enforced block(s) linked to bucket %q", len(bucket.EnforcedBlocks), bucket.Name) return nil } @@ -247,15 +261,8 @@ func (bucket *Bucket) PublishEnforcedBlocks( } log.Printf("[INFO] created new version for enforced block %q", blockName) } else { - // Create new enforced block - log.Printf("[INFO] creating enforced block %q for bucket %q", blockName, bucket.Name) - _, err := bucket.client.CreateEnforcedBlock( - ctx, blockName, blockContent, version, templateType, "", nil, - ) - if err != nil { - return fmt.Errorf("failed to create enforced block %q: %w", blockName, err) - } - log.Printf("[INFO] created enforced block %q", blockName) + // Requirement: do not create enforced blocks from CLI. + log.Printf("[INFO] enforced block %q does not exist; skipping creation for bucket %q", blockName, bucket.Name) } return nil