Add repo scaffold and the catalog-diff CLI #1
Reference in New Issue
Block a user
Delete Branch "benvin/scaffold-catalog-diff"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The old catalog_diff.py shells out to
puppet catalog compile --server, which compiles locally and silently ignores the server, so branch environments were never really tested. This replaces it with a Go CLI that compiles over the puppetserver API.Reviewed the diff, ran
make test(go test -v -race ./..., all green, no data races) andmake lint(golangci-lint run ./..., 0 issues).No correctness bugs found. Specifics checked and confirmed correct:
TestDiffDeterministicOrderingruns the diff 20x over multi-key maps;Diff's final sort key is (resource key, diff type, structure path), which is provably unique per change, so map-iteration randomness can't leak into output order).deepEqualusesjson.Marshal, which sorts map keys, so parameter-map ordering never registers as a change — also covered byTestDiffIgnoresMapOrdering.transient()correctly refuses to retryEnvironmentMismatchErrorand 4xxCompileError, only retries 5xx/transport errors; covered byTestRunRetriesTransientFailures/TestRunDoesNotRetryCompilationErrors. Backoff math is correct (starts at 2s, caps at 30s, jitters).factsmap — no data race, confirmed by-race. Bounded by--retry/--timeout, no goroutine leak.--help, README table, andmain.goconstants all agree (0/1/2/3,--testonlynever returns 2) — verified against tests, not just docs.NewRequestWithContext, and response body closing all look correct.catalog.redact), so a change is visible without the plaintext leaking. Non-marked catalog content is explicitly documented in the README as remaining in the clear — an honest, deliberate tradeoff rather than an oversight.Changesis normalized to[]Change{}(never nil) inNewReport, andResource.Parametersis always a non-nil map fromcleanParameters— no nil-vs-empty-slice inconsistency found.Two non-blocking observations (style/question, not bugs):
Report.Compiledis amap[string]boolwith afalse/"failed" rendering path incompiledLine, but every current call site (NewReport) only runs after both compiles already succeeded — soCompiledis always{base: true, test: true}today and the "failed" branch is unreachable. Looks like scaffolding for a future partial-failure report; worth a comment noting that, or trimming until it's used.cleanParametersmerges the catalog-levelsensitive_parameterslist with each resource's ownsensitive_parametersand redacts any resource parameter whose name appears in either list — i.e. a catalog-level sensitive name gets redacted on every resource, not just the resource(s) it was declared sensitive on. If the catalog's top-levelsensitive_parametersis actually a global list of parameter names (rather than per-resource), this is correct; if it's meant as some other shape, it could over-redact. Worth a quick confirmation against a real OpenVoxServer catalog sample.On atomicity: this bundles the CLI, two HTTP clients, the diff engine, and the full CI/packaging scaffold into one PR. Normally that would be feature creep, but this is the first PR into a brand-new empty repo — there's no prior scaffold to build on, and splitting it wouldn't leave a working state at any intermediate point (an empty repo with CI, or code with no CI to prove it). Treating this as a justified bootstrap exception rather than a violation.
PR body is compliant with conventions (7 lines, 616 chars, why + present-tense bullets, no narrative/alternatives/AI references).
- unwrap the {"catalog": ...} envelope, still accepting a bare catalog - report an environment-less response as a protocol mismatch, not an ENC problem - make Resource.Line a pointer and Resource.Params() map absent onto empty - render a missing manifest location as "(source unknown)" - retry compile failures caused by the compiler's PuppetDB query - stop retrying manifest errors the compiler returns as HTTP 500 - add a trimmed real-server response fixture and tests for each shapeReviewed at head
1aea8e8(benvin/scaffold-catalog-diff).make test,go test -race ./...andmake lintall pass clean. Verdict: solid overall — envelope decode, missing-parameters/file/line, retry classification and ENC-blame handling all match the ground-truth response shape and are well covered by tests. Two real bugs found (one crash, one latent decode bug), plus lower-priority items.Blocking
cmd/catalog-diff/main.go:298-326(compile) —catalog-diff <host> --retry -1(or any negative--retry) panics with a nil-pointer dereference instead of exiting cleanly. The loopfor attempt := 0; attempt <= opts.retry; attempt++never executes whenopts.retry < 0, solastErrstaysnil; thatnilgets wrapped in&exitError{err: nil}, andmain()'sfmt.Fprintln(os.Stderr, "error:", err)callsexitError.Error()→e.err.Error()on a nilerror→ SIGSEGV. Reproduced locally (built the binary / droverun()directly withopts.retry = -1,--testonly, a compiling puppetserver stub returning 500). No flag validation currently rejects a negative--retry. Fix: validate--retry >= 0in flag parsing, or makecompile()handle "no attempt ran" explicitly.internal/pdbmux/pdbmux.go:66-84(Factsfallback) — the/pdb/query/v4/factsets/<certname>fallback decodes into a barefactsetstruct, i.e. assumes a JSON object. Confirmed real PuppetDB returns an array of factsets at that endpoint;json.Unmarshal([]byte("[{...}]"), &factset{})fails withjson: cannot unmarshal array into Go value of type pdbmux.factset(verified). The bundled test fixture (factsetBodyinpdbmux_test.go) is itself a bare object, so the test only proves the code matches its own wrong assumption — it never exercises the real shape. This currently "works" only because pdbmux (the internal proxy) happens to return an object here, diverging from the real PuppetDB API it's meant to be compatible with. If pdbmux is ever pointed at real PuppetDB for this route, or its behavior changes, the fallback breaks for every node that needs it. Fix: accept both shapes (try array first, or ajson.RawMessage-sniffing decode), and add a fixture using the real array shape.Worth fixing, not blocking
internal/pdbmux/pdbmux.go:109-121(fold) — a fact record with no"value"key decodesValueas a niljson.RawMessage, andjson.Unmarshal(nil, &v)returns"unexpected end of JSON input"(verified, does not panic). That abortsFacts()entirely for the whole host on one malformed record, rather than skipping just that fact. Real PuppetDB fact data can be sparse/odd; worth treating a missingvalueasnilinstead of a hard error.internal/puppetserver/testdata/catalog_response.json/AGENTS.mdnote it's "a trimmed capture of a real OpenVoxServer 8.8 response" — it carries a real production hostname (prodnxsr0009.main.unkin.net), real class/role names (profiles::ceph::dashboard,roles::infra::incus::node), and a real internal path layout. The one genuine secret (content) is manually replaced withREDACTED-IN-FIXTURE, and the IP used (198.18.23.9) is in the IANA benchmark-reserved range, so nothing sensitive leaks — but this is a public repo, and topology/naming don't need to come from live infra to prove the envelope/missing-field shape. Consider a synthetic fixture instead.Not blocking, would have done differently
cmd/catalog-diff/main.go:238-252(run) — the base and test compiles run concurrently but neither is cancelled when the other fails; a fast manifest error in the test environment still waits out the full base-environment compile (up to--timeout, default 10m) before reporting. Anerrgroup-with-cancel would fail faster.Same function,
compile()— ifctxis cancelled during the backoff sleep,sleepCtxreturns the context error butcompile()discards it and returns the previous compile error vialastErr, so a caller-imposed deadline surfaces as a stale compile failure rather than a timeout/cancellation. Minor, but slightly misattributes the actual cause.Confirmed correct / no action needed
catalogwrapper), missingparameters(→ empty map, distinguishable viaResource.Parameters == nil), missingfile/line(pointerLine,HasSource()), and decode failures reported as protocol errors (not ENC/classification blame) — all match the real response shape and are exercised by targeted tests including the real captured fixture.Failed to execute '/pdb/query/v4'...,undefined method 'escape'...,Could not find class...).redact()→ sha256 digest) happens atNormalizetime, so it's baked into everyChange.OldValue/NewValuebefore either the text or JSON renderer sees it — confirmed via the goldenreport.json/report.txtfixtures.Diff()sorts the final change list by key/type/structure, anddeepEqual/unionKeysnormalize via JSON re-encoding / explicit key sorting. Covered byTestDiffDeterministicOrdering.--testonlynever 2) match--help/README and are individually tested.backend_options.kubernetes.resourcesall match thenode-lookuphouse conventions closely (includingserviceAccountName: default, which node-lookup also uses).Targeted review of the three most recent commits only (
6e4ee54,7f2c6f1,496f258).go test -race(x3),make lint, andmake pre-commitall pass clean.Blocking
decodeFactsets()(internal/pdbmux/pdbmux.go) silently accepts a single non-matching factset.When the array response contains exactly one entry and its
certnamedoesn't match the requested one, the code falls through thelen(sets) == 1shortcut and returns that entry's facts with no error and noWarncall:Reproduced directly: a factset array
[{"certname":"totally-different-host.example.net", ...}]requested forhost.example.netreturns that other host's facts witherr == nil. If PDB or pdbmux ever answers with a single factset for the wrong node (alias, caller bug, backend quirk), catalog-diff will silently compile against the wrong host's facts and produce a misleading diff with no signal anything went wrong. There's no test covering "array with exactly one non-matching entry" — only the 0-match, 2+-match, and bare-object (no certname field at all) cases are covered.Suggest either: only take the single-entry shortcut when
Certname == ""(matching the actual pdbmux quirk this was written for), or at minimum route it throughWarnso the mismatch is visible.Non-blocking
decodeFactsets()peeks the first non-whitespace byte to choose array vs. object, but doesn't strip a UTF-8 BOM. A BOM-prefixed array (EF BB BF+[...]) is misdispatched to the bare-object branch and fails withinvalid character '\xef'...instead of parsing as the array it is. Low real-world likelihood and it fails loudly rather than silently, and it's not a regression (the old single-shape decoder had the same gap) — not worth blocking on, but a one-linebytes.TrimPrefixwould close it if anyone cares."parsing factset for %s: %w"even though it isn't a parse failure — reads a little oddly, cosmetic only.Verified clean (no issues found)
abandoned(): worked through all the combinations (parent done, parent live + sibling failed, near-simultaneous) — the design only cancels the sharedcompileCtxviaabandon()on sibling failure, while the parentctxis only ever done from an external cause, soctx.Err() == nil && errors.Is(err, context.Canceled)cleanly separates "fallout from the sibling" from "the user/parent cancelled." No case found where a genuine failure gets misreported as abandonment or vice versa.defer abandon()covers every return path including panics;wg.Wait()happens-before the read of both errors, so no data race — confirmed with-racex3, clean.backoff(): checked 0, 1, 31, 32, 63, 64, and1<<30— the shift is only computed for1 <= attempt < 32, so it never overflows (max shift is2s<<30 ≈ 24.8 days, still fits int64, and gets clamped to the 30s ceiling before use) and is skipped entirely (falls back to the 30s ceiling) for anything at or above 32, however large.dis always positive before the jitter call, sorand.Int63n(d/2)never sees a non-positive argument and the total is always > 0. Confirmed via the existingTestBackoffStaysWithinBounds.--retrybounds and--timeoutpositivity are checked invalidate(), called beforeresolveEnvironmentor anything else touches the values; all reject with exit 3 as documented. No other numeric/duration flag has the same class of gap.198.18.23.9) was replaced with192.0.2.9(TEST-NET-1, documentation-reserved). The only remaining*.unkin.nethosts are the tool's actual intentional config defaults (puppet.k8s.syd1.au.unkin.net,pdbmux.k8s.syd1.au.unkin.net) and CI/module-path references — pre-existing, not fixture leakage, untouched by these commits. Wire-format shape is preserved: envelope, nullcode_id, stringversion, resources with/withoutparameters/file+line,exported: truecase, and per-resourcesensitive_parametersare all still exercised.fold()'sWarnhook is nil-checked before use.Judgment calls
fold()erroring when every record is undecodable: agree with the choice. Silently returning an empty-but-"successful" fact map would let a completely broken fetch masquerade as a real (if factless) host, compiling a catalog that's quietly wrong rather than loudly failing. Loud failure is the safer default here.maxRetry = 20cap: keep it. The overflow bug is already independently fixed in thebackoff()rewrite (verified safe for anyint, including very large values, since the shift is gated onattempt < 32), so the cap is no longer load-bearing for correctness — but it's a free, sensible operator guard against a typo like--retry 999999turning a single host lookup into an effectively-unbounded hang. No reason to drop it.Reviewed commit
3f14554(renameinternal/pdbmux→internal/puppetdb, switch toGET /pdb/query/v4/factswith an AST query).Verdict: clean, no blocking issues.
["=","certname",<host>]is built viajson.Marshalthen percent-encoded viaurl.Values.Encode(). Verified with the added test that a hostile certname (we"ird &host) survives both encodings intact — no injection/desync risk for weird certnames.200 []as the missing-node signal is handled correctly (decodeFactsempty →len(records)==0→ErrNodeNotFound), kept distinct from other non-200 statuses. Can't be confused with a node that genuinely has facts, since PuppetDB only omits rows for a certname it has none for./nodes/<certname>/factsfallback) are exactly the dead paths the commit message says were dropped — nothing lost as collateral.decodeFacts's "no facts for %s: puppetdb returned %s") is still reachable and exercised (TestFactsRejectsRecordsForAnotherNode,TestFactsRejectsAMixedResponseNamingEveryNode) — a reasonable defensive check to keep even though the query should filter server-side.pdbmux.*unkin.netoccurrences are the default URL value (config.go) and its README/AGENTS.md mirrors, exactly as permitted. No comment, error string or test name still describes it as a proxy.status == http.StatusNotFoundbranch inFacts()is probably unreachable against a real PuppetDB, which answers200 []rather than 404 for an unknown certname — harmless defensive code, not worth blocking on.go test -race ./...,make lint,make pre-commitall green on this branch.