diff --git a/command/build.go b/command/build.go index 1e9af7b701b..2df918479ca 100644 --- a/command/build.go +++ b/command/build.go @@ -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=, 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) diff --git a/command/cli.go b/command/cli.go index 38d3e9a6d49..9e26e84ed70 100644 --- a/command/cli.go +++ b/command/cli.go @@ -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) { diff --git a/internal/hcp/api/errors.go b/internal/hcp/api/errors.go index 7ea3064c1ba..3e654d6ca28 100644 --- a/internal/hcp/api/errors.go +++ b/internal/hcp/api/errors.go @@ -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 +} diff --git a/internal/hcp/api/mock_service.go b/internal/hcp/api/mock_service.go index 47a5ce4f8bc..84c1a78cda4 100644 --- a/internal/hcp/api/mock_service.go +++ b/internal/hcp/api/mock_service.go @@ -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 +} diff --git a/internal/hcp/api/service_enforced_provisioner.go b/internal/hcp/api/service_enforced_provisioner.go index 07fb28c3eac..b7abe593533 100644 --- a/internal/hcp/api/service_enforced_provisioner.go +++ b/internal/hcp/api/service_enforced_provisioner.go @@ -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) + }) + } +} diff --git a/internal/hcp/registry/enforcement.go b/internal/hcp/registry/enforcement.go new file mode 100644 index 00000000000..aaaa333a047 --- /dev/null +++ b/internal/hcp/registry/enforcement.go @@ -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" diff --git a/internal/hcp/registry/enforcement_cache.go b/internal/hcp/registry/enforcement_cache.go new file mode 100644 index 00000000000..e89a385a2a4 --- /dev/null +++ b/internal/hcp/registry/enforcement_cache.go @@ -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) +} diff --git a/internal/hcp/registry/hcl.go b/internal/hcp/registry/hcl.go index df984283e76..e125721d374 100644 --- a/internal/hcp/registry/hcl.go +++ b/internal/hcp/registry/hcl.go @@ -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 } diff --git a/internal/hcp/registry/json.go b/internal/hcp/registry/json.go index 2abc87d5b4d..ff63421f51d 100644 --- a/internal/hcp/registry/json.go +++ b/internal/hcp/registry/json.go @@ -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 } diff --git a/internal/hcp/registry/null_registry.go b/internal/hcp/registry/null_registry.go index 57f09a8369e..134528ab65c 100644 --- a/internal/hcp/registry/null_registry.go +++ b/internal/hcp/registry/null_registry.go @@ -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 } diff --git a/internal/hcp/registry/registry.go b/internal/hcp/registry/registry.go index a6a2c54374d..ab78416e9ab 100644 --- a/internal/hcp/registry/registry.go +++ b/internal/hcp/registry/registry.go @@ -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. diff --git a/internal/hcp/registry/types.bucket.go b/internal/hcp/registry/types.bucket.go index 032d30886e2..8d4e99fde95 100644 --- a/internal/hcp/registry/types.bucket.go +++ b/internal/hcp/registry/types.bucket.go @@ -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:" 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) { diff --git a/internal/hcp/registry/types.bucket_enforced_test.go b/internal/hcp/registry/types.bucket_enforced_test.go index 3b2ad5f856e..2313827a16b 100644 --- a/internal/hcp/registry/types.bucket_enforced_test.go +++ b/internal/hcp/registry/types.bucket_enforced_test.go @@ -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") } }