From 09193ee9602d0e5f76897f450a0b6c9d58bce163 Mon Sep 17 00:00:00 2001 From: Alessandro Franceschi Date: Thu, 8 Oct 2026 17:34:51 +0200 Subject: [PATCH 1/2] Add JUnit XML output and a warning-suppression flag to `piace compare` - `--junit-out PATH` writes JUnit XML from the shared redacted result. Each target is a test case, and run diagnostics get a separate case. Allowed differences pass, policy-disallowed differences fail, and compilation or operational failures are errors. Exit codes are unchanged. - `--suppress-source-content-warnings` silences expected `verify_content` warnings for source-backed files whose byte evidence is unavailable. It applies to all report formats. Errors, non-source warnings, and mismatched digest algorithms stay visible. - The JUnit path is validated against the other output destinations, and the acceptance harness now produces and scans the JUnit artifact for disclosure and determinism. - Document the new options in the README, CI guide, and development guide. Add the 0.6.0 and 0.5.0 changelog entries, and ignore `*.report.xml` and `COMMAND_HISTORY.md`. --- .gitignore | 5 +- CHANGELOG.md | 102 ++++++++++++++++ README.md | 33 +++++- cmd/piace/acceptance_determinism_test.go | 1 + cmd/piace/acceptance_disclosure_test.go | 3 + cmd/piace/acceptance_fixture_test.go | 10 +- cmd/piace/acceptance_junit_test.go | 77 +++++++++++++ cmd/piace/main.go | 26 ++++- docs/ci.md | 34 ++++++ docs/development.md | 4 +- internal/aggregate/disclosure_test.go | 3 +- internal/aggregate/evidence_test.go | 5 +- internal/compare/workflow.go | 3 +- internal/diff/diff.go | 5 +- internal/diff/diff_test.go | 4 +- internal/diff/doc.go | 2 + internal/diff/resources.go | 14 ++- internal/filecontent/doc.go | 6 + internal/filecontent/evidence.go | 35 +++++- internal/filecontent/evidence_test.go | 2 +- internal/filecontent/membership_test.go | 4 +- internal/filecontent/phase2_test.go | 14 +-- internal/filecontent/unmanaged_test.go | 10 +- internal/report/doc.go | 32 ++--- internal/report/junit.go | 115 ++++++++++++++++++ internal/report/junit_test.go | 141 +++++++++++++++++++++++ 26 files changed, 629 insertions(+), 61 deletions(-) create mode 100644 cmd/piace/acceptance_junit_test.go create mode 100644 internal/report/junit.go create mode 100644 internal/report/junit_test.go diff --git a/.gitignore b/.gitignore index 0fed448..c8a2da1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ coverage.* *.report.json *.report.html *.report.txt +*.report.xml # macOS / editors .DS_Store @@ -37,4 +38,6 @@ coverage.* .vscode/ # Local files test snapshots -local/ \ No newline at end of file +local/ + +COMMAND_HISTORY.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1247cfa..aaae649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index cc3f248..ff444ca 100644 --- a/README.md +++ b/README.md @@ -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] @@ -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) | @@ -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 @@ -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 `
` 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 ``; + compilation and operational failures produce ``. Run diagnostics get + a separate case, with warnings passing and errors failing. Each case carries + redacted evidence and warnings in ``, 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 @@ -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 @@ -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 diff --git a/cmd/piace/acceptance_determinism_test.go b/cmd/piace/acceptance_determinism_test.go index ae765bc..0849a9f 100644 --- a/cmd/piace/acceptance_determinism_test.go +++ b/cmd/piace/acceptance_determinism_test.go @@ -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", diff --git a/cmd/piace/acceptance_disclosure_test.go b/cmd/piace/acceptance_disclosure_test.go index 109de26..0e4a67d 100644 --- a/cmd/piace/acceptance_disclosure_test.go +++ b/cmd/piace/acceptance_disclosure_test.go @@ -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 diff --git a/cmd/piace/acceptance_fixture_test.go b/cmd/piace/acceptance_fixture_test.go index f88b966..2d6a173 100644 --- a/cmd/piace/acceptance_fixture_test.go +++ b/cmd/piace/acceptance_fixture_test.go @@ -91,13 +91,14 @@ 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 } @@ -105,7 +106,7 @@ func (a artifacts) all() map[string]string { } // 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 @@ -115,6 +116,7 @@ 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", @@ -122,6 +124,7 @@ func (h *harness) compare(t *testing.T, extra ...string) artifacts { "--services", h.path("services.yaml"), "--json-out", jsonOut, "--html-out", htmlOut, + "--junit-out", junitOut, }, extra...) stdout, stderr, code := captureRun(t, args) @@ -129,6 +132,7 @@ func (h *harness) compare(t *testing.T, extra ...string) artifacts { 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]) diff --git a/cmd/piace/acceptance_junit_test.go b/cmd/piace/acceptance_junit_test.go new file mode 100644 index 0000000..1331c18 --- /dev/null +++ b/cmd/piace/acceptance_junit_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/xml" + "os" + "strings" + "testing" + + "github.com/example42/piace/internal/exitcode" +) + +func TestAcceptance_JUnitPolicyDifference(t *testing.T) { + h := newHarness(t) + h.seedTarget("web-01.example.test", baseResources(), changedResources(), baseEdges()) + h.writeConfigs(t, targetsYAML(defaultDefaults, target("web-01.example.test")+" fail_on_diff: true\n")) + got := h.compare(t) + if got.code != exitcode.PolicyDisallowedDifference { + t.Fatalf("exit = %d, stderr: %s", got.code, got.stderr) + } + var document struct { + Suite struct { + Failures int `xml:"failures,attr"` + Cases []struct { + Name string `xml:"name,attr"` + Failure *struct { + Details string `xml:",chardata"` + } `xml:"failure"` + } `xml:"testcase"` + } `xml:"testsuite"` + } + if err := xml.Unmarshal([]byte(got.junit), &document); err != nil { + t.Fatal(err) + } + if document.Suite.Failures != 1 || len(document.Suite.Cases) != 1 { + t.Fatalf("incorrect suite: %+v", document.Suite) + } + c := document.Suite.Cases[0] + if c.Name != "web-01.example.test" || c.Failure == nil || !strings.Contains(c.Failure.Details, "Service[nginx] ensure") { + t.Fatalf("incorrect policy failure: %+v", c) + } +} + +func TestAcceptance_JUnitDestinationConflicts(t *testing.T) { + for _, destination := range []string{"targets.yaml", "same.xml"} { + t.Run(destination, func(t *testing.T) { + h := newHarness(t) + h.seedTarget("web-01.example.test", baseResources(), baseResources(), baseEdges()) + h.writeConfigs(t, targetsYAML(defaultDefaults, target("web-01.example.test"))) + before := readFile(t, h.path("targets.yaml")) + extra := []string{"--junit-out", h.path(destination)} + if destination == "same.xml" { + extra = append(extra, "--text-out", h.path(destination)) + } + got := h.compare(t, extra...) + if got.code != exitcode.OperationalError || !strings.Contains(got.stderr, "--junit-out") || !strings.Contains(got.stderr, "conflicting file destinations") { + t.Fatalf("exit = %d, stderr: %s", got.code, got.stderr) + } + if readFile(t, h.path("targets.yaml")) != before || h.pdb.count() != 0 { + t.Fatal("conflicting destination modified input or contacted services") + } + }) + } +} + +func TestAcceptance_JUnitWriteFailure(t *testing.T) { + h := newHarness(t) + h.seedTarget("web-01.example.test", baseResources(), baseResources(), baseEdges()) + h.writeConfigs(t, targetsYAML(defaultDefaults, target("web-01.example.test"))) + destination := h.path("report-directory") + if err := os.Mkdir(destination, 0o700); err != nil { + t.Fatal(err) + } + got := h.compare(t, "--junit-out", destination) + if got.code != exitcode.OperationalError || !strings.Contains(got.stderr, "writing JUnit report") || got.stdout != "" { + t.Fatalf("exit = %d, stdout: %s, stderr: %s", got.code, got.stdout, got.stderr) + } +} diff --git a/cmd/piace/main.go b/cmd/piace/main.go index cedc21e..2d6feaf 100644 --- a/cmd/piace/main.go +++ b/cmd/piace/main.go @@ -109,7 +109,8 @@ func run(args []string, stdout, stderr *os.File) exitcode.Code { func usage() string { return `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 piace capture catalog --targets TARGETS.yaml --services SERVICES.yaml \ --environment ENVIRONMENT @@ -205,10 +206,12 @@ type compareFlags struct { // file before resolution: it is configuration, not display policy, // and the report's provenance must record what was actually // compiled. See resolve.Overrides. - candidateEnvironment string - textOut string - jsonOut string - htmlOut string + candidateEnvironment string + textOut string + jsonOut string + htmlOut string + junitOut string + suppressSourceContentWarnings bool // impactNodes is display policy for the text report only; it never // reaches resolve.Config, because what PIACE queries and what PIACE // prints are separate concerns and an estimate's certname sample is @@ -227,7 +230,9 @@ func runCompare(args []string, stdout, stderr *os.File) exitcode.Code { fs.StringVar(&f.textOut, "text-out", "", "path to write the text report (default: stdout)") fs.StringVar(&f.jsonOut, "json-out", "", "path to write the versioned JSON report") fs.StringVar(&f.htmlOut, "html-out", "", "path to write the static HTML report") + fs.StringVar(&f.junitOut, "junit-out", "", "path to write the JUnit XML report") fs.BoolVar(&f.impactNodes, "impact-nodes", false, "list every certname an impact estimate returned instead of a capped sample (text report only)") + fs.BoolVar(&f.suppressSourceContentWarnings, "suppress-source-content-warnings", false, "silence verify_content warnings for source-backed files with unavailable byte evidence (all report formats)") f.debug.register(fs) if err := fs.Parse(args); err != nil { return exitcode.OperationalError @@ -252,6 +257,7 @@ func runCompare(args []string, stdout, stderr *os.File) exitcode.Code { if err := artifact.ValidateDestinations(comparisonInputs(f, cfg), []artifact.File{ {Role: "--json-out", Path: f.jsonOut}, {Role: "--html-out", Path: f.htmlOut}, + {Role: "--junit-out", Path: f.junitOut}, {Role: "--text-out", Path: f.textOut}, }); err != nil { fmt.Fprintf(stderr, "piace compare: %s\n", err) @@ -269,6 +275,7 @@ func runCompare(args []string, stdout, stderr *os.File) exitcode.Code { return exitcode.OperationalError } + workflow.ContentOptions = filecontent.Options{SuppressSourceContentWarnings: f.suppressSourceContentWarnings} result := workflow.Run(context.Background(), cfg) if err := writeReports(f, result, stdout); err != nil { @@ -378,7 +385,7 @@ func newCompareWorkflow(cfg resolve.Config, debugOpts []transport.Option) (*comp } // writeReports emits the requested artifacts. Text goes to stdout when -// --text-out is omitted; JSON and HTML are written only when explicitly +// --text-out is omitted; JSON, HTML, and JUnit are written only when explicitly // requested. // // The file artifacts are written before the text report, and the stdout @@ -423,6 +430,13 @@ func writeReports(f compareFlags, result model.Result, stdout *os.File) error { } pending = append(pending, pendingArtifact{role: "HTML report", path: f.htmlOut, data: data}) } + if f.junitOut != "" { + data, err := report.JUnit(result) + if err != nil { + return err + } + pending = append(pending, pendingArtifact{role: "JUnit report", path: f.junitOut, data: data}) + } text, err := report.Text(result, nil, opts) if err != nil { return err diff --git a/docs/ci.md b/docs/ci.md index 47419e6..c70ca71 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -124,6 +124,40 @@ creates nothing and has nothing to clean up. Produced by the run, in the workspace, uploaded as job artifacts: `report.json`, `report.html`, `assessment.json`. +### JUnit test results + +With a build supporting `--junit-out`, add `--junit-out report.xml` to the +comparison command alongside the JSON and HTML outputs. The shipped pipeline +examples pin release 0.4.0, which predates this flag; update the binary before +using it. + +For GitLab, add the XML file to the comparison job's existing artifact block: + +```yaml +artifacts: + when: always + access: 'developer' + paths: + - report.json + - report.html + - report.xml + reports: + junit: report.xml +``` + +GitLab displays cases in the pipeline Tests tab and merge-request test summary. +It reads evidence from each case's `system-out`; suite-level output remains in +the downloadable XML. JUnit reports do not decide job status: PIACE's exit +code still controls the job, including its existing `allow_failure` policy. +See [GitLab's JUnit documentation](https://docs.gitlab.com/ci/testing/unit_test_reports/). + +Each target certname is a case in `piace.target`. Allowed differences pass and +retain their redacted details; policy-disallowed differences fail. Compilation +and operational errors are errors. A separate `piace.run` case appears when +run-level diagnostics exist, so an impact-query error remains visible even +when all target cases pass. Warnings alone pass. Configuration errors before +the comparison starts do not produce an XML report. + ### Why nothing is rendered Every relative path in a config file resolves against the directory of the diff --git a/docs/development.md b/docs/development.md index 1c2f60f..d0a0bfb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -102,7 +102,7 @@ internal/diff/ Node diffing, exclusions, redaction (fixed ordering) internal/aggregate/ Cross-target grouping internal/impact/ Bounded PQL estimates internal/compare/ The compare pipeline -internal/report/ Text, JSON, and HTML renderers; reading a report back +internal/report/ Text, JSON, HTML, and JUnit renderers; reading a report back internal/model/ Shared result document and the outcome reducer internal/exitcode/ The stable exit codes and the outcome precedence order internal/assess/ Change assessment: what may leave, and what came back @@ -124,7 +124,7 @@ send it, and is reviewable with no knowledge of catalogs: it does not import ## Invariants the test suite enforces -- **Disclosure**: no report in any of the three formats carries credentials, +- **Disclosure**: no report in any of the four formats carries credentials, private key material, managed file content bytes, or unredacted sensitive values (`cmd/piace/acceptance_disclosure_test.go`). The same assertion is made at the inference request seam (`internal/assess/request_test.go`). diff --git a/internal/aggregate/disclosure_test.go b/internal/aggregate/disclosure_test.go index a6b78fd..07d9431 100644 --- a/internal/aggregate/disclosure_test.go +++ b/internal/aggregate/disclosure_test.go @@ -11,6 +11,7 @@ import ( "github.com/example42/piace/internal/config" "github.com/example42/piace/internal/config/resolve" "github.com/example42/piace/internal/diff" + "github.com/example42/piace/internal/filecontent" "github.com/example42/piace/internal/model" "github.com/example42/piace/internal/report" ) @@ -36,7 +37,7 @@ func TestGroupDisclosureFromDifferThroughConsumers(t *testing.T) { if name == protectedName && (policy == "selector" || policy == "wrapper_and_selector") { target.Redact = []config.RedactionSelector{{Type: "User", Parameter: "password"}} } - nd, diagnostics := diff.Diff(context.Background(), target, catalog("old-secret", true), catalog("new-secret", false), nil) + nd, diagnostics := diff.Diff(context.Background(), target, catalog("old-secret", true), catalog("new-secret", false), nil, filecontent.Options{}) if len(diagnostics) != 0 || len(nd.ResourceChanges) != 1 { t.Fatalf("comparison failed: %+v, %+v", nd, diagnostics) } diff --git a/internal/aggregate/evidence_test.go b/internal/aggregate/evidence_test.go index 20ab79a..1802cbc 100644 --- a/internal/aggregate/evidence_test.go +++ b/internal/aggregate/evidence_test.go @@ -12,6 +12,7 @@ import ( "github.com/example42/piace/internal/config/resolve" "github.com/example42/piace/internal/diff" "github.com/example42/piace/internal/exitcode" + "github.com/example42/piace/internal/filecontent" "github.com/example42/piace/internal/model" "github.com/example42/piace/internal/report" ) @@ -40,7 +41,7 @@ func TestMembershipEvidenceGroupingAndDisclosure(t *testing.T) { if removed { before, after = after, before } - nd, ds := diff.Diff(context.Background(), resolve.Target{Certname: spec.name, Redact: []config.RedactionSelector{{Type: resourceType, Parameter: "token"}}}, before, after, nil) + nd, ds := diff.Diff(context.Background(), resolve.Target{Certname: spec.name, Redact: []config.RedactionSelector{{Type: resourceType, Parameter: "token"}}}, before, after, nil, filecontent.Options{}) if len(ds) != 0 || len(nd.ResourceChanges) != 1 { t.Fatalf("diff failed: %+v %+v", nd, ds) } @@ -206,7 +207,7 @@ func TestDistinctSensitiveMembershipEvidenceNeverMerges(t *testing.T) { if removed { before, after = after, before } - nd, ds := diff.Diff(context.Background(), resolve.Target{Certname: secret[:1]}, before, after, nil) + nd, ds := diff.Diff(context.Background(), resolve.Target{Certname: secret[:1]}, before, after, nil, filecontent.Options{}) if len(ds) != 0 { t.Fatal(ds) } diff --git a/internal/compare/workflow.go b/internal/compare/workflow.go index 91e716a..5da3413 100644 --- a/internal/compare/workflow.go +++ b/internal/compare/workflow.go @@ -47,6 +47,7 @@ type Workflow struct { // content_indeterminate with a diagnostic rather than claiming a // verified comparison. ContentRetriever filecontent.ContentRetriever + ContentOptions filecontent.Options // ImpactQuerier issues the bounded PQL estimates. See doc.go for why // a nil querier is reported rather than skipped. ImpactQuerier impact.ImpactQuerier @@ -180,7 +181,7 @@ func (w *Workflow) compareTarget(ctx context.Context, target resolve.Target) mod } candidate.ContentContext = model.ContentContext{Source: "compiler", Environment: candidate.Environment, CatalogIdentity: candidateCatalog.CodeID} - nodeDiff, diagnostics := diff.Diff(ctx, target, baseline, candidate, w.ContentRetriever) + nodeDiff, diagnostics := diff.Diff(ctx, target, baseline, candidate, w.ContentRetriever, w.ContentOptions) tr.NodeDiff = &nodeDiff tr.Diagnostics = append(tr.Diagnostics, diagnostics...) return tr diff --git a/internal/diff/diff.go b/internal/diff/diff.go index b161153..24cc0b8 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -10,7 +10,7 @@ import ( // Diff compares one target's baseline and candidate normalized catalogs // and returns that target's complete model.NodeDiff plus every -// diagnostic produced while resolving File-content evidence. It is this +// unsuppressed diagnostic produced while resolving File-content evidence. It is this // package's only entry point; see doc.go for the fixed three-pass // ordering it implements and why that ordering is required. // @@ -33,6 +33,7 @@ func Diff( target resolve.Target, before, after model.NormalizedCatalog, retriever filecontent.ContentRetriever, + contentOptions filecontent.Options, ) (model.NodeDiff, []model.Diagnostic) { beforeResources := indexResources(before.Resources) afterResources := indexResources(after.Resources) @@ -40,7 +41,7 @@ func Diff( // Pass 1: full graph diff, computed with no knowledge of exclusion // or redaction configuration. resourceChanges, diagnostics := diffResources(ctx, target.Certname, before.ContentContext, after.ContentContext, - beforeResources, afterResources, retriever, fidelity{before: before.StringifiedRich, after: after.StringifiedRich}) + beforeResources, afterResources, retriever, fidelity{before: before.StringifiedRich, after: after.StringifiedRich}, contentOptions) edgeChanges := diffEdges(before.Edges, after.Edges) // Fingerprints digest the unredacted evidence and must therefore be diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index 10b9064..bb11082 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -73,7 +73,7 @@ func (s stubRetriever) Digest(_ context.Context, reference string, _ filecontent func run(t *testing.T, tgt resolve.Target, before, after model.NormalizedCatalog, retriever filecontent.ContentRetriever) (model.NodeDiff, []model.Diagnostic) { t.Helper() - return Diff(context.Background(), tgt, before, after, retriever) + return Diff(context.Background(), tgt, before, after, retriever, filecontent.Options{}) } func findChange(t *testing.T, nd model.NodeDiff, resourceType, title, parameter string) model.ResourceChange { @@ -1007,7 +1007,7 @@ func BenchmarkDiff_LargeCatalog(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - nd, _ := Diff(context.Background(), target, before, after, nil) + nd, _ := Diff(context.Background(), target, before, after, nil, filecontent.Options{}) if !nd.HasDifference { b.Fatal("the fixture produced no difference") } diff --git a/internal/diff/doc.go b/internal/diff/doc.go index 3b392a3..48c19a2 100644 --- a/internal/diff/doc.go +++ b/internal/diff/doc.go @@ -35,6 +35,8 @@ // side's parameter map with the same sensitivity and selector rules. All File // content-bearing parameters become markers; separate one-sided evidence // records verified content or an indeterminate state with a diagnostic. +// Content options can silence missing-evidence warnings for source-backed +// resources without suppressing errors or changing any evidence state. // // Fingerprints cover raw canonical evidence before publication, including File // content parameters and resolved evidence. Distinct sensitive changes therefore diff --git a/internal/diff/resources.go b/internal/diff/resources.go index 9031c71..2903256 100644 --- a/internal/diff/resources.go +++ b/internal/diff/resources.go @@ -45,6 +45,7 @@ func diffResources( beforeResources, afterResources map[model.ResourceIdentity]model.Resource, retriever filecontent.ContentRetriever, f fidelity, + contentOptions filecontent.Options, ) ([]rawResourceChange, []model.Diagnostic) { var changes []rawResourceChange var diagnostics []model.Diagnostic @@ -56,20 +57,20 @@ func diffResources( switch { case inBefore && !inAfter: - change, diag := membershipChange(ctx, certname, model.ChangeResourceRemoved, filecontent.Side{Resource: beforeRes, Context: beforeContext}, retriever) + change, diag := membershipChange(ctx, certname, model.ChangeResourceRemoved, filecontent.Side{Resource: beforeRes, Context: beforeContext}, retriever, contentOptions) changes = append(changes, change) if diag != nil { diagnostics = append(diagnostics, *diag) } case !inBefore && inAfter: - change, diag := membershipChange(ctx, certname, model.ChangeResourceAdded, filecontent.Side{Resource: afterRes, Context: afterContext}, retriever) + change, diag := membershipChange(ctx, certname, model.ChangeResourceAdded, filecontent.Side{Resource: afterRes, Context: afterContext}, retriever, contentOptions) changes = append(changes, change) if diag != nil { diagnostics = append(diagnostics, *diag) } default: paramChanges, diags := diffParameters(ctx, certname, identity, - filecontent.Side{Resource: beforeRes, Context: beforeContext}, filecontent.Side{Resource: afterRes, Context: afterContext}, retriever, f) + filecontent.Side{Resource: beforeRes, Context: beforeContext}, filecontent.Side{Resource: afterRes, Context: afterContext}, retriever, f, contentOptions) changes = append(changes, paramChanges...) diagnostics = append(diagnostics, diags...) } @@ -78,7 +79,7 @@ func diffResources( return changes, diagnostics } -func membershipChange(ctx context.Context, certname string, kind model.ChangeKind, side filecontent.Side, retriever filecontent.ContentRetriever) (rawResourceChange, *model.Diagnostic) { +func membershipChange(ctx context.Context, certname string, kind model.ChangeKind, side filecontent.Side, retriever filecontent.ContentRetriever, contentOptions filecontent.Options) (rawResourceChange, *model.Diagnostic) { change := rawResourceChange{Kind: kind, Identity: side.Resource.Identity} if kind == model.ChangeResourceAdded { change.After = side.Resource.Parameters @@ -88,7 +89,7 @@ func membershipChange(ctx context.Context, certname string, kind model.ChangeKin if !filecontent.NeedsEvidence(side.Resource) { return change, nil } - evidence, diag := filecontent.ResolveMembershipEvidence(ctx, certname, kind, side, retriever) + evidence, diag := filecontent.ResolveMembershipEvidence(ctx, certname, kind, side, retriever, contentOptions) change.FileContent = &evidence return change, diag } @@ -108,6 +109,7 @@ func diffParameters( beforeSide, afterSide filecontent.Side, retriever filecontent.ContentRetriever, f fidelity, + contentOptions filecontent.Options, ) ([]rawResourceChange, []model.Diagnostic) { var changes []rawResourceChange var diagnostics []model.Diagnostic @@ -155,7 +157,7 @@ func diffParameters( if isFile && (fileContentDiffers || filecontent.NeedsEvidence(beforeSide.Resource) || filecontent.NeedsEvidence(afterSide.Resource)) { evidence, diag := filecontent.ResolveFileContentEvidence(ctx, certname, - identity, beforeSide, afterSide, retriever) + identity, beforeSide, afterSide, retriever, contentOptions) if diag != nil { diagnostics = append(diagnostics, *diag) } diff --git a/internal/filecontent/doc.go b/internal/filecontent/doc.go index 4999182..06e21e2 100644 --- a/internal/filecontent/doc.go +++ b/internal/filecontent/doc.go @@ -77,6 +77,12 @@ // that reaches the exit code. In capture the same split decides whether a // snapshot is published at all. // +// Comparison may opt into SuppressSourceContentWarnings. It omits warning +// diagnostics for resources carrying source when byte evidence is unavailable, +// including resource membership evidence. Resolution and evidence states are +// unchanged. Errors, non-source warnings, and incompatible verified digest +// algorithms remain diagnostics. Capture uses the default diagnostic policy. +// // Wire provenance: compiler/testdata/static-catalog.json is reduced from // Puppet's published static catalog example, not a live capture. Source-array // behavior follows lib/puppet/type/file/source.rb. Existing repository notes diff --git a/internal/filecontent/evidence.go b/internal/filecontent/evidence.go index e649a6f..4894a29 100644 --- a/internal/filecontent/evidence.go +++ b/internal/filecontent/evidence.go @@ -19,6 +19,28 @@ type Side struct { Context model.ContentContext } +// Options controls notices without changing evidence resolution or its state. +type Options struct { + SuppressSourceContentWarnings bool +} + +func (o Options) suppressSourceWarning(severity model.DiagnosticSeverity, evidence model.FileContentEvidence, sides ...Side) bool { + if !o.SuppressSourceContentWarnings || severity != model.SeverityWarning { + return false + } + // Two verified digests with different algorithms are a distinct warning, + // even when the resource also carries a source parameter. + if (evidence.Before == nil || evidence.Before.Verified) && (evidence.After == nil || evidence.After.Verified) { + return false + } + for _, side := range sides { + if side.Resource.Parameters["source"] != nil { + return true + } + } + return false +} + // ValidateContentDigest reports whether d is a digest this package could // have produced: a supported algorithm and a full hexadecimal value of // that algorithm's length, optionally carrying Puppet's matching @@ -46,7 +68,7 @@ func NeedsEvidence(r model.Resource) bool { // Membership evidence describes only the catalog side that exists. Removing a // resource from a catalog does not assert that Puppet will delete its file. -func ResolveMembershipEvidence(ctx context.Context, certname string, kind model.ChangeKind, side Side, retriever ContentRetriever) (model.FileContentEvidence, *model.Diagnostic) { +func ResolveMembershipEvidence(ctx context.Context, certname string, kind model.ChangeKind, side Side, retriever ContentRetriever, opts Options) (model.FileContentEvidence, *model.Diagnostic) { if !managesContent(parameters(side.Resource)) { return model.FileContentEvidence{State: model.FileContentNotManaged}, nil } @@ -61,6 +83,9 @@ func ResolveMembershipEvidence(ctx context.Context, certname string, kind model. if err != nil { e.State = model.FileContentIndeterminate reason, severity := classifyEvidenceError(err) + if opts.suppressSourceWarning(severity, e, side) { + return e, nil + } d := verifyContentDiagnostic(severity, certname, side.Resource.Identity, "resource membership changed; "+reason) return e, &d } @@ -73,7 +98,7 @@ func ResolveMembershipEvidence(ctx context.Context, certname string, kind model. return e, nil } -func ResolveFileContentEvidence(ctx context.Context, certname string, identity model.ResourceIdentity, before, after Side, retriever ContentRetriever) (model.FileContentEvidence, *model.Diagnostic) { +func ResolveFileContentEvidence(ctx context.Context, certname string, identity model.ResourceIdentity, before, after Side, retriever ContentRetriever, opts Options) (model.FileContentEvidence, *model.Diagnostic) { bp, ap := parameters(before.Resource), parameters(after.Resource) // Checked before any evidence resolution: a side that manages no // bytes has no desired content for the other side to be compared @@ -95,6 +120,9 @@ func ResolveFileContentEvidence(ctx context.Context, certname string, identity m if referenceChanged { e.State = model.FileContentReferenceChanged } + if opts.suppressSourceWarning(model.SeverityWarning, e, before, after) { + return e, nil + } d := verifyContentDiagnostic(model.SeverityWarning, certname, identity, "directory, recursive or non-file source: byte-level content comparison is unsupported; recursive sourceselect rules are not evaluated") return e, &d } @@ -144,6 +172,9 @@ func ResolveFileContentEvidence(ctx context.Context, certname string, identity m // has no single set of bytes. reason, severity = "the two catalogs carry content digests of different algorithms, which cannot be compared", model.SeverityWarning } + if opts.suppressSourceWarning(severity, e, before, after) { + return e, nil + } d := verifyContentDiagnostic(severity, certname, identity, reason) return e, &d } diff --git a/internal/filecontent/evidence_test.go b/internal/filecontent/evidence_test.go index 661a410..2bd77da 100644 --- a/internal/filecontent/evidence_test.go +++ b/internal/filecontent/evidence_test.go @@ -551,5 +551,5 @@ func TestIsDirectoryOrRecursive(t *testing.T) { func resolveEvidenceTest(ctx context.Context, certname, environment string, identity model.ResourceIdentity, before, after map[string]model.Value, retriever ContentRetriever) (model.FileContentEvidence, *model.Diagnostic) { c := model.ContentContext{Source: "compiler", Environment: environment} - return ResolveFileContentEvidence(ctx, certname, identity, Side{Resource: model.Resource{Identity: identity, Parameters: before}, Context: c}, Side{Resource: model.Resource{Identity: identity, Parameters: after}, Context: c}, retriever) + return ResolveFileContentEvidence(ctx, certname, identity, Side{Resource: model.Resource{Identity: identity, Parameters: before}, Context: c}, Side{Resource: model.Resource{Identity: identity, Parameters: after}, Context: c}, retriever, Options{}) } diff --git a/internal/filecontent/membership_test.go b/internal/filecontent/membership_test.go index e082fd2..7837315 100644 --- a/internal/filecontent/membership_test.go +++ b/internal/filecontent/membership_test.go @@ -10,7 +10,7 @@ import ( func TestMembershipEvidenceResolvesOnlyExistingSide(t *testing.T) { for _, kind := range []model.ChangeKind{model.ChangeResourceAdded, model.ChangeResourceRemoved} { side := Side{Resource: model.Resource{Identity: model.ResourceIdentity{Type: "File", Title: "/app"}, Parameters: map[string]any{"content": "bytes"}}} - e, d := ResolveMembershipEvidence(context.Background(), "node", kind, side, nil) + e, d := ResolveMembershipEvidence(context.Background(), "node", kind, side, nil, Options{}) if d != nil || e.Algorithm != "sha256" { t.Fatalf("resolution failed: %+v %+v", e, d) } @@ -37,7 +37,7 @@ func TestMembershipEvidenceDisclosesMissingOrUnsupportedEvidence(t *testing.T) { {map[string]any{"checksum_value": "invalid-secret"}, model.SeverityError}, } { side := Side{Resource: model.Resource{Identity: model.ResourceIdentity{Type: "File", Title: "/app"}, Parameters: tc.params}, Context: model.ContentContext{Historical: true}} - e, d := ResolveMembershipEvidence(context.Background(), "node", model.ChangeResourceRemoved, side, nil) + e, d := ResolveMembershipEvidence(context.Background(), "node", model.ChangeResourceRemoved, side, nil, Options{}) if d == nil || d.Severity != tc.want || e.State != model.FileContentIndeterminate || e.Before.Verified || e.BeforeDigest != "" || e.After != nil { t.Fatalf("unsupported evidence claimed verified: %+v %+v", e, d) } diff --git a/internal/filecontent/phase2_test.go b/internal/filecontent/phase2_test.go index 4ec1b97..b6bbf10 100644 --- a/internal/filecontent/phase2_test.go +++ b/internal/filecontent/phase2_test.go @@ -32,18 +32,18 @@ func TestIndependentEnvironmentAndHistoricalEvidence(t *testing.T) { r := &recordingRetriever{get: func(_ string, rc RetrievalContext) (DigestEvidence, error) { return DigestEvidence{Algorithm: "sha256", Digest: hashLocalContent(rc.Environment)}, nil }} - e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r) + e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r, Options{}) if d != nil || e.State != model.FileContentChanged || len(r.calls) != 2 || r.calls[0] == r.calls[1] { t.Fatalf("independent environments lost: %+v, %+v, %v", e, d, r.calls) } before.Context.Historical = true r.calls = nil - e, d = ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r) + e, d = ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r, Options{}) if d == nil || e.State != model.FileContentIndeterminate || len(r.calls) != 1 || !strings.HasPrefix(r.calls[0], "candidate:") { t.Fatalf("historical source fetched from current environment: %+v, %+v, %v", e, d, r.calls) } before.Resource.CapturedContent = &model.ContentDigest{Algorithm: "sha256", Digest: hashLocalContent("production")} - e, d = ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r) + e, d = ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r, Options{}) if d != nil || e.State != model.FileContentChanged || e.Before.Source != model.FileContentEvidenceCaptured || !e.Before.Verified { t.Fatalf("captured evidence not used: %+v, %+v", e, d) } @@ -81,7 +81,7 @@ func TestChecksumValidationAndEquivalentForms(t *testing.T) { } for _, value := range []string{"audit-new-secret", "", strings.Repeat("g", 64), strings.Repeat("a", 63), "{md5}" + strings.Repeat("a", 64), "{sha256}", strings.Repeat("a", 64) + "\n"} { s := evidenceSide("candidate", false, map[string]any{"checksum": "sha256", "checksum_value": value}) - e, d := ResolveFileContentEvidence(context.Background(), "node", s.Resource.Identity, s, s, nil) + e, d := ResolveFileContentEvidence(context.Background(), "node", s.Resource.Identity, s, s, nil, Options{}) if d == nil || e.State == model.FileContentUnchanged || e.BeforeDigest != "" || e.AfterDigest != "" { t.Fatal("invalid checksum published") } @@ -103,12 +103,12 @@ func TestStaticMetadataAndMixedEvidence(t *testing.T) { before.Resource.StaticContent.Checksum.Type = "sha256" before.Resource.StaticContent.Checksum.Value = "{sha256}" + hashLocalContent("inline") after := evidenceSide("candidate", false, map[string]any{"content": "inline"}) - e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil) + e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil, Options{}) if d != nil || e.State != model.FileContentUnchanged || e.Before.Source != model.FileContentEvidenceStaticMetadata || e.After.Source != model.FileContentEvidenceInline { t.Fatalf("static/inline mismatch: %+v %+v", e, d) } before.Resource.StaticContent.Checksum.Value = "audit-new-secret" - e, d = ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil) + e, d = ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil, Options{}) if d == nil || e.State != model.FileContentReferenceChanged || e.BeforeDigest != "" { t.Fatal("bad static metadata accepted") } @@ -123,7 +123,7 @@ func TestRecursiveMetadataNeverBecomesASingleFileDigest(t *testing.T) { t.Fatal("recursive content retrieved as one file") return DigestEvidence{}, nil }} - e, d := ResolveFileContentEvidence(context.Background(), "node", s.Resource.Identity, s, s, r) + e, d := ResolveFileContentEvidence(context.Background(), "node", s.Resource.Identity, s, s, r, Options{}) if d == nil || e.State != model.FileContentIndeterminate || len(r.calls) != 0 { t.Fatal("recursive comparison appears verified") } diff --git a/internal/filecontent/unmanaged_test.go b/internal/filecontent/unmanaged_test.go index e75824e..6affc6b 100644 --- a/internal/filecontent/unmanaged_test.go +++ b/internal/filecontent/unmanaged_test.go @@ -34,7 +34,7 @@ func TestUnmanagedContentIsNotAFailedComparison(t *testing.T) { before := Side{Resource: absentFile(map[string]any{"ensure": "absent", "content": "one"})} after := Side{Resource: absentFile(map[string]any{"ensure": "absent", "content": "two"})} - e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil) + e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil, Options{}) if d != nil { t.Errorf("an unmanaged File produced a diagnostic: %+v", d) } @@ -56,7 +56,7 @@ func TestUnmanagedOnEitherSideAloneIsEnough(t *testing.T) { } { t.Run(name, func(t *testing.T) { for _, sides := range [][2]Side{{managed, unmanaged}, {unmanaged, managed}} { - e, d := ResolveFileContentEvidence(context.Background(), "node", managed.Resource.Identity, sides[0], sides[1], nil) + e, d := ResolveFileContentEvidence(context.Background(), "node", managed.Resource.Identity, sides[0], sides[1], nil, Options{}) if d != nil { t.Errorf("diagnostic for a side that manages no bytes: %+v", d) } @@ -74,7 +74,7 @@ func TestUnmanagedMembershipChangeReportsNoContentEvidence(t *testing.T) { Resource: absentFile(map[string]any{"ensure": "absent", "content": "one"}), Context: model.ContentContext{Historical: true}, } - e, d := ResolveMembershipEvidence(context.Background(), "node", kind, side, nil) + e, d := ResolveMembershipEvidence(context.Background(), "node", kind, side, nil, Options{}) if d != nil { t.Errorf("%s: diagnostic for a resource managing no bytes: %+v", kind, d) } @@ -95,7 +95,7 @@ func TestUnsupportedByteComparisonsStillAre(t *testing.T) { } { t.Run(name, func(t *testing.T) { side := Side{Resource: absentFile(params), Context: model.ContentContext{Historical: true}} - e, d := ResolveMembershipEvidence(context.Background(), "node", model.ChangeResourceRemoved, side, nil) + e, d := ResolveMembershipEvidence(context.Background(), "node", model.ChangeResourceRemoved, side, nil, Options{}) if d == nil || e.State != model.FileContentIndeterminate { t.Fatalf("unsupported byte evidence was not reported: %+v %+v", e, d) } @@ -119,7 +119,7 @@ func TestMismatchedDigestAlgorithmsAreAWarning(t *testing.T) { Parameters: map[string]any{"content": "bytes"}, }} - e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil) + e, d := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, nil, Options{}) if e.State != model.FileContentIndeterminate { t.Errorf("state = %q, want %q", e.State, model.FileContentIndeterminate) } diff --git a/internal/report/doc.go b/internal/report/doc.go index fc049d0..81be969 100644 --- a/internal/report/doc.go +++ b/internal/report/doc.go @@ -1,11 +1,10 @@ // Package report renders the shared result document (model.Result) into -// the three output formats internal/report owns: concise CI text, the versioned -// canonical JSON report, and a single self-contained `file://` HTML -// artifact. +// four output formats: concise CI text, versioned canonical JSON, +// self-contained HTML, and JUnit XML for CI test results. // -// # One projection, three formats +// # One projection, four formats // -// HTML, text, and JSON derive from the same redacted projection, which +// HTML, text, JSON, and JUnit derive from the same redacted projection, which // prevents format drift and secret exposure. That projection is // model.Result itself. Redaction has already happened upstream, inside // internal/diff's pass 3, strictly before serialization, and the @@ -13,8 +12,9 @@ // model.ResourceChange.Fingerprint is `json:"-"` and the raw canonical // values are gone by the time a Result exists. No renderer in this // package can therefore disclose a sensitive value, because none of them -// has access to one. Nothing here re-derives, re-orders, or re-computes -// anything: the three functions differ only in encoding. +// has access to one. Renderers use the classified target outcomes rather +// than deciding whether a difference is allowed themselves. JUnit also +// reduces run diagnostics alone for its separate diagnostic case. // // The shared formatting helpers in render.go exist for the same reason. // A value rendered one way in text and another way in HTML is format @@ -40,11 +40,11 @@ // been in a file. Redaction markers are not omissions and stay readable: // a redacted entry is present, marked, and carries no digest. // -// # Three formats, three amounts of detail +// # Report detail // -// The three formats show the same document at three levels of detail. -// This is display policy, not a second projection: nothing here filters -// or recomputes what a comparison found, and every format decides through +// The formats show the same document at different levels of detail. +// This is display policy, not a second projection: renderers do not +// recompute what a comparison found, and every format decides through // the same shared helpers (formatValue, changeSummary/changeParts, // estimateCount, targetCountList), so they cannot drift apart in what a // value says. @@ -69,7 +69,13 @@ // stored documents that still contain such rows. What stays outside // every disclosure is everything that has to be visibly marked // (see below), plus the estimate label and note. -// - Text is the only format that omits, because a CI log is a linear +// - JUnit maps each target to a test case, with a separate case for run +// diagnostics. Allowed differences pass, policy differences fail, +// and compilation or operational failures are errors. Case output uses +// the text helpers and adds edge changes. Suite output summarizes the +// run, aggregate resource changes, and labelled impact estimates with +// all returned certnames, omitting PQL and request options. +// - Text omits detail because a CI log is a linear // read with no way to skip a section and no way to expand one. It // drops edge changes (a run's edge differences routinely outnumber // its resource differences and are usually connected to them), an @@ -148,7 +154,7 @@ // deliberately emits no such wording, since model.ImpactEstimate carries // state rather than prose, so the obligation is discharged here, in // every format, via ImpactEstimateLabel and ImpactEstimateNote. Both are -// package constants rather than per-format literals so the three formats +// package constants rather than per-format literals so the formats // cannot drift into saying different things, and neither ever describes // a returned certname as a node that will change: the estimate says only // that a node's latest stored catalog contains the resource. diff --git a/internal/report/junit.go b/internal/report/junit.go new file mode 100644 index 0000000..747d404 --- /dev/null +++ b/internal/report/junit.go @@ -0,0 +1,115 @@ +package report + +import ( + "bytes" + "encoding/xml" + "fmt" + + "github.com/example42/piace/internal/exitcode" + "github.com/example42/piace/internal/model" +) + +type junitSuites struct { + XMLName xml.Name `xml:"testsuites"` + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Errors int `xml:"errors,attr"` + Suite junitSuite `xml:"testsuite"` +} + +type junitSuite struct { + Name string `xml:"name,attr"` + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Errors int `xml:"errors,attr"` + Timestamp string `xml:"timestamp,attr,omitempty"` + Cases []junitCase `xml:"testcase"` + Output string `xml:"system-out"` +} + +type junitCase struct { + Name string `xml:"name,attr"` + Classname string `xml:"classname,attr"` + Failure *junitProblem `xml:"failure,omitempty"` + Error *junitProblem `xml:"error,omitempty"` + Output string `xml:"system-out"` +} + +type junitProblem struct { + Message string `xml:"message,attr"` + Type string `xml:"type,attr"` + Details string `xml:",chardata"` +} + +// JUnit maps the already classified outcomes to CI test cases. Allowed +// differences pass because fail_on_diff is the policy that decides failure. +func JUnit(r model.Result) ([]byte, error) { + suite := junitSuite{Name: "piace.compare", Timestamp: r.Invocation.TimestampUTC} + for _, target := range r.Targets { + var output bytes.Buffer + writeTextTargets(&output, []model.TargetResult{target}) + // Edge-only differences must explain a failure even though the text + // renderer normally omits edges for a compact CI log. + if target.NodeDiff != nil { + for _, change := range target.NodeDiff.EdgeChanges { + textf(&output, " %s %s -> %s\n", change.Kind, change.Edge.Source, change.Edge.Target) + } + } + c, err := newJUnitCase(target.Certname, "piace.target", target.Outcome, output.String()) + if err != nil { + return nil, err + } + suite.Cases = append(suite.Cases, c) + } + if len(r.Diagnostics) > 0 { + // Reducing only run diagnostics avoids attributing a target's failure + // to the synthetic run case as well. + run := model.Result{Diagnostics: r.Diagnostics} + run.Finalize() + var output bytes.Buffer + writeTextRunDiagnostics(&output, r.Diagnostics) + c, err := newJUnitCase("run diagnostics", "piace.run", run.Outcome, output.String()) + if err != nil { + return nil, err + } + suite.Cases = append(suite.Cases, c) + } + for _, c := range suite.Cases { + if c.Failure != nil { + suite.Failures++ + } + if c.Error != nil { + suite.Errors++ + } + } + suite.Tests = len(suite.Cases) + var output bytes.Buffer + textf(&output, "PIACE %s\noutcome: %s (exit %d)\n", r.Invocation.ToolVersion, r.Outcome, r.ExitCode) + for _, reason := range r.Reasons { + textf(&output, " reason: %s\n", reason) + } + writeTextAggregate(&output, r.Aggregate) + writeTextImpact(&output, r.ImpactEstimates, Options{ImpactNodes: true}) + suite.Output = output.String() + document := junitSuites{Tests: suite.Tests, Failures: suite.Failures, Errors: suite.Errors, Suite: suite} + data, err := xml.MarshalIndent(document, "", " ") + if err != nil { + return nil, fmt.Errorf("encoding JUnit report: %w", err) + } + return append([]byte(xml.Header), append(data, '\n')...), nil +} + +func newJUnitCase(name, classname string, outcome exitcode.Outcome, output string) (junitCase, error) { + c := junitCase{Name: name, Classname: classname, Output: output} + problem := &junitProblem{Message: string(outcome), Type: string(outcome), Details: output} + switch outcome { + case exitcode.OutcomeClean, exitcode.OutcomeDifferencesAllowed: + case exitcode.OutcomePolicyDisallowedDifference: + c.Failure = problem + case exitcode.OutcomeCompilationFailure, exitcode.OutcomeOperationalError: + c.Error = problem + default: + return junitCase{}, fmt.Errorf("encoding JUnit report: unrecognized outcome %q for %s", outcome, name) + } + return c, nil +} diff --git a/internal/report/junit_test.go b/internal/report/junit_test.go new file mode 100644 index 0000000..4f3dbf0 --- /dev/null +++ b/internal/report/junit_test.go @@ -0,0 +1,141 @@ +package report + +import ( + "bytes" + "encoding/xml" + "strings" + "testing" + + "github.com/example42/piace/internal/exitcode" + "github.com/example42/piace/internal/model" +) + +func TestJUnitOutcomeMapping(t *testing.T) { + r := model.NewResult("test", "2026-10-08T12:00:00Z") + r.Targets = []model.TargetResult{ + {Certname: "clean", NodeDiff: &model.NodeDiff{}}, + {Certname: "allowed", NodeDiff: &model.NodeDiff{HasDifference: true}}, + {Certname: "policy", Config: &model.ConfigProvenance{FailOnDiff: true}, NodeDiff: &model.NodeDiff{HasDifference: true}}, + {Certname: "compile", Diagnostics: []model.Diagnostic{{Severity: model.SeverityError, Operation: model.OperationRequestCandidate, Message: "compilation rejected"}}}, + {Certname: "operational", Diagnostics: []model.Diagnostic{{Severity: model.SeverityError, Operation: model.OperationLoadBaseline, Message: "baseline unavailable"}}}, + } + r.Reduce() + data, err := JUnit(r) + if err != nil { + t.Fatal(err) + } + var got junitSuites + if err := xml.Unmarshal(data, &got); err != nil { + t.Fatalf("invalid XML: %v\n%s", err, data) + } + if got.Tests != 5 || got.Failures != 1 || got.Errors != 2 || got.Suite.Tests != 5 || got.Suite.Failures != 1 || got.Suite.Errors != 2 { + t.Fatalf("incorrect counts: %+v", got) + } + if got.Suite.Name != "piace.compare" || got.Suite.Timestamp != r.Invocation.TimestampUTC || len(got.Suite.Cases) != 5 { + t.Fatalf("incorrect suite: %+v", got.Suite) + } + for i, c := range got.Suite.Cases { + if c.Name != r.Targets[i].Certname || c.Classname != "piace.target" { + t.Errorf("case identity = %s/%s", c.Classname, c.Name) + } + if (c.Failure != nil) != (i == 2) || (c.Error != nil) != (i >= 3) { + t.Errorf("incorrect outcome for %s: %+v", c.Name, c) + } + if c.Error != nil && !strings.Contains(c.Error.Details, r.Targets[i].Diagnostics[0].Message) { + t.Errorf("missing error details for %s", c.Name) + } + } + if !bytes.HasPrefix(data, []byte(xml.Header)) || !bytes.HasSuffix(data, []byte("\n")) { + t.Errorf("missing XML declaration or trailing newline") + } +} + +func TestJUnitRunDiagnostics(t *testing.T) { + for _, severity := range []model.DiagnosticSeverity{model.SeverityWarning, model.SeverityError} { + t.Run(string(severity), func(t *testing.T) { + r := model.NewResult("test", "") + r.Targets = []model.TargetResult{{Certname: "policy", Config: &model.ConfigProvenance{FailOnDiff: true}, NodeDiff: &model.NodeDiff{HasDifference: true}}} + r.Diagnostics = []model.Diagnostic{{Severity: severity, Operation: model.OperationEstimateImpact, Message: "impact query unavailable"}} + r.Reduce() + data, err := JUnit(r) + if err != nil { + t.Fatal(err) + } + var got junitSuites + if err := xml.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if len(got.Suite.Cases) != 2 || got.Failures != 1 { + t.Fatalf("incorrect cases: %+v", got) + } + wantErrors := 0 + if severity == model.SeverityError { + wantErrors = 1 + } + if got.Errors != wantErrors || got.Suite.Errors != wantErrors { + t.Errorf("errors = %d/%d, want %d", got.Errors, got.Suite.Errors, wantErrors) + } + c := got.Suite.Cases[1] + if c.Name != "run diagnostics" || c.Classname != "piace.run" || c.Failure != nil || (c.Error != nil) != (severity == model.SeverityError) { + t.Errorf("incorrect run diagnostic classification: %+v", c) + } + if !strings.Contains(c.Output, "impact query unavailable") { + t.Error("run diagnostic missing") + } + }) + } +} + +func TestJUnitEvidenceAndRoundTrip(t *testing.T) { + r := sampleResult() + r.Targets[0].NodeDiff.ResourceChanges[0].Identity.Title = "&\"日本語\x00\x1b" + first, err := JUnit(r) + if err != nil { + t.Fatal(err) + } + var got junitSuites + if err := xml.Unmarshal(first, &got); err != nil { + t.Fatalf("invalid XML: %v", err) + } + output := got.Suite.Cases[0].Output + for _, want := range []string{"&\"日本語", `\x00`, `\x1b`, model.RedactedValue, model.V3TrustedFactWarning, "Class[a] -> Class[b]", "sha256 aaaa -> bbbb"} { + if !strings.Contains(output, want) { + t.Errorf("missing %q in case output:\n%s", want, output) + } + } + if !strings.Contains(got.Suite.Output, ImpactEstimateLabel) || !strings.Contains(got.Suite.Output, ImpactEstimateNote) { + t.Error("impact labelling missing") + } + stored, err := JSON(r) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeJSON(stored) + if err != nil { + t.Fatal(err) + } + second, err := JUnit(decoded) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("JUnit differs after JSON round trip") + } +} + +func TestJUnitEmptyResultAndUnknownOutcome(t *testing.T) { + r := model.NewResult("test", "") + r.Reduce() + data, err := JUnit(r) + if err != nil { + t.Fatal(err) + } + var got junitSuites + if err := xml.Unmarshal(data, &got); err != nil || got.Tests != 0 || len(got.Suite.Cases) != 0 { + t.Fatalf("empty result: %+v, %v", got, err) + } + r.Targets = []model.TargetResult{{Certname: "node", Outcome: exitcode.Outcome("unknown")}} + if _, err := JUnit(r); err == nil { + t.Fatal("unknown outcome silently passed") + } +} From 795370853657fe1ec2cf2a36df5ca0920b580293 Mon Sep 17 00:00:00 2001 From: Alessandro Franceschi Date: Thu, 8 Oct 2026 17:43:13 +0200 Subject: [PATCH 2/2] Add tests for suppressing source content warnings in compare - Cover the `--suppress-source-content-warnings` flag end to end: with it, `verify_content` warnings disappear from every output format, while the node diff, aggregate, outcome and exit code stay identical. - Check that the File source-reference change is still reported when its warning is suppressed. - Check that retrieval failures still produce errors and a non-zero exit, so suppression cannot hide real problems. - Unit-test `ResolveFileContentEvidence` and `ResolveMembershipEvidence` across historical, changed, list, recursive and local sources: evidence and retrievals are unchanged, only the warning goes away. - Verify unrelated diagnostics (invalid checksum, unsafe source, no source, mismatched algorithms) are not altered by the option. --- cmd/piace/acceptance_content_warnings_test.go | 75 ++++++++++++++++ internal/filecontent/warnings_test.go | 88 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 cmd/piace/acceptance_content_warnings_test.go create mode 100644 internal/filecontent/warnings_test.go diff --git a/cmd/piace/acceptance_content_warnings_test.go b/cmd/piace/acceptance_content_warnings_test.go new file mode 100644 index 0000000..9c5d861 --- /dev/null +++ b/cmd/piace/acceptance_content_warnings_test.go @@ -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) + } + } +} diff --git a/internal/filecontent/warnings_test.go b/internal/filecontent/warnings_test.go new file mode 100644 index 0000000..c79f79b --- /dev/null +++ b/internal/filecontent/warnings_test.go @@ -0,0 +1,88 @@ +package filecontent + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/example42/piace/internal/model" +) + +func TestSuppressSourceContentWarningsPreservesEvidence(t *testing.T) { + source := map[string]any{"source": "puppet:///modules/app/config"} + for _, tc := range []struct { + name string + before, after map[string]any + retrievalError error + wantSeverity model.DiagnosticSeverity + suppress bool + }{ + {"historical source", source, source, nil, model.SeverityWarning, true}, + {"changed source", source, map[string]any{"source": "puppet:///modules/app/new"}, nil, model.SeverityWarning, true}, + {"source list", map[string]any{"source": []any{"first", "second"}}, source, nil, model.SeverityWarning, true}, + {"recursive source", map[string]any{"source": "puppet:///modules/app/dir", "recurse": true}, source, nil, model.SeverityWarning, true}, + {"local source", source, map[string]any{"source": "/local/file"}, errUnsupportedSourceScheme, model.SeverityWarning, true}, + {"no source", map[string]any{"content": "bytes", "ensure": "directory"}, map[string]any{"content": "bytes", "ensure": "directory"}, nil, model.SeverityWarning, false}, + {"different algorithms", map[string]any{"source": "source", "checksum": "md5", "checksum_value": strings.Repeat("a", 32)}, map[string]any{"source": "source", "content": "bytes"}, nil, model.SeverityWarning, false}, + {"invalid checksum", map[string]any{"source": "source", "checksum_value": "invalid"}, source, nil, model.SeverityError, false}, + {"retrieval failure", source, source, errors.New("retrieval failed"), model.SeverityError, false}, + {"unsafe source", source, source, errUnsafeSourcePath, model.SeverityError, false}, + } { + t.Run(tc.name, func(t *testing.T) { + before := evidenceSide("production", true, tc.before) + after := evidenceSide("candidate", false, tc.after) + r := &recordingRetriever{get: func(string, RetrievalContext) (DigestEvidence, error) { + return DigestEvidence{Algorithm: "sha256", Digest: hashLocalContent("bytes")}, tc.retrievalError + }} + evidence, diagnostic := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r, Options{}) + if diagnostic == nil || diagnostic.Severity != tc.wantSeverity { + t.Fatalf("default diagnostic = %+v", diagnostic) + } + calls := append([]string(nil), r.calls...) + r.calls = nil + quietEvidence, quietDiagnostic := ResolveFileContentEvidence(context.Background(), "node", before.Resource.Identity, before, after, r, Options{SuppressSourceContentWarnings: true}) + if !reflect.DeepEqual(evidence, quietEvidence) || !reflect.DeepEqual(calls, r.calls) { + t.Fatalf("suppression changed evidence or retrievals: %+v / %+v, %v / %v", evidence, quietEvidence, calls, r.calls) + } + if tc.suppress { + if quietDiagnostic != nil { + t.Errorf("warning not suppressed: %+v", quietDiagnostic) + } + } else if !reflect.DeepEqual(diagnostic, quietDiagnostic) { + t.Errorf("unrelated diagnostic changed: %+v / %+v", diagnostic, quietDiagnostic) + } + }) + } +} + +func TestSuppressSourceMembershipWarningsPreservesEvidence(t *testing.T) { + for _, tc := range []struct { + name string + kind model.ChangeKind + params map[string]any + suppress bool + }{ + {"historical removal", model.ChangeResourceRemoved, map[string]any{"source": "puppet:///modules/app/file"}, true}, + {"recursive addition", model.ChangeResourceAdded, map[string]any{"source": "puppet:///modules/app/dir", "recurse": true}, true}, + {"invalid checksum", model.ChangeResourceRemoved, map[string]any{"source": "source", "checksum_value": "invalid"}, false}, + {"non-source directory", model.ChangeResourceAdded, map[string]any{"content": "bytes", "ensure": "directory"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + side := evidenceSide("production", tc.kind == model.ChangeResourceRemoved, tc.params) + evidence, diagnostic := ResolveMembershipEvidence(context.Background(), "node", tc.kind, side, nil, Options{}) + quietEvidence, quietDiagnostic := ResolveMembershipEvidence(context.Background(), "node", tc.kind, side, nil, Options{SuppressSourceContentWarnings: true}) + if diagnostic == nil || !reflect.DeepEqual(evidence, quietEvidence) { + t.Fatalf("missing default warning or changed evidence: %+v / %+v", evidence, quietEvidence) + } + if tc.suppress { + if quietDiagnostic != nil { + t.Errorf("warning not suppressed: %+v", quietDiagnostic) + } + } else if !reflect.DeepEqual(diagnostic, quietDiagnostic) { + t.Errorf("unrelated diagnostic changed: %+v / %+v", diagnostic, quietDiagnostic) + } + }) + } +}