Skip to content
Merged

060 #10

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ coverage.*
*.report.json
*.report.html
*.report.txt
*.report.xml

# macOS / editors
.DS_Store
Expand All @@ -37,4 +38,6 @@ coverage.*
.vscode/

# Local files test snapshots
local/
local/

COMMAND_HISTORY.md
102 changes: 102 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,106 @@ All notable changes to PIACE are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.0] - 2026-10-08

### Added

- `piace compare --suppress-source-content-warnings` silences expected
`verify_content` warnings for source-backed files with unavailable byte
evidence across all report formats, preserving comparison evidence,
source-reference differences, and operational errors.

- `piace compare --junit-out PATH` writes JUnit XML test results from the
shared redacted result. Each target becomes a test case; run diagnostics
get a separate case. Allowed differences pass, policy-disallowed
differences fail, and compilation or operational failures are errors.

## [0.5.0] - 2026-09-11

### Added

- Catalog snapshots retain captured single-file content digests and static
compiler metadata inside the checksummed payload. Capture provenance records
the requested and effective compiler APIs, fallback, trusted-fact source,
and fact source. Comparisons against file baselines expose that provenance
and any capture-time v3 trust warning.
- Resource additions and removals carry the existing side's redacted parameter
map and one-sided File-content evidence. Aggregate groups and change
assessments retain content state, evidence sources, and verification status.
Dependency-graph edge groups participate in the assessment budget.
- Service endpoints accept an optional `timeout`. Request deadlines use the
caller's timeout, then the service timeout, then the transport default,
without an additional fixed client timeout shortening the request.

### Changed

- **Artifact formats are incompatible with earlier releases.** Result documents
use `schema_version: 3`, and `explain` requires that version. Snapshots use
`format_version: 2`, replacing `compiler_api` with structured capture
provenance. Version-1 snapshots must be recaptured.
- File-content evidence is resolved independently for baseline and candidate,
even when the source reference is unchanged. Historical baselines use retained
evidence only. Source lists preserve order and try the next source only after
a recognized missing-file response. Checksums require a supported algorithm
and a full hexadecimal digest.
- Unverifiable File content produces a `verify_content` diagnostic rather than
a resource difference and does not drive `fail_on_diff`. Missing historical
digests, local paths, directory or recursive sources, and incompatible digest
algorithms produce warnings; failed retrievals and invalid checksums remain
errors. Changed source references remain differences even when their bytes
cannot be verified. Catalog capture can publish snapshots with limitation
warnings.
- Configuration requirements follow the command and selected sources.
`capture facts` no longer requires candidate or baseline configuration, and
file-only comparisons with impact estimation disabled need no PuppetDB
endpoint or credentials.
- Warning diagnostics appear in a counted, collapsed Notices section in HTML.
Documentation distinguishes byte-identical rendering of one result from
reproducible comparisons across different invocation timestamps.

### Fixed

- Relationship arrays compare without treating order as a change. Missing
compiler edges are no longer reconstructed from relationship parameters.
- Regular expressions compare against PuppetDB's measured stringified form
when the catalogs have different fidelities. Other unmeasured rich types
retain a warning when a difference may be representational.
- Files with `ensure: absent` or `ensure: link` manage no content bytes and
no longer trigger unsupported-content diagnostics.
- V4 catalog requests prefer the requested environment rather than allowing
the classifier's environment assignment to redirect compilation.
- Reports and assessments publish through atomic replacement; snapshot
no-clobber publication is atomic. Conflicting output destinations and outputs
that alias input files are rejected before service requests. Publication
failures identify which artifacts were written.
- Inference request size and omission accounting include the serialized
request, with deterministic evidence reduction when the byte budget is
exceeded.

### Security

- Resource-level `sensitive_parameters` and recursive Sensitive wrappers
survive normalization. Either catalog's sensitivity protects both values,
aggregate disclosure respects every member, and malformed sensitivity
metadata is rejected. Raw evidence and grouping fingerprints stay outside
the published projection.
- Endpoint URL credentials are rejected, compiler/PuppetDB requests are
restricted to the configured HTTPS authority, and untrusted debug metadata
is bounded and escaped.
- Requested targets, response certnames, snapshot envelopes and payloads,
and supplied trusted facts must agree. Malformed factsets and trusted-fact
structures fail validation before compilation.
- Comparisons reject PuppetDB baselines when direct v3 or permitted v3
fallback could overwrite them. Fallback requires explicit opt-in and is
limited to an ambiguous empty-body or literal `Not Found` HTTP 404;
HTTP 501 no longer triggers it.
- Stored reports are validated for outcome consistency, aggregate references,
identity, and disclosure before rendering or inference. Local reads, JSON
nesting, numeric expansion, git subprocess output and duration, and inference
request sizes are bounded.
- CI examples execute Sigstore signature verification before checksum
verification and fetch explicit refs for change-context generation.

## [0.4.0] - 2026-09-04

### Changed
Expand Down Expand Up @@ -357,6 +457,8 @@ than what changed.
The last two are recorded as skipped tests carrying their confirmation
procedures in `cmd/piace/acceptance_assumptions_test.go`.

[0.6.0]: https://github.com/example42/piace/releases/tag/v0.6.0
[0.5.0]: https://github.com/example42/piace/releases/tag/v0.5.0
[0.4.0]: https://github.com/example42/piace/releases/tag/v0.4.0
[0.3.0]: https://github.com/example42/piace/releases/tag/v0.3.0
[0.2.1]: https://github.com/example42/piace/releases/tag/v0.2.1
Expand Down
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ is that shape.
```
piace compare --targets TARGETS.yaml --services SERVICES.yaml \
[--candidate-environment ENVIRONMENT] \
[--text-out PATH] [--json-out PATH] [--html-out PATH] [--impact-nodes]
[--text-out PATH] [--json-out PATH] [--html-out PATH] [--junit-out PATH]
[--impact-nodes] [--suppress-source-content-warnings]

piace capture facts --targets TARGETS.yaml --services SERVICES.yaml [--replace]

Expand All @@ -112,7 +113,9 @@ piace change-context (--base-ref REF | --base-ref-env VAR) \
| `--text-out` | compare | Text report path; default stdout |
| `--json-out` | compare | Versioned, canonically encoded JSON report |
| `--html-out` | compare, explain | Self-contained static HTML report |
| `--junit-out` | compare | JUnit XML with one test case per target and a separate case for run diagnostics |
| `--impact-nodes` | compare | Name every certname an impact estimate returned, not a capped sample (text report only) |
| `--suppress-source-content-warnings` | compare | Silence `verify_content` warnings for source-backed files with unavailable byte evidence in every report format |
| `--environment` | capture catalog | Environment to compile the snapshot from (required) |
| `--replace` | capture | Overwrite an existing snapshot |
| `--json-in` | explain | Stored result document; `-` reads stdin (required) |
Expand All @@ -134,8 +137,8 @@ the production baseline, and never overrides `candidate.environment`.

### Reports

All three formats render from one redacted result document, so they cannot
disagree. Only the text report omits anything.
All four formats render from one redacted result document and use the same
outcomes. Text and JUnit summarize evidence; JSON and HTML are complete.

- **Text** (stdout by default): summarizes for a linear CI log. Omits
dependency-graph edge changes and each impact estimate's PQL and request
Expand All @@ -152,6 +155,26 @@ disagree. Only the text report omits anything.
needs. You land on an index of the run; every list of rows is a `<details>`
section whose heading counts what it holds. Failures, the v3 warning and
outcome badges never collapse. Printing expands everything.
- **JUnit XML** (`--junit-out`): one test case per target certname. Clean and
allowed differences pass; policy-disallowed differences produce `<failure>`;
compilation and operational failures produce `<error>`. Run diagnostics get
a separate case, with warnings passing and errors failing. Each case carries
redacted evidence and warnings in `<system-out>`, including edge changes.
Suite output carries the run outcome, aggregate resource summary, and
labelled impact estimates with all returned certnames. Aggregate edge groups,
impact PQL, and request options are omitted. Exit codes are unchanged.
Errors before the comparison starts, such as invalid configuration, do not
produce a report. See [CI integration](docs/ci.md#junit-test-results).

`--suppress-source-content-warnings` silences expected `verify_content`
notices for File resources with a `source` parameter when historical digests
are missing, sources are recursive or directories, or source bytes cannot be
served by the compiler (for example, a local path). It applies to all four
formats, including JSON. Evidence resolution still runs; changed source
references and resource membership remain differences. The option does not
assert byte equality. Retrieval errors, invalid checksums, warnings for
resources without a source, and incompatible digest algorithms remain visible.
`capture` keeps its existing diagnostics.

The text report drops edge changes because a run's edge differences usually
outnumber its resource differences and are usually connected to them, so a
Expand Down Expand Up @@ -539,7 +562,7 @@ against a PuppetDB baseline at all, because the baseline does not contain it.

**The v3 warning.** With `catalog_api: v3`, or any permitted v4-to-v3 fallback,
`$trusted` in the compiled catalog can reflect the catalog-reader certificate
rather than the target. The warning is non-suppressible, appears in all three
rather than the target. The warning is non-suppressible, appears in all four
formats, and does not change the exit status; it makes the trust semantics
reviewable. It also describes v3 persistence consequences, including on failed
requests. A file baseline protects input, not other PuppetDB consumers. v4 sends the target's own trusted facts, and fails compilation
Expand Down Expand Up @@ -635,7 +658,7 @@ Malformed sensitivity lists and wrappers fail normalization.
**What is verified, and what is not.** Two sensitivity representations are
recognized: a resource-level `sensitive_parameters` list, and a recursive Pcore
`Sensitive` wrapper. Synthetic tests cover both across compiler arrays and
PuppetDB expanded containers, all three report formats, normal debug output and
PuppetDB expanded containers, all four report formats, normal debug output and
the inference request.

What is now measured, on a deployed OpenVox 8.15.2 installation, is that a
Expand Down
75 changes: 75 additions & 0 deletions cmd/piace/acceptance_content_warnings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"encoding/json"
"reflect"
"strings"
"testing"

"github.com/example42/piace/internal/exitcode"
"github.com/example42/piace/internal/model"
)

func TestAcceptance_SuppressSourceContentWarnings(t *testing.T) {
h := newHarness(t)
baseline := []resourceSpec{
{Type: "File", Title: "/same", Parameters: map[string]any{"source": "puppet:///modules/app/same"}},
{Type: "File", Title: "/changed", Parameters: map[string]any{"source": "puppet:///modules/app/old"}},
{Type: "File", Title: "/directory", Parameters: map[string]any{"source": "puppet:///modules/app/dir", "recurse": true}},
{Type: "File", Title: "/local", Parameters: map[string]any{"source": "/local/file"}},
}
candidate := append([]resourceSpec(nil), baseline...)
candidate[1].Parameters = map[string]any{"source": "puppet:///modules/app/new"}
h.seedTarget("web-01.example.test", baseline, candidate, nil)
h.compiler.fileContent["modules/app/same"] = "same bytes"
h.compiler.fileContent["modules/app/new"] = "new bytes"
h.writeConfigs(t, targetsYAML(defaultDefaults, target("web-01.example.test")))
before := h.compare(t)
after := h.compare(t, "--suppress-source-content-warnings")
if before.code != exitcode.Success || after.code != before.code {
t.Fatalf("exits = %d/%d, stderr: %s / %s", before.code, after.code, before.stderr, after.stderr)
}
for format, artifact := range after.all() {
if strings.Contains(artifact, "verify_content") {
t.Errorf("%s still carries source warnings", format)
}
}
for format, artifact := range before.all() {
if !strings.Contains(artifact, "verify_content") {
t.Errorf("%s default warnings absent", format)
}
}
var original, quiet model.Result
if err := json.Unmarshal([]byte(before.json), &original); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(after.json), &quiet); err != nil {
t.Fatal(err)
}
if len(original.Targets[0].Diagnostics) != 4 || len(quiet.Targets[0].Diagnostics) != 0 {
t.Fatalf("diagnostic counts = %d/%d", len(original.Targets[0].Diagnostics), len(quiet.Targets[0].Diagnostics))
}
if !reflect.DeepEqual(original.Targets[0].NodeDiff, quiet.Targets[0].NodeDiff) || !reflect.DeepEqual(original.Aggregate, quiet.Aggregate) || original.Outcome != quiet.Outcome {
t.Fatal("suppression changed comparison evidence or outcome")
}
changes := quiet.Targets[0].NodeDiff.ResourceChanges
if len(changes) != 1 || changes[0].FileContent == nil || changes[0].FileContent.State != model.FileContentReferenceChanged {
t.Fatalf("source-reference difference disappeared: %+v", changes)
}
}

func TestAcceptance_SuppressSourceWarningsPreservesErrors(t *testing.T) {
h := newHarness(t)
resources := []resourceSpec{{Type: "File", Title: "/missing", Parameters: map[string]any{"source": "puppet:///modules/app/missing"}}}
h.seedTarget("web-01.example.test", resources, resources, nil)
h.writeConfigs(t, targetsYAML(defaultDefaults, target("web-01.example.test")))
got := h.compare(t, "--suppress-source-content-warnings")
if got.code != exitcode.OperationalError {
t.Fatalf("retrieval failure was silenced: exit %d, stderr: %s", got.code, got.stderr)
}
for format, artifact := range got.all() {
if !strings.Contains(artifact, "verify_content") {
t.Errorf("%s lost the retrieval error", format)
}
}
}
1 change: 1 addition & 0 deletions cmd/piace/acceptance_determinism_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ func TestAcceptance_ReportsAreByteIdenticalForIdenticalInputs(t *testing.T) {
{"text", first.text, second.text},
{"json", first.json, second.json},
{"html", first.html, second.html},
{"junit", first.junit, second.junit},
} {
if format.a != format.b {
t.Errorf("the %s artifact is not byte-identical across two runs over identical inputs\nfirst:\n%s\nsecond:\n%s",
Expand Down
3 changes: 3 additions & 0 deletions cmd/piace/acceptance_disclosure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ func TestAcceptance_NoReportDisclosesSecretsOrManagedBytes(t *testing.T) {
if got.text == "" {
t.Fatal("the text artifact was not produced, so it was not scanned")
}
if got.junit == "" {
t.Fatal("the JUnit artifact was not produced, so it was not scanned")
}
}

// TestAcceptance_FileContentEvidenceStates covers the file-content
Expand Down
10 changes: 7 additions & 3 deletions cmd/piace/acceptance_fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,22 @@ type artifacts struct {
stderr string
json string
html string
junit string
text string
}

// all returns every rendered artifact as one slice, for scans that must
// cover all three formats.
// cover all report formats.
func (a artifacts) all() map[string]string {
out := map[string]string{"stdout": a.stdout, "json": a.json, "html": a.html}
out := map[string]string{"stdout": a.stdout, "json": a.json, "html": a.html, "junit": a.junit}
if a.text != "" {
out["text"] = a.text
}
return out
}

// compare runs `piace compare` through the CLI entry point, always
// requesting the JSON and HTML artifacts, and returns everything the run
// requesting the JSON, HTML, and JUnit artifacts, and returns everything the run
// produced. Driving run() rather than compare.Workflow is the point of
// this suite: it exercises PEM loading, real mTLS handshakes, HTTP status
// and JSON decoding in the adapters, artifact writing, and the process
Expand All @@ -115,20 +116,23 @@ func (h *harness) compare(t *testing.T, extra ...string) artifacts {
suffix := fmt.Sprintf("%d", h.pdb.count())
jsonOut := h.path("report" + suffix + ".json")
htmlOut := h.path("report" + suffix + ".html")
junitOut := h.path("report" + suffix + ".xml")

args := append([]string{
"compare",
"--targets", h.path("targets.yaml"),
"--services", h.path("services.yaml"),
"--json-out", jsonOut,
"--html-out", htmlOut,
"--junit-out", junitOut,
}, extra...)

stdout, stderr, code := captureRun(t, args)

result := artifacts{code: code, stdout: stdout, stderr: stderr}
result.json = readIfExists(t, jsonOut)
result.html = readIfExists(t, htmlOut)
result.junit = readIfExists(t, junitOut)
for i, arg := range extra {
if arg == "--text-out" && i+1 < len(extra) {
result.text = readIfExists(t, extra[i+1])
Expand Down
Loading
Loading