mirror of https://github.com/hashicorp/packer
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/integritypkr/enforced-provisioners-vnext
parent
2f86cc4001
commit
9fc9bca022
@ -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)
|
||||
}
|
||||
Loading…
Reference in new issue