feat(hcp): wire enforced provisioners vNext on the Packer CLI

Resolve and apply a bucket's enforced provisioner set at build
start via the HCP Packer resolver, honoring the mandatory/advisory
failure matrix with on-disk cache revalidation (If-None-Match) and
fail-closed behavior on mandatory buckets.

Add build-time --skip-enforcement with a closed reason-code enum,
client-side hard-limit guardrails (<=128 KiB block_content,
<=25 provisioners per bucket; mandatory fails closed, advisory warns
and drops), and record the resolution context into build metadata
for audit/integrity
pkr/enforced-provisioners-vnext
John R Brown 2 weeks ago
parent 2f86cc4001
commit 9fc9bca022

@ -15,10 +15,12 @@ import (
"sync"
"time"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/hcl/v2"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/hashicorp/packer/internal/hcp/registry"
"github.com/hashicorp/packer/packer"
"github.com/hashicorp/packer/version"
"golang.org/x/sync/semaphore"
"github.com/hako/durafmt"
@ -150,12 +152,49 @@ 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 {
// Resolve and inject enforced provisioners from HCP Packer (RFC vNext).
if cla.SkipEnforcement {
// Skip governance (RFC 10): a closed-enum reason code is required.
if cla.SkipReasonCode == "" {
return writeDiags(c.Ui, nil, hcl.Diagnostics{
&hcl.Diagnostic{
Summary: "HCP: fetching enforced provisioners failed",
Severity: hcl.DiagError,
Summary: "HCP: --skip-enforcement requires --skip-reason-code",
Detail: fmt.Sprintf("--skip-enforcement must be accompanied by --skip-reason-code=<code>, one of: %s.",
strings.Join(registry.ValidSkipReasonCodes, ", ")),
},
})
}
if !registry.IsValidSkipReasonCode(cla.SkipReasonCode) {
return writeDiags(c.Ui, nil, hcl.Diagnostics{
&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "HCP: invalid --skip-reason-code",
Detail: fmt.Sprintf("%q is not a valid skip reason code. Must be one of: %s.",
cla.SkipReasonCode, strings.Join(registry.ValidSkipReasonCodes, ", ")),
},
})
}
hcpRegistry.RecordEnforcementSkip(cla.SkipReasonCode, cla.SkipReasonNote)
c.Ui.Say(fmt.Sprintf("Skipping HCP Packer enforced provisioners (--skip-enforcement; reason_code=%s).", cla.SkipReasonCode))
if cla.SkipReasonNote != "" {
c.Ui.Say(fmt.Sprintf(" reason note: %s", cla.SkipReasonNote))
}
} else {
buildCorrelationID, err := uuid.GenerateUUID()
if err != nil {
buildCorrelationID = ""
}
opts := registry.EnforcementOptions{
CLIVersion: version.Version,
BuildCorrelationID: buildCorrelationID,
}
if err := hcpRegistry.FetchEnforcedBlocks(buildCtx, opts); err != nil {
return writeDiags(c.Ui, nil, hcl.Diagnostics{
&hcl.Diagnostic{
Summary: "HCP: resolving enforced provisioners failed",
Severity: hcl.DiagError,
Detail: err.Error(),
},
@ -166,8 +205,6 @@ func (c *BuildCommand) RunContext(buildCtx context.Context, cla *BuildArgs) int
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 {
@ -476,7 +513,9 @@ 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.
-skip-enforcement Skip injection of HCP Packer enforced provisioners. Requires admin privileges and -skip-reason-code.
-skip-reason-code=code Reason code required with -skip-enforcement. One of: breakglass_incident, resolver_outage, verified_exception, migration_compatibility.
-skip-reason-note=text Optional free-text note accompanying -skip-reason-code.
`
return strings.TrimSpace(helpText)

@ -101,7 +101,9 @@ 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.")
flags.BoolVar(&ba.SkipEnforcement, "skip-enforcement", false, "Skip injection of HCP Packer enforced provisioners. Requires admin privileges and --skip-reason-code.")
flags.StringVar(&ba.SkipReasonCode, "skip-reason-code", "", "Reason code required with --skip-enforcement. One of: breakglass_incident, resolver_outage, verified_exception, migration_compatibility.")
flags.StringVar(&ba.SkipReasonNote, "skip-reason-note", "", "Optional free-text note accompanying --skip-reason-code.")
ba.MetaArgs.AddFlagSets(flags)
}
@ -139,6 +141,8 @@ type BuildArgs struct {
OnError string
ReleaseOnly bool
SkipEnforcement bool
SkipReasonCode string
SkipReasonNote string
}
func (ia *InitArgs) AddFlagSets(flags *flag.FlagSet) {

@ -7,6 +7,7 @@ import (
"fmt"
"regexp"
"strconv"
"strings"
"google.golang.org/grpc/codes"
)
@ -50,3 +51,43 @@ func CheckErrorCode(err error, code codes.Code) bool {
errCode, _ := strconv.Atoi(matches[1])
return errCode == int(code)
}
// Canonical enforced-provisioner wire error_code reasons (RFC 14.3). These are
// carried in the resolver error body via google.rpc.ErrorInfo and rendered into
// the gateway JSON error, so they are detectable in the error string.
const (
EnforcementReasonResolverUnavailable = "enforcement_resolver_unavailable"
EnforcementReasonResolutionIncomplete = "enforcement_resolution_incomplete"
EnforcementReasonRevokedLinkBlocking = "enforcement_revoked_link_blocking"
EnforcementReasonDataIntegrityError = "enforcement_data_integrity_error"
EnforcementReasonClientUpgradeRequired = "enforcement_client_upgrade_required"
)
// EnforcementErrorReason extracts the canonical enforced-provisioner error_code
// reason from an error returned by the resolver, if present. Returns "" when no
// known reason is found.
func EnforcementErrorReason(err error) string {
if err == nil {
return ""
}
msg := err.Error()
for _, reason := range []string{
EnforcementReasonResolverUnavailable,
EnforcementReasonResolutionIncomplete,
EnforcementReasonRevokedLinkBlocking,
EnforcementReasonDataIntegrityError,
EnforcementReasonClientUpgradeRequired,
} {
if strings.Contains(msg, reason) {
return reason
}
}
return ""
}
// IsClientUpgradeRequired reports whether the resolver rejected this CLI as too
// old to enforce a mandatory bucket (RFC 6.4 / 12.4 / 14.3, HTTP 426).
func IsClientUpgradeRequired(err error) bool {
return EnforcementErrorReason(err) == EnforcementReasonClientUpgradeRequired ||
CheckErrorCode(err, codes.Code(26)) // gateway maps 426 → no native gRPC code; reason match is primary
}

@ -26,7 +26,9 @@ type MockPackerClientService struct {
TrackCalledServiceMethods bool
// Enforced block tracking
GetEnforcedBlocksByBucketCalled bool
GetEnforcedBlocksByBucketCalled bool
ResolveEnforcedProvisionersCalled bool
ResolveEnforcedProvisionersLastIfMatch string
// Mock Creates
CreateBucketResp *hcpPackerModels.HashicorpCloudPacker20230101CreateBucketResponse
@ -41,6 +43,10 @@ type MockPackerClientService struct {
GetEnforcedBlocksByBucketResp *hcpPackerModels.HashicorpCloudPacker20230101GetEnforcedBlocksByBucketResponse
GetEnforcedBlocksByBucketErr error
// Mock resolver
ResolveEnforcedProvisionersResp *hcpPackerModels.HashicorpCloudPacker20230101ResolveEnforcedProvisionersResponse
ResolveEnforcedProvisionersErr error
ExistingBuilds []string
ExistingBuildLabels map[string]string
@ -358,3 +364,28 @@ func (svc *MockPackerClientService) PackerServiceGetEnforcedBlocksByBucket(
return ok, nil
}
func (svc *MockPackerClientService) PackerServiceResolveEnforcedProvisioners(
params *hcpPackerService.PackerServiceResolveEnforcedProvisionersParams, _ runtime.ClientAuthInfoWriter,
opts ...hcpPackerService.ClientOption,
) (*hcpPackerService.PackerServiceResolveEnforcedProvisionersOK, error) {
if svc.TrackCalledServiceMethods {
svc.ResolveEnforcedProvisionersCalled = true
}
if svc.ResolveEnforcedProvisionersErr != nil {
return nil, svc.ResolveEnforcedProvisionersErr
}
ok := &hcpPackerService.PackerServiceResolveEnforcedProvisionersOK{}
if svc.ResolveEnforcedProvisionersResp != nil {
ok.Payload = svc.ResolveEnforcedProvisionersResp
} else {
ok.Payload = &hcpPackerModels.HashicorpCloudPacker20230101ResolveEnforcedProvisionersResponse{
EffectiveProvisioners: []*hcpPackerModels.HashicorpCloudPacker20230101EffectiveProvisioner{},
}
}
return ok, nil
}

@ -6,6 +6,8 @@ package api
import (
"context"
"github.com/go-openapi/runtime"
"github.com/go-openapi/strfmt"
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"
)
@ -31,3 +33,57 @@ func (c *Client) GetEnforcedBlocksForBucket(
return resp.Payload, nil
}
// ResolveEnforcedProvisioners calls the vNext resolver endpoint
// (POST .../buckets/{bucket_name}:resolve-enforced-provisioners). The resolver
// is the source of truth for the ordered effective provisioner set, the bucket
// policy mode (mandatory/advisory), and the resolution audit context.
//
// ifNoneMatch, when non-empty, is sent as the If-None-Match cache-validation
// header. The freshness token of record for subsequent calls is the body's
// audit_context.etag (the generated SDK does not model the ETag response header
// or a 304 response).
func (c *Client) ResolveEnforcedProvisioners(
ctx context.Context,
bucketName string,
ifNoneMatch string,
buildCorrelationID string,
cliVersion string,
) (*hcpPackerModels.HashicorpCloudPacker20230101ResolveEnforcedProvisionersResponse, error) {
params := hcpPackerService.NewPackerServiceResolveEnforcedProvisionersParamsWithContext(ctx)
params.LocationOrganizationID = c.OrganizationID
params.LocationProjectID = c.ProjectID
params.BucketName = bucketName
params.Body = &hcpPackerModels.HashicorpCloudPacker20230101ResolveEnforcedProvisionersBody{
BuildCorrelationID: buildCorrelationID,
CliVersion: cliVersion,
}
resp, err := c.Packer.PackerServiceResolveEnforcedProvisioners(params, nil, withIfNoneMatch(ifNoneMatch))
if err != nil {
return nil, err
}
return resp.Payload, nil
}
// withIfNoneMatch returns a ClientOption that sets the If-None-Match request
// header on the resolver call, wrapping the params writer so the header is added
// after the generated body/path params are written. A no-op when etag is empty.
func withIfNoneMatch(etag string) hcpPackerService.ClientOption {
return func(op *runtime.ClientOperation) {
if etag == "" {
return
}
inner := op.Params
op.Params = runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, reg strfmt.Registry) error {
if inner != nil {
if err := inner.WriteToRequest(req, reg); err != nil {
return err
}
}
return req.SetHeaderParam("If-None-Match", etag)
})
}
}

@ -0,0 +1,146 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: BUSL-1.1
package registry
import (
"fmt"
"sort"
hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models"
)
// Normalized bucket enforcement policy modes (RFC 6.1).
const (
policyModeMandatory = "mandatory"
policyModeAdvisory = "advisory"
)
// RFC section 11 hard limits, mirrored client-side as a defensive guardrail
// against resolver contract drift. These MUST stay in sync with the
// cloud-packer-service handlers (maxBlockContentBytes / maxLinkedProvisionersPerBucket).
const (
// maxBlockContentBytes caps a single enforced provisioner's block_content.
maxBlockContentBytes = 128 * 1024 // 128 KiB
// maxLinkedProvisionersPerBucket caps how many enforced provisioners a
// bucket may resolve to.
maxLinkedProvisionersPerBucket = 25
)
// buildEnforcedBlocks maps the resolver's effective-provisioner entries to the
// CLI's EnforcedBlock type and returns them in canonical (ascending ordinal)
// execution order. Nil entries are skipped; the sort is defensive against
// transport reordering (RFC 6.2).
func buildEnforcedBlocks(eps []*hcpPackerModels.HashicorpCloudPacker20230101EffectiveProvisioner) []*EnforcedBlock {
blocks := make([]*EnforcedBlock, 0, len(eps))
for _, ep := range eps {
if ep == nil {
continue
}
block := &EnforcedBlock{
ID: ep.EnforcedBlockID,
BlockContent: ep.BlockContent,
VersionID: ep.EnforcedBlockVersionID,
Ordinal: ep.Ordinal,
ContentHash: ep.ContentHash,
// Name is not part of the effective entry; fall back to the version
// id for log/UI identification.
Name: ep.EnforcedBlockID,
}
if ep.TemplateType != nil {
block.TemplateType = string(*ep.TemplateType)
}
if ep.VersionState != nil {
block.VersionState = string(*ep.VersionState)
}
blocks = append(blocks, block)
}
sort.SliceStable(blocks, func(i, j int) bool { return blocks[i].Ordinal < blocks[j].Ordinal })
return blocks
}
// validateResolvedEnforcementLimits enforces the RFC section 11 hard limits on
// the resolved enforced-provisioner set as a client-side guardrail. It returns
// a non-nil error describing the first violation; callers decide whether to
// fail closed (mandatory) or warn and continue (advisory).
func validateResolvedEnforcementLimits(blocks []*EnforcedBlock) error {
if len(blocks) > maxLinkedProvisionersPerBucket {
return fmt.Errorf(
"resolver returned %d enforced provisioners, exceeding the maximum of %d per bucket",
len(blocks), maxLinkedProvisionersPerBucket,
)
}
for _, b := range blocks {
if b == nil {
continue
}
if len(b.BlockContent) > maxBlockContentBytes {
id := b.VersionID
if id == "" {
id = b.ID
}
return fmt.Errorf(
"enforced provisioner %q block_content is %d bytes, exceeding the maximum of %d bytes",
id, len(b.BlockContent), maxBlockContentBytes,
)
}
}
return nil
}
// Closed set of skip reason codes for GA (RFC 10). Additions require an RFC
// amendment.
const (
SkipReasonBreakglassIncident = "breakglass_incident"
SkipReasonResolverOutage = "resolver_outage"
SkipReasonVerifiedException = "verified_exception"
SkipReasonMigrationCompat = "migration_compatibility"
)
// ValidSkipReasonCodes is the closed enum of accepted --skip-reason-code values.
var ValidSkipReasonCodes = []string{
SkipReasonBreakglassIncident,
SkipReasonResolverOutage,
SkipReasonVerifiedException,
SkipReasonMigrationCompat,
}
// IsValidSkipReasonCode reports whether code is a member of the closed reason
// enum (RFC 10).
func IsValidSkipReasonCode(code string) bool {
for _, c := range ValidSkipReasonCodes {
if c == code {
return true
}
}
return false
}
// EnforcementOptions carries CLI-supplied context into the resolver call.
type EnforcementOptions struct {
// CLIVersion is the calling Packer version, used by the server for
// minimum-version enforcement on mandatory buckets (RFC 12.4).
CLIVersion string
// BuildCorrelationID correlates the resolution with build audit events.
BuildCorrelationID string
}
// normalizePolicyMode maps the SDK enum (or its short form) to the normalized
// "mandatory"/"advisory" vocabulary. Unset defaults to mandatory (RFC 6.1).
func normalizePolicyMode(mode *hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyMode) string {
if mode == nil {
return policyModeMandatory
}
switch *mode {
case hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyModeENFORCEMENTPOLICYMODEADVISORY:
return policyModeAdvisory
case hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyModeENFORCEMENTPOLICYMODEMANDATORY:
return policyModeMandatory
default:
// UNSET or unknown: fail-safe to the GA default.
return policyModeMandatory
}
}
// versionStateReleased is the expected lifecycle state of a resolved version.
const versionStateReleased = "ENFORCED_BLOCK_VERSION_STATUS_RELEASED"

@ -0,0 +1,110 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: BUSL-1.1
package registry
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"time"
)
// Enforcement cache implements the RFC 6.4 stale-cache semantics for the
// resolver. The freshness token of record is the resolver body's
// audit_context.etag; the CLI persists the last successful resolution so that,
// on a subsequent resolver outage, it can revalidate with If-None-Match and
// (subject to per-mode max-age) reuse the cached effective set.
//
// Max cache age (RFC 6.4 / 11): mandatory 300s, advisory 3600s.
const (
mandatoryCacheMaxAge = 300 * time.Second
advisoryCacheMaxAge = 3600 * time.Second
)
// enforcementCacheEntry is the persisted snapshot of a successful resolution.
type enforcementCacheEntry struct {
Etag string `json:"etag"`
ResolvedAt time.Time `json:"resolved_at"`
TTLSeconds int32 `json:"ttl_seconds"`
PolicyMode string `json:"policy_mode"`
ResolutionID string `json:"resolution_id"`
Blocks []*EnforcedBlock `json:"blocks"`
}
// fresh reports whether the cached resolution is still usable for the given
// policy mode, i.e. its age does not exceed the per-mode max age (RFC 6.4).
func (e *enforcementCacheEntry) fresh(mode string, now time.Time) bool {
if e == nil || e.ResolvedAt.IsZero() {
return false
}
maxAge := mandatoryCacheMaxAge
if mode == policyModeAdvisory {
maxAge = advisoryCacheMaxAge
}
return now.Sub(e.ResolvedAt) <= maxAge
}
// enforcementCacheDir returns the directory used to persist resolutions. It
// honors PACKER_ENFORCEMENT_CACHE_DIR for tests/overrides, otherwise uses the
// user cache dir. Returns "" (caching disabled) if no location is available.
func enforcementCacheDir() string {
if dir := os.Getenv("PACKER_ENFORCEMENT_CACHE_DIR"); dir != "" {
return dir
}
base, err := os.UserCacheDir()
if err != nil {
return ""
}
return filepath.Join(base, "packer", "enforcement")
}
// enforcementCacheKey produces a stable, filesystem-safe key for a bucket within
// a project so resolutions are scoped per org/project/bucket.
func enforcementCacheKey(orgID, projectID, bucketName string) string {
sum := sha256.Sum256([]byte(orgID + "/" + projectID + "/" + bucketName))
return hex.EncodeToString(sum[:]) + ".json"
}
// loadEnforcementCache reads the cached resolution for a bucket. A nil entry
// (and nil error) means no usable cache exists.
func loadEnforcementCache(orgID, projectID, bucketName string) (*enforcementCacheEntry, error) {
dir := enforcementCacheDir()
if dir == "" {
return nil, nil
}
path := filepath.Join(dir, enforcementCacheKey(orgID, projectID, bucketName))
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var entry enforcementCacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
// A corrupt cache file is treated as absent rather than fatal.
return nil, nil
}
return &entry, nil
}
// saveEnforcementCache persists a successful resolution. Failures to write are
// non-fatal to the build and are surfaced to the caller for logging only.
func saveEnforcementCache(orgID, projectID, bucketName string, entry *enforcementCacheEntry) error {
dir := enforcementCacheDir()
if dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.Marshal(entry)
if err != nil {
return err
}
path := filepath.Join(dir, enforcementCacheKey(orgID, projectID, bucketName))
return os.WriteFile(path, data, 0o600)
}

@ -91,19 +91,32 @@ 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)
// FetchEnforcedBlocks resolves enforced provisioner blocks from HCP Packer.
func (h *HCLRegistry) FetchEnforcedBlocks(ctx context.Context, opts EnforcementOptions) error {
return h.bucket.FetchEnforcedBlocks(ctx, opts)
}
// RecordEnforcementSkip records an authorized --skip-enforcement decision.
func (h *HCLRegistry) RecordEnforcementSkip(reasonCode, reasonNote string) {
h.bucket.RecordEnforcementSkip(reasonCode, reasonNote)
}
// InjectEnforcedProvisioners injects enforced provisioners into the builds
func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics {
// Surface advisory-mode degradation warnings (RFC 6.4).
for _, w := range h.bucket.EnforcementWarnings {
h.ui.Say(fmt.Sprintf("Warning: %s", w))
}
enforcedBlocks := h.bucket.EnforcedBlocks
if len(enforcedBlocks) == 0 {
// Distinguish "no configured provisioners" from "skipped" (RFC 10).
h.ui.Say(fmt.Sprintf("No HCP Packer enforced provisioners configured for bucket %q.", h.bucket.Name))
return nil
}
var allDiags hcl.Diagnostics
injected := 0
// Parse all enforced blocks into provisioner blocks
for _, eb := range enforcedBlocks {
@ -142,6 +155,7 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl
}
build.Provisioners = append(build.Provisioners, coreProv)
injected++
log.Printf("[INFO] injected enforced provisioner %q from block %q into build %q",
pb.PType, eb.Name, build.Name())
@ -149,6 +163,12 @@ func (h *HCLRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl
}
}
if injected > 0 {
h.ui.Say(fmt.Sprintf(
"Applied HCP Packer enforced provisioners for bucket %q (policy_mode=%s, resolution_id=%s).",
h.bucket.Name, h.bucket.EnforcementPolicyMode, h.bucket.EnforcementResolutionID))
}
return allDiags
}

@ -115,19 +115,30 @@ 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)
// FetchEnforcedBlocks resolves enforced provisioner blocks from HCP Packer.
func (h *JSONRegistry) FetchEnforcedBlocks(ctx context.Context, opts EnforcementOptions) error {
return h.bucket.FetchEnforcedBlocks(ctx, opts)
}
// RecordEnforcementSkip records an authorized --skip-enforcement decision.
func (h *JSONRegistry) RecordEnforcementSkip(reasonCode, reasonNote string) {
h.bucket.RecordEnforcementSkip(reasonCode, reasonNote)
}
// InjectEnforcedProvisioners injects enforced provisioners into the builds
func (h *JSONRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics {
for _, w := range h.bucket.EnforcementWarnings {
h.ui.Say(fmt.Sprintf("Warning: %s", w))
}
enforcedBlocks := h.bucket.EnforcedBlocks
if len(enforcedBlocks) == 0 {
h.ui.Say(fmt.Sprintf("No HCP Packer enforced provisioners configured for bucket %q.", h.bucket.Name))
return nil
}
var allDiags hcl.Diagnostics
injected := 0
for _, eb := range enforcedBlocks {
if eb.BlockContent == "" {
@ -175,6 +186,7 @@ func (h *JSONRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hc
build.Provisioners = append(build.Provisioners, coreProv)
injectedProvisioners = append(injectedProvisioners, coreProv)
injected++
log.Printf("[INFO] injected enforced provisioner %q from block %q into legacy JSON build %q",
pb.PType, eb.Name, build.Name())
@ -194,5 +206,11 @@ func (h *JSONRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hc
}
}
if injected > 0 {
h.ui.Say(fmt.Sprintf(
"Applied HCP Packer enforced provisioners for bucket %q (policy_mode=%s, resolution_id=%s).",
h.bucket.Name, h.bucket.EnforcementPolicyMode, h.bucket.EnforcementResolutionID))
}
return allDiags
}

@ -37,10 +37,12 @@ func (r nullRegistry) Metadata() Metadata {
return NilMetadata{}
}
func (r nullRegistry) FetchEnforcedBlocks(ctx context.Context) error {
func (r nullRegistry) FetchEnforcedBlocks(ctx context.Context, opts EnforcementOptions) error {
return nil
}
func (r nullRegistry) RecordEnforcementSkip(reasonCode, reasonNote string) {}
func (r nullRegistry) InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics {
return nil
}

@ -20,10 +20,15 @@ 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
// FetchEnforcedBlocks resolves the effective enforced-provisioner set from
// HCP Packer (RFC 6.2) and applies the mandatory/advisory failure matrix.
FetchEnforcedBlocks(ctx context.Context, opts EnforcementOptions) error
// InjectEnforcedProvisioners injects the resolved enforced provisioners into
// the builds in canonical execution order.
InjectEnforcedProvisioners(builds []*packer.CoreBuild) hcl.Diagnostics
// RecordEnforcementSkip records an authorized --skip-enforcement decision
// into build metadata (RFC 10).
RecordEnforcementSkip(reasonCode, reasonNote string)
}
// New instantiates the appropriate registry for the Packer configuration template type.

@ -9,6 +9,7 @@ import (
"fmt"
"log"
"os"
"strings"
"sync"
"time"
@ -29,7 +30,8 @@ import (
// build is still alive.
const HeartbeatPeriod = 2 * time.Minute
// EnforcedBlock represents an enforced provisioner block from HCP Packer
// EnforcedBlock represents a single resolved enforced provisioner entry from the
// HCP Packer resolver (RFC 6.2 effective_provisioners[]).
type EnforcedBlock struct {
ID string
Name string
@ -37,6 +39,14 @@ type EnforcedBlock struct {
VersionID string
Version string
TemplateType string
// Ordinal is the resolved execution ordinal; the resolved set is applied in
// ascending ordinal order (the resolver's canonical execution order).
Ordinal int32
// ContentHash is the immutable "sha256:<hex>" hash recorded for the version
// at publish time; emitted into build metadata for integrity (RFC 6.3).
ContentHash string
// VersionState is the resolved lifecycle state (expected released).
VersionState string
}
// Bucket represents a single bucket on the HCP Packer registry.
@ -52,6 +62,14 @@ type Bucket struct {
Version *Version
EnforcedBlocks []*EnforcedBlock
client *hcpPackerAPI.Client
// Enforcement resolution state, populated by FetchEnforcedBlocks.
EnforcementPolicyMode string // normalized "mandatory"/"advisory"
EnforcementResolutionID string // resolver audit_context.resolution_id
EnforcementEtag string // resolver audit_context.etag (freshness token)
EnforcementConfigured bool // bucket has an active enforced-provisioner set
EnforcementFromCache bool // resolved set was served from local stale cache
EnforcementWarnings []string // advisory-mode degradation warnings to surface
}
type ParentVersion struct {
@ -153,61 +171,209 @@ 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 {
// FetchEnforcedBlocks resolves the effective enforced-provisioner set for this
// bucket via the HCP Packer resolver (RFC 6.2) and applies the mandatory/advisory
// failure-handling matrix (RFC 6.4).
//
// On success it populates bucket.EnforcedBlocks (in resolver-canonical ordinal
// order) and the resolution metadata, and persists a local cache keyed on the
// returned etag for stale-cache revalidation.
//
// Returns a non-nil error only when the build must FAIL CLOSED (mandatory mode).
// In advisory mode, degradations append to bucket.EnforcementWarnings and return
// nil so the build continues.
func (bucket *Bucket) FetchEnforcedBlocks(ctx context.Context, opts EnforcementOptions) 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)
orgID := bucket.client.OrganizationID
projectID := bucket.client.ProjectID
// Load any prior successful resolution so we can revalidate with
// If-None-Match and fall back to it on resolver outage (RFC 6.4).
cached, cacheErr := loadEnforcementCache(orgID, projectID, bucket.Name)
if cacheErr != nil {
log.Printf("[DEBUG] enforced provisioners: ignoring unreadable cache for bucket %q: %v", bucket.Name, cacheErr)
}
ifNoneMatch := ""
if cached != nil {
ifNoneMatch = cached.Etag
}
resp, err := bucket.client.GetEnforcedBlocksForBucket(ctx, bucket.Name)
log.Printf("[INFO] resolving enforced provisioners for bucket %q", bucket.Name)
resp, err := bucket.client.ResolveEnforcedProvisioners(ctx, bucket.Name, ifNoneMatch, opts.BuildCorrelationID, opts.CLIVersion)
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 bucket.handleResolveError(err, cached)
}
if resp == nil {
return bucket.handleResolveError(errors.New("empty resolver response"), cached)
}
return fmt.Errorf("failed fetching enforced blocks for bucket %q: %w", bucket.Name, err)
mode := normalizePolicyMode(resp.PolicyMode)
bucket.EnforcementPolicyMode = mode
if resp.AuditContext != nil {
bucket.EnforcementResolutionID = resp.AuditContext.ResolutionID
bucket.EnforcementEtag = resp.AuditContext.Etag
}
bucket.EnforcementFromCache = false
if resp == nil {
log.Printf("[INFO] no enforced blocks response returned for bucket %q", bucket.Name)
blocks := buildEnforcedBlocks(resp.EffectiveProvisioners)
// Client-side RFC 11 hard-limit guardrail: reject a resolved set that
// violates the per-bucket count or per-version payload caps before it is
// applied or cached.
if limitErr := validateResolvedEnforcementLimits(blocks); limitErr != nil {
return bucket.handleEnforcementLimitViolation(mode, limitErr)
}
bucket.EnforcedBlocks = blocks
bucket.EnforcementConfigured = len(blocks) > 0
// Persist the successful resolution for future revalidation/outage reuse.
entry := &enforcementCacheEntry{
Etag: bucket.EnforcementEtag,
ResolvedAt: time.Now().UTC(),
PolicyMode: mode,
ResolutionID: bucket.EnforcementResolutionID,
Blocks: blocks,
}
if resp.ResolutionTTLSeconds > 0 {
entry.TTLSeconds = resp.ResolutionTTLSeconds
}
if err := saveEnforcementCache(orgID, projectID, bucket.Name, entry); err != nil {
log.Printf("[DEBUG] enforced provisioners: failed to cache resolution for bucket %q: %v", bucket.Name, err)
}
bucket.recordEnforcementMetadata()
log.Printf("[INFO] resolved %d enforced provisioner(s) for bucket %q (policy_mode=%s resolution_id=%s)",
len(blocks), bucket.Name, mode, bucket.EnforcementResolutionID)
return nil
}
// Reserved build-label keys used to persist the resolved enforcement context
// into HCP build metadata (RFC 6.3 integrity / 10 audit).
const (
enforcementLabelPolicyMode = "hcp_packer_enforcement_policy_mode"
enforcementLabelResolutionID = "hcp_packer_enforcement_resolution_id"
enforcementLabelVersionIDs = "hcp_packer_enforcement_version_ids"
enforcementLabelContentHashes = "hcp_packer_enforcement_content_hashes"
enforcementLabelFromCache = "hcp_packer_enforcement_from_cache"
enforcementLabelSkipped = "hcp_packer_enforcement_skipped"
enforcementLabelSkipReason = "hcp_packer_enforcement_skip_reason_code"
enforcementLabelSkipNote = "hcp_packer_enforcement_skip_reason_note"
)
// recordEnforcementMetadata persists the resolved enforcement context into the
// bucket build labels so it is captured in HCP build metadata. The CLI must
// record the resolution id, resolved version ids, content hashes, and policy
// mode (RFC 6.2/6.3).
func (bucket *Bucket) recordEnforcementMetadata() {
if bucket.BuildLabels == nil {
bucket.BuildLabels = make(map[string]string)
}
bucket.BuildLabels[enforcementLabelPolicyMode] = bucket.EnforcementPolicyMode
bucket.BuildLabels[enforcementLabelResolutionID] = bucket.EnforcementResolutionID
if bucket.EnforcementFromCache {
bucket.BuildLabels[enforcementLabelFromCache] = "true"
}
versionIDs := make([]string, 0, len(bucket.EnforcedBlocks))
hashes := make([]string, 0, len(bucket.EnforcedBlocks))
for _, eb := range bucket.EnforcedBlocks {
versionIDs = append(versionIDs, eb.VersionID)
hashes = append(hashes, eb.ContentHash)
}
bucket.BuildLabels[enforcementLabelVersionIDs] = strings.Join(versionIDs, ",")
bucket.BuildLabels[enforcementLabelContentHashes] = strings.Join(hashes, ",")
}
// RecordEnforcementSkip persists an authorized --skip-enforcement decision into
// build metadata (RFC 10). Server-side authorization and audit emission are
// enforced separately by the bucket policy; this records the CLI-side request.
func (bucket *Bucket) RecordEnforcementSkip(reasonCode, reasonNote string) {
if bucket.BuildLabels == nil {
bucket.BuildLabels = make(map[string]string)
}
bucket.BuildLabels[enforcementLabelSkipped] = "true"
bucket.BuildLabels[enforcementLabelSkipReason] = reasonCode
if reasonNote != "" {
bucket.BuildLabels[enforcementLabelSkipNote] = reasonNote
}
}
// handleEnforcementLimitViolation applies the failure matrix when the resolved
// set violates an RFC 11 hard limit. Mandatory buckets fail closed; advisory
// buckets warn and continue without enforcement (the suspect set is dropped
// rather than applied). The resolution context is still recorded for audit.
func (bucket *Bucket) handleEnforcementLimitViolation(mode string, err error) error {
if mode == policyModeAdvisory {
bucket.EnforcedBlocks = nil
bucket.EnforcementConfigured = false
bucket.EnforcementWarnings = append(bucket.EnforcementWarnings,
fmt.Sprintf("HCP Packer resolved enforced provisioners for bucket %q exceed CLI safety limits (advisory mode); continuing without enforcement: %v", bucket.Name, err))
log.Printf("[WARN] enforced provisioners: resolved set for bucket %q exceeds CLI limits (advisory); continuing: %v", bucket.Name, err)
bucket.recordEnforcementMetadata()
return nil
}
return fmt.Errorf("enforced provisioners for bucket %q exceed CLI safety limits (mandatory mode, fail-closed): %w", bucket.Name, err)
}
bucket.EnforcedBlocks = make([]*EnforcedBlock, 0, len(resp.EnforcedBlockDetail))
for _, detail := range resp.EnforcedBlockDetail {
if detail == nil || detail.Version == nil {
continue
}
// handleResolveError applies the RFC 6.4 failure matrix when the resolver call
// fails. It consults the locally cached resolution (if fresh enough for the
// effective mode) before deciding to fail closed or warn-and-continue.
func (bucket *Bucket) handleResolveError(err error, cached *enforcementCacheEntry) error {
// API does not yet support the resolver (older deployment): treat as no
// enforcement configured rather than failing the build.
if hcpPackerAPI.CheckErrorCode(err, codes.Unimplemented) || hcpPackerAPI.CheckErrorCode(err, codes.NotFound) {
log.Printf("[DEBUG] enforced provisioners: resolver unavailable on this API for bucket %q: %v", bucket.Name, err)
bucket.EnforcedBlocks = nil
bucket.EnforcementConfigured = false
return nil
}
block := &EnforcedBlock{
ID: detail.ID,
Name: detail.Name,
BlockContent: detail.Version.BlockContent,
VersionID: detail.Version.ID,
Version: detail.Version.Version,
}
// Old CLI on a mandatory bucket: explicit upgrade requirement (RFC 6.4/12.4).
if hcpPackerAPI.IsClientUpgradeRequired(err) {
return fmt.Errorf("this Packer version cannot enforce provisioners for bucket %q: the registry requires a newer CLI (%s). Upgrade Packer to continue", bucket.Name, hcpPackerAPI.EnforcementReasonClientUpgradeRequired)
}
if detail.Version.TemplateType != nil {
block.TemplateType = string(*detail.Version.TemplateType)
}
// Determine effective mode for the failure decision. Prefer the last known
// policy mode from cache; default to mandatory (fail-safe) when unknown.
mode := policyModeMandatory
if cached != nil && cached.PolicyMode != "" {
mode = cached.PolicyMode
}
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)
now := time.Now().UTC()
if cached != nil && cached.fresh(mode, now) {
// Reuse the cached resolution (RFC 6.4 stale-cache rules).
bucket.EnforcedBlocks = cached.Blocks
bucket.EnforcementPolicyMode = mode
bucket.EnforcementResolutionID = cached.ResolutionID
bucket.EnforcementEtag = cached.Etag
bucket.EnforcementConfigured = len(cached.Blocks) > 0
bucket.EnforcementFromCache = true
log.Printf("[WARN] enforced provisioners: resolver unavailable for bucket %q; reusing cached resolution (age within %s limit)", bucket.Name, mode)
bucket.EnforcementWarnings = append(bucket.EnforcementWarnings,
fmt.Sprintf("HCP Packer resolver unavailable for bucket %q; using cached enforced provisioners.", bucket.Name))
return nil
}
if len(bucket.EnforcedBlocks) == 0 {
log.Printf("[INFO] no enforced provisioner blocks linked to bucket %q", bucket.Name)
// No usable cache.
if mode == policyModeAdvisory {
// Advisory: warn and continue without enforcement.
bucket.EnforcedBlocks = nil
bucket.EnforcementPolicyMode = mode
bucket.EnforcementConfigured = false
bucket.EnforcementWarnings = append(bucket.EnforcementWarnings,
fmt.Sprintf("HCP Packer resolver unavailable for bucket %q (advisory mode); continuing without enforced provisioners: %v", bucket.Name, err))
log.Printf("[WARN] enforced provisioners: resolver unavailable for bucket %q (advisory); continuing: %v", bucket.Name, err)
return nil
}
log.Printf("[INFO] fetched %d enforced block(s) linked to bucket %q", len(bucket.EnforcedBlocks), bucket.Name)
return nil
// Mandatory with no fresh cache: fail closed.
return fmt.Errorf("failed resolving enforced provisioners for bucket %q (mandatory mode, fail-closed): %w", bucket.Name, err)
}
func (bucket *Bucket) RegisterBuildForComponent(sourceName string) {

@ -7,111 +7,274 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
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) {
func newTestBucket(mock *hcpPackerAPI.MockPackerClientService) *Bucket {
return &Bucket{
Name: "test-bucket",
BuildLabels: map[string]string{},
client: &hcpPackerAPI.Client{Packer: mock, OrganizationID: "org-1", ProjectID: "prj-1"},
}
}
func mandatory() *hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyMode {
return hcpPackerModels.NewHashicorpCloudPacker20230101EnforcementPolicyMode(
hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyModeENFORCEMENTPOLICYMODEMANDATORY)
}
func advisory() *hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyMode {
return hcpPackerModels.NewHashicorpCloudPacker20230101EnforcementPolicyMode(
hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyModeENFORCEMENTPOLICYMODEADVISORY)
}
func TestBucket_FetchEnforcedBlocks_ResolverReturnsOrderedSet(t *testing.T) {
t.Setenv("PACKER_ENFORCEMENT_CACHE_DIR", t.TempDir())
hcl2Type := hcpPackerModels.HashicorpCloudPacker20230101TemplateTypeHCL2
jsonType := hcpPackerModels.HashicorpCloudPacker20230101TemplateTypeJSON
mockService := hcpPackerAPI.NewMockPackerClientService()
mockService.GetEnforcedBlocksByBucketResp = &hcpPackerModels.HashicorpCloudPacker20230101GetEnforcedBlocksByBucketResponse{
EnforcedBlockDetail: []*hcpPackerModels.HashicorpCloudPacker20230101EnforcedBlockDetail{
// Provide entries out of ordinal order to verify the CLI applies the
// canonical (ascending ordinal) execution order.
mockService.ResolveEnforcedProvisionersResp = &hcpPackerModels.HashicorpCloudPacker20230101ResolveEnforcedProvisionersResponse{
PolicyMode: mandatory(),
ResolutionTTLSeconds: 300,
EffectiveProvisioners: []*hcpPackerModels.HashicorpCloudPacker20230101EffectiveProvisioner{
{
ID: "hcl-id",
Name: "hcl-block",
Version: &hcpPackerModels.HashicorpCloudPacker20230101EnforcedBlockVersion{
ID: "hcl-v1",
Version: "1",
BlockContent: "provisioner \"shell\" {}",
TemplateType: &hcl2Type,
},
EnforcedBlockID: "eb-20",
EnforcedBlockVersionID: "ebv-20",
BlockContent: "provisioner \"shell\" {}",
ContentHash: "sha256:bbb",
Ordinal: 20,
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\" {}",
},
EnforcedBlockID: "eb-10",
EnforcedBlockVersionID: "ebv-10",
BlockContent: "provisioner \"shell\" {}",
ContentHash: "sha256:aaa",
Ordinal: 10,
TemplateType: &hcl2Type,
},
},
}
bucket := &Bucket{
Name: "test-bucket",
client: &hcpPackerAPI.Client{
Packer: mockService,
AuditContext: &hcpPackerModels.HashicorpCloudPacker20230101ResolveAuditContext{
ResolutionID: "res-1",
Etag: "W/\"etag-1\"",
},
}
err := bucket.FetchEnforcedBlocks(context.Background())
if err != nil {
bucket := newTestBucket(mockService)
if err := bucket.FetchEnforcedBlocks(context.Background(), EnforcementOptions{CLIVersion: "1.2.3"}); 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 got := len(bucket.EnforcedBlocks); got != 2 {
t.Fatalf("got %d blocks, want 2", got)
}
if bucket.EnforcedBlocks[0].Ordinal != 10 || bucket.EnforcedBlocks[1].Ordinal != 20 {
t.Fatalf("blocks not in ascending ordinal order: got %d then %d",
bucket.EnforcedBlocks[0].Ordinal, bucket.EnforcedBlocks[1].Ordinal)
}
if bucket.EnforcedBlocks[0].ContentHash != "sha256:aaa" {
t.Fatalf("first block content hash = %q, want sha256:aaa", bucket.EnforcedBlocks[0].ContentHash)
}
if bucket.EnforcementPolicyMode != policyModeMandatory {
t.Fatalf("policy mode = %q, want mandatory", bucket.EnforcementPolicyMode)
}
if bucket.EnforcementResolutionID != "res-1" {
t.Fatalf("resolution id = %q, want res-1", bucket.EnforcementResolutionID)
}
// Build metadata must capture the resolved context (RFC 6.3).
if bucket.BuildLabels[enforcementLabelResolutionID] != "res-1" {
t.Fatalf("metadata resolution id label = %q, want res-1", bucket.BuildLabels[enforcementLabelResolutionID])
}
if bucket.BuildLabels[enforcementLabelContentHashes] != "sha256:aaa,sha256:bbb" {
t.Fatalf("metadata content hashes label = %q", bucket.BuildLabels[enforcementLabelContentHashes])
}
}
if bucket.EnforcedBlocks[0].Name != "hcl-block" {
t.Fatalf("first block name = %q, want %q", bucket.EnforcedBlocks[0].Name, "hcl-block")
// makeEffectiveProvisioners builds n resolver entries with ascending ordinals.
// Each block_content is contentBytes long (use it to exercise the payload cap).
func makeEffectiveProvisioners(n, contentBytes int) []*hcpPackerModels.HashicorpCloudPacker20230101EffectiveProvisioner {
hcl2Type := hcpPackerModels.HashicorpCloudPacker20230101TemplateTypeHCL2
out := make([]*hcpPackerModels.HashicorpCloudPacker20230101EffectiveProvisioner, 0, n)
for i := 0; i < n; i++ {
out = append(out, &hcpPackerModels.HashicorpCloudPacker20230101EffectiveProvisioner{
EnforcedBlockID: fmt.Sprintf("eb-%d", i),
EnforcedBlockVersionID: fmt.Sprintf("ebv-%d", i),
BlockContent: strings.Repeat("a", contentBytes),
ContentHash: fmt.Sprintf("sha256:%d", i),
Ordinal: int32((i + 1) * 10),
TemplateType: &hcl2Type,
})
}
return out
}
if bucket.EnforcedBlocks[1].Name != "json-block" {
t.Fatalf("second block name = %q, want %q", bucket.EnforcedBlocks[1].Name, "json-block")
func TestBucket_FetchEnforcedBlocks_HardLimits(t *testing.T) {
tests := []struct {
name string
mode *hcpPackerModels.HashicorpCloudPacker20230101EnforcementPolicyMode
count int
contentBytes int
wantErr bool
wantConfigured bool
wantWarnings bool
}{
{
name: "at count limit (25) is accepted",
mode: mandatory(),
count: maxLinkedProvisionersPerBucket,
contentBytes: 16,
wantErr: false,
wantConfigured: true,
},
{
name: "at payload limit (128 KiB) is accepted",
mode: mandatory(),
count: 1,
contentBytes: maxBlockContentBytes,
wantErr: false,
wantConfigured: true,
},
{
name: "over count limit fails closed (mandatory)",
mode: mandatory(),
count: maxLinkedProvisionersPerBucket + 1,
contentBytes: 16,
wantErr: true,
},
{
name: "over payload limit fails closed (mandatory)",
mode: mandatory(),
count: 1,
contentBytes: maxBlockContentBytes + 1,
wantErr: true,
},
{
name: "over count limit warns and drops (advisory)",
mode: advisory(),
count: maxLinkedProvisionersPerBucket + 1,
contentBytes: 16,
wantErr: false,
wantConfigured: false,
wantWarnings: true,
},
}
if bucket.EnforcedBlocks[2].Name != "unset-block" {
t.Fatalf("third block name = %q, want %q", bucket.EnforcedBlocks[2].Name, "unset-block")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("PACKER_ENFORCEMENT_CACHE_DIR", t.TempDir())
mockService := hcpPackerAPI.NewMockPackerClientService()
mockService.ResolveEnforcedProvisionersResp = &hcpPackerModels.HashicorpCloudPacker20230101ResolveEnforcedProvisionersResponse{
PolicyMode: tt.mode,
ResolutionTTLSeconds: 300,
EffectiveProvisioners: makeEffectiveProvisioners(tt.count, tt.contentBytes),
AuditContext: &hcpPackerModels.HashicorpCloudPacker20230101ResolveAuditContext{
ResolutionID: "res-1",
Etag: "W/\"etag-1\"",
},
}
bucket := newTestBucket(mockService)
err := bucket.FetchEnforcedBlocks(context.Background(), EnforcementOptions{CLIVersion: "1.2.3"})
if (err != nil) != tt.wantErr {
t.Fatalf("FetchEnforcedBlocks() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if bucket.EnforcementConfigured != tt.wantConfigured {
t.Fatalf("EnforcementConfigured = %v, want %v", bucket.EnforcementConfigured, tt.wantConfigured)
}
if tt.wantWarnings && len(bucket.EnforcementWarnings) == 0 {
t.Fatal("expected an advisory degradation warning, got none")
}
})
}
}
func TestBucket_FetchEnforcedBlocks_ReturnsErrorOnServiceFailure(t *testing.T) {
func TestBucket_FetchEnforcedBlocks_MandatoryFailClosedOnError(t *testing.T) {
t.Setenv("PACKER_ENFORCEMENT_CACHE_DIR", t.TempDir())
mockService := hcpPackerAPI.NewMockPackerClientService()
mockService.GetEnforcedBlocksByBucketErr = errors.New("service unavailable")
mockService.ResolveEnforcedProvisionersErr = errors.New("service unavailable")
bucket := &Bucket{
Name: "test-bucket",
client: &hcpPackerAPI.Client{
Packer: mockService,
},
}
bucket := newTestBucket(mockService)
err := bucket.FetchEnforcedBlocks(context.Background())
err := bucket.FetchEnforcedBlocks(context.Background(), EnforcementOptions{})
if err == nil {
t.Fatal("FetchEnforcedBlocks() expected error, got nil")
t.Fatal("FetchEnforcedBlocks() expected fail-closed error in mandatory mode, got nil")
}
}
func TestBucket_FetchEnforcedBlocks_NotFoundIsNonFatal(t *testing.T) {
t.Setenv("PACKER_ENFORCEMENT_CACHE_DIR", t.TempDir())
mockService := hcpPackerAPI.NewMockPackerClientService()
mockService.ResolveEnforcedProvisionersErr = fmt.Errorf("Code:%d %s", codes.NotFound, codes.NotFound.String())
bucket := newTestBucket(mockService)
if err := bucket.FetchEnforcedBlocks(context.Background(), EnforcementOptions{}); err != nil {
t.Fatalf("FetchEnforcedBlocks() expected nil error for NotFound, got: %v", err)
}
if bucket.EnforcementConfigured {
t.Fatal("expected EnforcementConfigured=false when resolver unsupported")
}
}
func TestBucket_FetchEnforcedBlocks_ClientUpgradeRequired(t *testing.T) {
t.Setenv("PACKER_ENFORCEMENT_CACHE_DIR", t.TempDir())
mockService := hcpPackerAPI.NewMockPackerClientService()
mockService.GetEnforcedBlocksByBucketErr = fmt.Errorf("Code:%d %s", codes.NotFound, codes.NotFound.String())
mockService.ResolveEnforcedProvisionersErr = errors.New("rpc error: enforcement_client_upgrade_required")
bucket := &Bucket{
Name: "test-bucket",
client: &hcpPackerAPI.Client{
Packer: mockService,
bucket := newTestBucket(mockService)
err := bucket.FetchEnforcedBlocks(context.Background(), EnforcementOptions{})
if err == nil {
t.Fatal("expected upgrade-required error, got nil")
}
}
func TestBucket_FetchEnforcedBlocks_ReusesFreshCacheOnOutage(t *testing.T) {
dir := t.TempDir()
t.Setenv("PACKER_ENFORCEMENT_CACHE_DIR", dir)
// Seed a fresh advisory resolution into the cache.
entry := &enforcementCacheEntry{
Etag: "W/\"cached\"",
ResolvedAt: time.Now().UTC(),
PolicyMode: policyModeAdvisory,
ResolutionID: "res-cached",
Blocks: []*EnforcedBlock{
{VersionID: "ebv-cached", BlockContent: "provisioner \"shell\" {}", Ordinal: 10},
},
}
if err := saveEnforcementCache("org-1", "prj-1", "test-bucket", entry); err != nil {
t.Fatalf("seed cache: %v", err)
}
err := bucket.FetchEnforcedBlocks(context.Background())
if err != nil {
t.Fatalf("FetchEnforcedBlocks() expected nil error for NotFound, got: %v", err)
mockService := hcpPackerAPI.NewMockPackerClientService()
mockService.ResolveEnforcedProvisionersErr = errors.New("service unavailable")
bucket := newTestBucket(mockService)
if err := bucket.FetchEnforcedBlocks(context.Background(), EnforcementOptions{}); err != nil {
t.Fatalf("expected cached reuse (no error), got: %v", err)
}
if !bucket.EnforcementFromCache {
t.Fatal("expected EnforcementFromCache=true")
}
if len(bucket.EnforcedBlocks) != 1 || bucket.EnforcedBlocks[0].VersionID != "ebv-cached" {
t.Fatalf("expected cached block reused, got %+v", bucket.EnforcedBlocks)
}
if len(bucket.EnforcementWarnings) == 0 {
t.Fatal("expected an advisory degradation warning")
}
}

Loading…
Cancel
Save