Conversation
Chris's request, via the coordinator: the hold on this branch is about merge order, and what he merges is PR #282, not issue #250. So the gates should name what is being ordered. Not a sweep. #250 and #282 answer different questions and the prose already distinguishes them, so the split is by what each sentence is about: merge order -> #282 "until #282 merges", "blocked until", the tag gate, the workflow step's THIS TARGET DOES NOT EXIST YET the work -> #250 "#250's to define and #254's to exercise", "if #250 chooses differently", "the exact filenames are #250's", the packageFormats obligation A PR number is actionable but perishable: close and reopen #282 and the number changes while the issue reference stays true. So the PR is right for a gate, which is consumed at a particular moment, and the issue is right for prose about a deliverable, which outlives it. Where a sentence carries both -- the Known gaps row, the do-not-tag callout -- it names both, and the callout says which is which so the next editor does not flatten them back together. Thirteen references, six changed. The workflow comment and the callout each carry a line explaining the convention, because the mixture otherwise looks like an oversight and the next person to touch it will helpfully make it consistent. Left alone deliberately: the packageFormats obligation in magefile.go and releasing.md still names #250. #282 has discharged it -- its own comment records that Build.Packages drives nfpm from that variable -- but it is discharged on a branch, not on main, so the obligation is still true of the tree this text ships in. Both notices come out at rebase time, not now.
This comment has been minimized.
This comment has been minimized.
|
Thanks — worked through all eleven at 🟠 HIGH —
|
Chris's request, via the coordinator: the hold on this branch is about merge order, and what he merges is PR #282, not issue #250. So the gates should name what is being ordered. Not a sweep. #250 and #282 answer different questions and the prose already distinguishes them, so the split is by what each sentence is about: merge order -> #282 "until #282 merges", "blocked until", the tag gate, the workflow step's THIS TARGET DOES NOT EXIST YET the work -> #250 "#250's to define and #254's to exercise", "if #250 chooses differently", "the exact filenames are #250's", the packageFormats obligation A PR number is actionable but perishable: close and reopen #282 and the number changes while the issue reference stays true. So the PR is right for a gate, which is consumed at a particular moment, and the issue is right for prose about a deliverable, which outlives it. Where a sentence carries both -- the Known gaps row, the do-not-tag callout -- it names both, and the callout says which is which so the next editor does not flatten them back together. Thirteen references, six changed. The workflow comment and the callout each carry a line explaining the convention, because the mixture otherwise looks like an oversight and the next person to touch it will helpfully make it consistent. Left alone deliberately: the packageFormats obligation in magefile.go and releasing.md still names #250. #282 has discharged it -- its own comment records that Build.Packages drives nfpm from that variable -- but it is discharged on a branch, not on main, so the obligation is still true of the tree this text ships in. Both notices come out at rebase time, not now.
f74ea05 to
95a5055
Compare
|
Rebased onto current Step 3's mint path is exercised rather than assumed. Built both binaries from this branch on top of main and ran the real A second run adopts what the first minted rather than minting again. So the open question in the PR body — what does step 3 do before #189 lands — is closed by #189 landing, and the answer is that it mints. The capability probe stays, but it was naming the wrong cause. It cited One thing operators will now see that needs explaining. The mint prints Rebase checked for silent reverts —
|
voxpupuli#224, voxpupuli#260, voxpupuli#261, voxpupuli#267 and voxpupuli#189 all merged this afternoon, on top of the five that went yesterday. Seven entries left: voxpupuli#212, voxpupuli#168, voxpupuli#265, voxpupuli#266, voxpupuli#282, voxpupuli#166 and this branch. voxpupuli#282 (feature/package-payload) is new and is the PR for issue voxpupuli#250, so the tag constraint now watches a PR rather than an issue. It is not stacked on voxpupuli#266 but collides with it on README.md, magefile.go and magefile_test.go, so the two sit adjacent and the collision surfaces once. Both voxpupuli#261 obligations are discharged: main carries ~~[voxpupuli#202] and ~~[voxpupuli#203] together and exactly one hmac-key ordinal, checked after the merge. The section keeps the shape rather than the items. The carried fix survives voxpupuli#189 merging but its basis changed — capability_test.go is on main now rather than only on voxpupuli#189, so it is a voxpupuli#212 fix outright. Verified the patch still applies to main's copy.
This comment has been minimized.
This comment has been minimized.
79829cf to
00561e5
Compare
|
Worked through all 22 at 🔴 CRITICAL — packaged install cannot startFixed, and the finding understated it. Confirmed first: there is no built-in default for The cause is worth recording because it is a shape rather than an oversight: the config file was created in the previous commit to hold the port, and the port was the thing under discussion, so it held the port and nothing else. Fixing The credential provisioning mints is named for the host, which no shipped file can know. So the config names two stable paths ( Verified by running it, not by reading it. Provision into a scratch tree, start with the shipped config and no flags: Specs now assert every setting the server refuses to start without, and cross-check that the paths the config names are ones the script actually links — if those drift, the service starts and exits. 🟠 HIGH (5) — all fixed
🟡 MEDIUM (11) — 10 fixed, 1 deferredFixed: the On the shared Deferred: the account-provisioning branch matrix has no test. It needs a container to exercise 🔵 LOW (5) — all fixed
One defect found while fixing, not in the reviewWriting
|
This comment has been minimized.
This comment has been minimized.
00561e5 to
daf7a9b
Compare
|
Worked through all 11 at 🟠 HIGH (3)RPM payload never inspected. Correct, and the asymmetry was worse than "one format untested": the PR body verified nfpm's Node-certificate path untested. Fixed: Lifecycle scripts untested. Fixed for everything that does not need a container: which arguments act at all, and that an upgrade does not re-enable a unit the operator disabled. External commands are stubs that log their arguments, so nothing touches this machine's accounts. You were right to tell me to check this against my earlier deferral rather than reuse it. The earlier deferral does not cover this, and the finding says so plainly: the harness to drive these already existed in the same file. What remains deferred is genuinely narrower — whether 🟡 MEDIUM (5) — all fixed
Both documentation findings fixed — and a third drift I found by reading around them rather than only at them: the certname paragraph still described the unresolved-name marker as written on every unresolved name, when I changed it last round to be written only when that run created the CA. That one was mine, from the previous round's fix, and no reviewer caught it. 🔵 LOW (3) — all fixed
The ownership fixups reported nothing when they failed. Both sent stderr to On the prose class, since this is the third round it has appeared: the counter-check I have adopted is to re-read the surrounding paragraphs of every file touched, not just the diff, and to give the PR description the same pass — nothing diffs it. That is what turned up the fourth drift above.
|
This comment has been minimized.
This comment has been minimized.
|
Worked through all of these at Two of the findings are one finding. "Build.Packages has no test of its own" and "Build.Packages(), the actual target, is never invoked by a test" both cite 🟠 HIGH —
|
voxpupuli#224, voxpupuli#260, voxpupuli#261, voxpupuli#267 and voxpupuli#189 all merged this afternoon, on top of the five that went yesterday. Seven entries left: voxpupuli#212, voxpupuli#168, voxpupuli#265, voxpupuli#266, voxpupuli#282, voxpupuli#166 and this branch. voxpupuli#282 (feature/package-payload) is new and is the PR for issue voxpupuli#250, so the tag constraint now watches a PR rather than an issue. It is not stacked on voxpupuli#266 but collides with it on README.md, magefile.go and magefile_test.go, so the two sit adjacent and the collision surfaces once. Both voxpupuli#261 obligations are discharged: main carries ~~[voxpupuli#202] and ~~[voxpupuli#203] together and exactly one hmac-key ordinal, checked after the merge. The section keeps the shape rather than the items. The carried fix survives voxpupuli#189 merging but its basis changed — capability_test.go is on main now rather than only on voxpupuli#189, so it is a voxpupuli#212 fix outright. Verified the patch still applies to main's copy.
voxpupuli#283 fix/init-bootstrap-reentrancy is issue voxpupuli#201 — Init's slow path re-entering the bootstrap lock. Verified: open, non-draft, head ea58fae, and it touches init.go, a new initreentrancy_test.go and locking.md. It sits next to voxpupuli#265 because the two collide on locking.md, which the coordinator's 'zero conflicts' did not show: that was measured against main, and pairwise against the other branches is a different question. Trial now reads 2 of 8 — voxpupuli#283 on locking.md and the known voxpupuli#266 x voxpupuli#282 magefile.go. voxpupuli#168 and voxpupuli#166 rebased and both heads verified; voxpupuli#166 is still correctly stacked on voxpupuli#168 after the rebase, so its ordering stands.
voxpupuli#166, voxpupuli#168, voxpupuli#212 and voxpupuli#265 merged; voxpupuli#284, voxpupuli#285, voxpupuli#288 and voxpupuli#289 added. Eight entries. Derived independently rather than taken on report, and it agreed. Ordering from a pairwise conflict matrix rather than from file overlap: the only pair that actually conflicts is voxpupuli#266 x voxpupuli#282 on magefile.go, so they stay adjacent and everything else is order-independent. voxpupuli#285 and voxpupuli#289 touch collector.go and metrics.md in common but merge cleanly against each other, so pairing them would buy nothing. Also recorded that membership is the only thing needing an edit here. Heads move constantly and cost nothing because the entries are refs, not shas, so 'wait until PR X settles before refreshing' answers the wrong question - X settling changes no membership, and meanwhile the list can be omitting open PRs, which is the half that makes a green build misleading.
voxpupuli#283, voxpupuli#288 and voxpupuli#289 merged; voxpupuli#294 and voxpupuli#296 added. Seven entries, and main has moved to 2d11cc0. Ordering from a pairwise conflict matrix over all six open PRs. Two pairs actually collide: voxpupuli#266 x voxpupuli#282 on magefile.go, as always, and a new one - voxpupuli#285 x voxpupuli#294 on cmd/openvox-ca/config.go. Each pair sits adjacent so its conflict surfaces once; the other two branches are order-independent. Trial replays that as 2 of 7, matching the matrix. Worth noting voxpupuli#266 is now 56 behind main, by far the stalest entry.
This comment has been minimized.
This comment has been minimized.
3c072b8 to
7a2be5a
Compare
…ages` The shared half of the deb and rpm packages: everything both formats install, the provisioning that makes an installed CA actually serve, and the mage target that builds them. The configuration file is deliberately absent -- it is where the two formats diverge, and it belongs to #251 and `mage build:packages` reads the variant tarballs already in dist/ and writes one package per format for each packaged variant into the same directory. It does not build binaries: the ones inside openvox-ca_VER_amd64.deb are literally taken out of openvox-ca_VER_linux_amd64.tar.gz, so a package is never a second compilation that might differ from the artefact that was tested and attested. The filenames are nfpm's conventional ones, which is what apt and dnf expect; they carry no variant name. nfpm rather than goreleaser because the FIPS binaries are cgo and dynamically linked, and importing externally-built binaries needs goreleaser's Pro-only `prebuilt` builder. nfpm is what goreleaser's `nfpms:` block uses underneath. It is a build-time dependency only: magefile.go is behind `//go:build mage`, and neither nfpm nor any module its addition bumped appears in `go list -deps ./cmd/...` for either binary, so the shipped artefacts and their boringcrypto builds are untouched. The FIPS variants are not packaged. nfpm runs neither dpkg-shlibdeps nor rpm's automatic requires, so a dynamically linked package's dependencies would have to be written by hand and kept true. Recorded as `packaged` on distVariantSpec rather than as a second list of names. `packageFormats`, `packageExtensions()`, `packagedDistVariants()` and the `packaged` field are defined here with the names and semantics #266 gives them. #266 adds the same symbols and lands after this, so its rebase deletes its copies. Carrying a private format list instead would be the drift #266 warns about and `verifyDistVariants` cannot see, since it reads the workflows and not the packaging code. The unit becomes a template. `ExecStart` carries a placeholder rendered to /usr/local/bin for the release tarballs -- what an unmanaged `install` prefix means -- and to /usr/bin for the packages, which must not write outside /usr. `mage build:unit <bindir>` renders it for a from-source install. Rendering refuses a template with no placeholder, because a substitution that substitutes nothing would ship a unit naming the wrong prefix with nothing to say so. `StateDirectory=` goes and the storage default becomes /etc/puppetlabs/puppet/ssl/ca, the Clojure CA's own layout. The service keeps a narrow `ReadWritePaths=` naming that directory -- under ProtectSystem=strict a filesystem-backend CA with no writable path cannot write a signed certificate, a serial or a CRL. The wider parent /etc/puppetlabs/puppet/ssl is granted to the oneshot alone, which needs it to link certs/ca.pem and crl.pem into the tree above the CA. The service account is `puppet`, created by the package. OpenVox Server already creates puppet:puppet and chowns the ssl tree to it, so the tree openvox-ca writes is already expected to be owned by that account; but openvox-ca must work on a host running neither openvox-agent nor openvox-server, so it creates the account itself. sysusers.d is the declaration; the postinstall invokes systemd-sysusers because Debian wires no reliable trigger for that directory, and falls back to groupadd/useradd where systemd-sysusers is absent. Provisioning is a Type=oneshot ordered before the service, running as `puppet`, with every step guarded on absence so it is idempotent and a takeover does nothing. Its [Install] carries only RequiredBy=openvox-ca.service and no WantedBy=, so enabling it at install time provisions nothing: a CA is created the first time an operator starts openvox-ca, not the first time the host reboots. Installing a package is not consent to create a certificate authority. Before the service rather than beside it, because it writes to storage directly and the packages default to the filesystem backend, which coordinates no writes across hosts and cannot append to its inventory atomically -- two writers there leave an integrity record covering a state that never existed. A packaged install is single-instance by definition (#275), and nothing here implies otherwise. Step 3 mints this host's node certificate with `openvox-ca generate`, which is #189 and is not yet merged. Rather than assume it, the script probes for the subcommand and fails with the reason named. See the PR body for why that, and not a silent skip: without a node certificate the service refuses to start on a non-loopback address, so the choice is between a failure that says which build it needs and a failure about TLS configuration two units later. The probe reads the command listing. `openvox-ca generate --help` and `openvox-ca help generate` both exit 0 on a build with no such subcommand -- cobra answers --help from the root command before validating arguments, and an unknown help topic is not an error to it -- so a probe on either would report every build as capable. Verified in both directions rather than by inspection. Also adds verifyMageTargets, wired into `mage dev:check`: every mage target named outside Go must resolve. release.yml's packaging job calls `mage build:packages` as a string, so deleting or renaming the target would compile cleanly, pass every test, and fail at tag time -- after the tag is pushed, and while container-images.yml and helm-chart.yml publish their images regardless. #266 was asked for this check and declined it, because on that branch it would have been red from the moment it was written and so indistinguishable from a broken guard. Here it is green on arrival. It has two floors, because a guard that is all membership tests passes vacuously when its parse returns nothing: the magefile parse must find build:dist, and a workflow whose text invokes mage must yield at least one invocation from its run: steps. The second is matched with the invocation pattern rather than by searching for "mage ", which "image " contains -- the first version fired on container-images.yml, which is the other way a floor fails. Refs #250
The install recipe had gone stale in three ways the previous commit introduces, and each of them fails at start rather than at install: it created a `puppet-ca` account the unit no longer runs as, it told the operator to point `cadir` at the `StateDirectory=` the unit no longer creates, and it said the repository's copy of the unit could be installed from where it sits -- which now renders to a literal placeholder. Adds a section on installing from a package: what the first-boot oneshot does, in the order it does it; how `$NAME` is resolved and what the two failure states leave behind; and why enabling the oneshot at install time provisions nothing. Also states the single-instance constraint (#275) where an operator will meet it. The packages configure the filesystem backend, so exactly one openvox-ca may run against a given CA directory -- and that is the same reason provisioning is ordered before the service rather than beside it. Refs #250
Two points from #266's dry-run rebase onto this branch, both adopted. `packaged: false` on the two FIPS variants rather than the zero value. No behavioural difference and no spec moves either way; the difference is to the next person adding a variant, for whom an omission reads as an oversight and a false reads as a decision. This one is a decision, and the field already carries the reasoning. `mageInvocationRE` takes the leading class from #266's `releaseJobMageRE` -- anything that is not a word character, a dot or a dash. Both branches found the same trap independently (`"image "` ends in `"mage "`, so a substring search finds one in every workflow that mentions a container image) and fixed it separately; two expressions answering one question drift. Written non-capturing so the target stays group 1, which is the only difference. It is also the broader class, and that is the point: `bin/mage dev:check` is an invocation and the previous `[\s;&|(]` prefix would have missed it while the floor, matching on the same expression, would have fired. The two cannot disagree now because there is one expression. Adds specs pinning both directions -- a word ending in "mage" is not an invocation, an invocation by explicit path is -- so a future widening of that class cannot quietly reintroduce either failure. Refs #250
Adding nfpm broke the test runner, not the product. `nfpm/v2@v2.47.0`
requires `charmbracelet/x/ansi@v0.11.0`, so minimal version selection
raised it for the whole module -- and `charmbracelet/x/cellbuf@v0.0.13`,
reached through `lipgloss@v1.1.0` from `mfridman/tparse`, calls the
pre-0.11 ansi API:
x/cellbuf@v0.0.13/cell.go:203:10: b.SlowBlink undefined
(type ansi.Style has no field or method SlowBlink)
tparse is a `tool` directive in go.mod, built against the module's
resolved versions, and `test:unit` pipes `go test -json` into it. So the
whole unit suite stopped being runnable. It did not fail cleanly either:
the CI job sat at "Run unit tests" for two hours against a 3m49s baseline
on main, because the consumer of the pipe never came up.
Raising cellbuf to v0.0.15 makes that stack self-consistent again
(ansi v0.11.5, colorprofile v0.4.1). `go tool tparse` builds and renders,
and `mage test:unit` passes in full.
Worth stating for the next person adding a dependency here, because the
check that would have caught it is not the obvious one: `go list -deps
./cmd/...` showed nfpm reaching neither binary, which is true and is why
the product is unaffected -- but the build tooling shares the same module
graph, and a `tool` directive is built from it too. A dependency can be
absent from everything shipped and still stop the tests from running.
Refs #250
…ectory Acts on the review at 77e6c99. Every finding was checked against the code before being accepted; all eleven were real. **The certname could traverse out of its directory.** $NAME becomes $SSLDIR/certs/$NAME.pem and is passed to --cert-out and --key-out, and two of its three sources are less trusted than they look: `hostname -f` is whatever reverse DNS answers, and puppet.conf is writable by anything with access to it. `is_usable_fqdn` required a dot and rejected localhost forms, and rejected neither `/` nor `..`, so `../../../etc/x.example.com` passed it. Demonstrated rather than assumed: driving the old script with that name produced --cert-out .../ssl/certs/../../../etc/x.example.com.pem The oneshot's sandbox bounds the damage -- unprivileged, ProtectSystem= strict, ReadWritePaths=/etc/puppetlabs/puppet/ssl -- but the CA's own ca_crt.pem lives inside that subtree, so "bounded" is not "harmless". is_safe_certname is an allow-list rather than a search for "..": the way to be sure a string cannot leave a directory is to permit only characters that cannot express leaving one. It also refuses a leading dash, so a name can never be read as an option by the commands it is passed to. Every source is checked. An unsafe explicit answer stops rather than falling through -- it is a mistake in a file someone wrote by hand, and quietly provisioning under a different name would hide it. **mage build:packages had no test.** It is this branch's deliverable and the one function exercising nfpm end to end, and nothing ran it. Now: - checkPackagingInputs, extracted so both emptiness branches can be reached with synthetic input -- the real lists are never empty, so a test that could not supply its own would assert nothing. Both error messages are asserted, not just the fact of an error. - buildVariantPackages end to end against the real packaging/nfpm.yaml, from a staged tarball, with no compilation: the fixtures are the "binaries", which is the point. - The deb's payload is read back and asserted -- every installed path with its mode, the doc tree, the ssl directories. Without reading the archive a packaging test can only assert that a file appeared with the right name, which a package containing nothing satisfies. - That the unit inside the package is rendered for /usr/bin and is not the tarball's /usr/local/bin copy. The fixture tarball deliberately carries the tarball rendering, so shipping it by mistake fails here. Also tested: stageDocTree's happy path, its floor (extracted as checkDocTreeFloor so it can be exercised without renaming directories in the working tree), and Build.Unit's absolute-path rejection. **Two comments described a release.yml this tree does not have.** requiredMageTargets said build:packages "is called twice by release.yml's packaging job" in the present indicative. There is no packaging job here -- that is #266 -- and a maintainer checking the comment would find the justification apparently false rather than merely early. Reworded to say the entry is deliberately ahead of its caller, and to say why the workflow scan cannot cover it today: the scan can only see callers that exist. **Stale docs the account and cadir changes made untrue:** the note claiming the unit's default cadir differs from the example (it is now the same path), and two `sudo -u puppet-ca` recipes whose own justification is "matching the `User=` in the unit". The README's documentation table now mentions systemd.md's package-install section. The `cadir: /var/lib/puppet-ca` examples elsewhere in storage-backends.md are deliberately left: they are one valid choice of directory, not a claim about the unit's default, and docs/systemd.md now states that cadir and ReadWritePaths must name the same place. Two spec loops became DescribeTable/Entry per AGENTS.md:120. Refs #250
#189 merged, so `openvox-ca generate` is on main and step 3's mint path is no longer hypothetical. Verified rather than assumed: built both binaries from this branch rebased onto main and ran the real first-boot against a scratch ssldir. It mints, and what it produces is right -- subject=CN=ca.example.com, issuer=CN=Puppet CA: ca.example.com X509v3 Subject Alternative Name: DNS:ca.example.com notBefore 2026-08-30, notAfter 2031-08-30 (the 43800h asked for) private key mode 0600, cert and key public keys identical openssl verify against the bootstrapped ca_crt.pem: OK and a second run adopts what the first minted rather than minting again. **The capability probe stays, but it was catching the wrong thing to name.** It said the build "has no 'generate' subcommand ... (openvox-ca#189)", which pointed at a merged PR and read as a missing feature. Every build that ships this script now has the subcommand, so what the probe actually catches from here is a mismatched pair -- an older binary left on PATH by an earlier tarball install, or a partial upgrade -- which a package cannot prevent and which produces exactly the same unusable install. The message says that instead, and names the binary it actually probed. The probe itself is unchanged and still reads the root command listing: `openvox-ca generate --help` and `openvox-ca help generate` both exit 0 on a build without the subcommand, because cobra answers --help from the root before validating arguments and an unknown help topic is not an error to it. That reasoning did not depend on #189's state. **Also documents a warning operators will now actually see.** The mint prints openvox-ca generate's "this backend does not fully coordinate writes ... stop the server before running this". On a first boot that is expected and is precisely why this unit is ordered Before=openvox-ca.service: at the moment it runs there is no server to stop. Left unexplained it looks like the provisioning step reporting that it should not have run. The same warning after starting the oneshot by hand against a live CA is real, and docs/systemd.md now draws that line. Refs #250
Chris's ruling on #250: the 8141 default is defined once here rather than independently by #251 and #252. #158 records the decision -- 8140 is right for a tarball, a container and a CA-only host, and collides with OpenVox Server, which is what a package gets installed beside -- but not where it lives. **The mechanism is a configuration file, and the alternatives are wrong rather than merely less tidy.** The server resolves the port as config file, then environment, then flag: applyServerEnv overwrites the parsed file (cmd/openvox-ca/config.go), and the Changed("port") branch overwrites that (cmd/openvox-ca/main.go). So `--port 8141` in the unit, or PUPPET_CA_PORT in a drop-in, would not be a default at all -- it would beat the operator's own config file and ignore an edit made in the obvious place, silently. A default has to sit at the layer an operator can override, and only the file qualifies. **Which dissolves the reason the config file was out of scope.** The issue excluded it because "it is where the two formats diverge", and with nfpm they do not: `type: "config|noreplace"` is one declaration that becomes a dpkg conffile and rpm %config(noreplace). Verified rather than assumed -- the built .deb carries `/etc/puppet-ca/config.yaml` in its `conffiles`, and nfpm's rpm packager maps that type to `rpmpack.ConfigFile|rpmpack.NoReplaceFile` (rpm.go:422). Neither `apt upgrade` nor `dnf update` overwrites an operator's edit on either format. So the file ships here with the one setting that is genuinely a packaging decision, and #251/#252 build on it rather than each declaring a port. The rest stays at the binary's defaults deliberately: what a packaged CA does should be the documented behaviour of the binary, not a second set of values to look up. 0640 root:puppet, not 0644, because this is the file that will hold credentials once a backend needs them -- etcd_password, an inline OpenBao role_id -- and the service only needs to read it. The mode costs nothing now and is a permissions change on upgrade later. **The tarball channel is untouched and stays on 8140.** It ships no configuration file, and the shared unit gains no port: templating the port the way @bindir@ templates the path would have moved both channels. Specs pin both halves -- the deb's config sets 8141 at 0640, the rendered unit mentions neither `--port` nor PUPPET_CA_PORT, and the tarball's manifest is still exactly the two binaries and the unit. Mutation-checked: setting the file to 8140 fails the packaged-port spec on the expected value rather than incidentally. Refs #250
Acts on the Review Council verdict at 79829cf. Every finding was checked against the code first; all 22 were real. **The CRITICAL: a packaged install could not start.** The configuration file shipped one line, `port: 8141`, because it was created to hold the port and the port was the thing under discussion. openvox-ca has no built-in default for cadir and refuses to start without one, the unit passes no --cadir, and nothing sets PUPPET_CA_CADIR -- so `dnf install && systemctl enable --now` failed at startup with "cadir is required". **Fixing that exposed a second instance of the same defect, which the review did not catch.** With cadir set the server got one step further and then exited: it binds 0.0.0.0, and refuses plain HTTP on a non-loopback address. tls_cert and tls_key were missing for exactly the same reason. The credential provisioning mints is named for the host, which no file shipped in a package can know, so the configuration names two stable paths -- certs/openvox-ca-server.pem and the matching private key -- and the oneshot links them to this host's credential once it has one. That indirection also makes re-minting under a corrected name a relink rather than a configuration edit. Verified by running it rather than by reading it. Provision into a scratch tree, start the server with the shipped configuration and no flags: readiness endpoint: HTTP 200 GET /puppet-ca/v1/certificate/ca: -----BEGIN CERTIFICATE----- certificate presented: subject=CN=ca.example.com issuer=CN=Puppet CA: ca.example.com Specs now assert every setting the server refuses to start without, and cross-check that the paths the configuration names are ones the provisioning script actually links -- because if those two drift the service starts and exits, which is the failure this whole commit is about. **Security fixes.** The configuration file is 0640 and can hold credentials, but was owned by an account that does not exist when dpkg or rpm unpacks it, so it installed root:root and the service could not read it; postinstall now sets its owner, after sysusers has created `puppet`. Both chowns use --no-dereference: they run as root over a directory that `puppet` can write, where a planted symlink would otherwise redirect them. first-boot creates private_keys at 0750 rather than creating it 0755 and narrowing it afterwards. **Correctness fixes.** The unconditional `chmod` in ensure_ssl_tree broke the guarded-on-absence contract and would abort provisioning under `set -e` on a takeover host. The unresolved-name marker was written before the CA was created, so on a takeover it asserted a CA this run had not issued and told the operator to `rm -rf` an existing one -- it is now written only when this run created the CA. postinstall gained a maintainer-script argument guard, so `systemctl enable` runs on first install only and an upgrade no longer overrides an operator's deliberate disable. preremove stops the oneshot as well as disabling it, since RemainAfterExit leaves it active. The two copies of the localhost pattern list are now one; they had already drifted, so *.localdomain was rejected as an FQDN and accepted as a short hostname. `docTreeEntries` was consumed by fixed index, so a fourth documentation path would have been silently never packaged. `verifyMageTargets` read a hand-maintained list of five workflow filenames; it now globs the directory, with a floor, because a new workflow was simply not being checked. **Tests.** The certname allow-list -- this branch's own security fix -- now has coverage, and it runs the shipped shell function rather than a Go restatement of it: a table over is_safe_certname and is_localhost_name, driven through `sh`. Mutation-checked by weakening the allow-list to accept everything, which fails every rejection entry. Also renderUnit's missing-placeholder branch (split as renderUnitFrom so it is reachable), Build.Unit's success path, and extractTarGz's path-traversal refusal. Writing Build.Unit's success-path test exposed a defect its absence had hidden: the message printed the untrimmed bindir, so a trailing slash reported "ExecStart=/opt/bin//openvox-ca" for a unit that had been rendered correctly. **Documentation.** What removal does not do -- the CA key, the ssl tree and the `puppet` account all survive `apt remove`, which means removing the package does not decommission the CA. What sharing the `puppet` account costs: on a host also running OpenVox Server the CA's private key sits inside Server's trust boundary, which is the price of fitting the layout Server establishes and is worth stating rather than discovering. The two hard-failure paths in step 3. And the nfpm header, which still said the configuration file was not in this package. Refs #250
Acts on the third Review Council verdict, at 00561e5. All 11 findings checked against the code first; all 11 were real, and all 11 are fixed. **The rpm was proven to exist and never opened.** Both formats come out of one code path, but that is an argument rather than a check: nfpm renders per-format metadata differently for each, and only the deb was ever unpacked. The rpm's payload is now read with the reader nfpm already depends on -- rather than a second hand-rolled header parser, where the risk is getting it subtly wrong and asserting against the mistake -- and every property asserted of the deb is asserted of it. That also closes a gap this PR's own body admitted: it verified nfpm's `config|noreplace` rpm mapping by citing nfpm's source, while the deb half was verified empirically. The rpm is now inspected for RPMFILE_CONFIG and RPMFILE_NOREPLACE on the config file, and for their absence everywhere else. **The shell the packages ship had almost no coverage.** Now exercised through the shipped scripts themselves rather than restatements of them: `has_generate_subcommand` in both directions -- the probe reads the root command listing precisely because `generate --help` exits 0 on a build without the subcommand, and nothing protected that; `ensure_node_certificate` adopting a pair, refusing half a credential either way round, and stopping when the build cannot mint; `resolve_certname`'s precedence, including that an unsafe explicit answer stops rather than falling through to the next source. The maintainer scripts are exercised for the decisions that need no container: which arguments act at all, and that an upgrade does not re-enable a unit the operator disabled. Every external command is a stub that logs its arguments, so nothing touches this machine's accounts. What is still deferred is narrower than last round's deferral and stated as such: whether systemd-sysusers actually creates the account it is handed needs a real /etc/passwd, which is #254's leg. A test stubbing systemd-sysusers and then asserting the stub had been called would be asserting its own fixture. **A guard that could not fail for the case it existed for.** checkDocTreeFloor required LICENSE and README.md -- the two entries that cannot stop matching on their own -- and never checked `docs/`, the one whose pathspec can. A renamed docs/ passed the floor and would have packaged the two files beside an empty tree. **preremove accepted an argument dpkg cannot send it.** `purge` goes to postrm, never to prerm, so the branch was unreachable and read as though purge were handled here. The accepted set is now exactly dpkg's `remove` and rpm's remaining-count 0. **The ownership fixups reported nothing when they failed.** Both sent stderr to /dev/null and continued, so a package could install "successfully" and leave a service unable to read its own configuration with nothing anywhere saying why. They now warn, naming the consequence, and are still not fatal: a maintainer script that exits non-zero leaves the package half-configured, which is worse to hand an operator than a working install with a loud warning. **Three documentation drifts, and a fourth found by reading around them.** The packaged-install section still told the operator to write a configuration file the package now ships working; the provisioning summary omitted step 5 entirely, which is the step that makes the service able to start; and the certname paragraph described a marker written on every unresolved name when it is now written only when that run created the CA. That fourth one was mine from the previous round and no reviewer found it. Build.Unit's success path went through the repository's own dist/. It now goes through writeRenderedUnit against a temporary directory. go.mod gains go-rpmutils, a reader for the test only, and two indirects already in the graph via nfpm. Re-checked rather than assumed, because the PR body claims it: `go list -deps ./cmd/openvox-ca` is still byte-identical to main at 864 packages. Refs #250
… tiers Acts on the fourth Review Council verdict, at daf7a9b. Nine findings, of which two are one finding counted twice, so eight distinct. All checked against the code first; all real; all fixed. One remedy is deliberately not the one recommended -- see below. **A table that asserted nothing.** DescribeTable("preremove acts only on a real removal") took a shouldAct parameter and never read it. Its body checked only that the script exited 0, which a stubbed systemctl and a script wrapping every call in `|| :` make true whichever way the script behaves. Six entries, three of each polarity, none distinguishable. It would have passed had preremove done nothing at all, or stopped the service on every upgrade -- the exact defect the table is named after. Go does not warn on an unused parameter, so nothing but a reader could catch it. It now reads shouldAct and asserts on the call log. Making the positive half assertable needed the systemd runtime path to be overridable: the scripts guard their systemctl calls on /run/systemd/system, so on any host without systemd they correctly do nothing and "acts on a removal" cannot be checked at all. Same seam, and same caveat, as first-boot's roots -- nothing in the packages sets it and neither package manager passes an environment. Mutation-checked, because a table that could not fail once should not be taken on trust twice: making preremove accept `upgrade` and `1` fails exactly the two upgrade entries and nothing else. **The chmod remedy the finding recommended is not the one applied, and taking it would have made things worse.** The diagnosis is right -- plain chmod follows a symlink, so it was unguarded where the adjacent chown was not. But `chmod -h` is a recent GNU addition. Verified in a container rather than assumed: rockylinux:9 -> chmod (GNU coreutils) 8.32 chmod: invalid option -- 'h' exit 1 RHEL and Rocky 9 are primary targets for this package, and Debian bookworm ships 9.1 which also rejects it. Worse, the call site is `chmod ... || warn`, so on those platforms the rejection would be swallowed into a warning and the mode would never be set at all -- trading a symlink hole for not applying 0640 on the most common targets. Applied instead is the check the finding talked itself out of: refuse when $CONFIG is a symlink, saying why and what the operator should do. POSIX, portable to every target, and it does not depend on coreutils vintage. `-L` is tested before `-f` because `-f` follows the link. **resolve_certname's fallback tiers 2-4 were unreachable by any spec**, because nothing could control what `hostname` returns. A stub on PATH -- the technique the maintainer-script specs already use -- unlocks them: the dotted answer, the short-hostname warning, a localhost form correctly refused as an FQDN, and the localhost fallback itself. write_unresolved_marker is now covered too, which matters more than the tiers: it is the only thing that says a CA is unreachable when systemctl status says it is healthy. Covering it exposed that the function sat below the `-- Run --` banner, so it was part of the executable section rather than the definitions. Moved up with the other steps, where it belongs and where a spec can reach it. Build.Packages itself -- as opposed to the per-variant helper under it -- now has a spec for the case that matters: refusing when the tarballs it consumes are absent, which is the promise that it never silently rebuilds. **Stale prose, all four sites.** AGENTS.md's command table gained build:packages and build:unit; the Build namespace's inline listing gained the same two; docs/systemd.md step 4 described the ca.pem/crl.pem symlinks backwards -- they live in the ssl tree and point into the CA directory, not the other way round. Refs #250
Acts on the fifth Review Council verdict, at 3c072b8. **The bug, reproduced before it was fixed.** Provisioning a host whose certname is `ca` left `$SSLDIR/certs/ca.pem` holding that host's node certificate rather than the CA certificate: subject=CN=ca (the node) issuer=CN=Puppet CA: ca CA:TRUE occurrences: 0 Step 3 writes `certs/$NAME.pem`. Step 4 then wants `certs/ca.pem` to be a symlink to `../ca/ca_crt.pem`, finds a file already there, and -- being guarded on absence, which is correct and deliberate -- leaves it alone. The run reports success and logs nothing about the alias, because the "linking" line only appears when a link is actually made. Every agent on that host then fetches its CA certificate from a path holding a leaf. It is not a contrived name. Tier 3 resolves the short hostname, so a host called `ca.example.com` reaches this without anyone typing `ca` anywhere. `ca` and `openvox-ca-server` are now reserved and provisioning refuses them, naming the collision and what to set instead. Checked once, after resolution, so no tier can slip past it -- an explicit answer, puppet.conf and both hostname tiers all go through the same gate. A hard failure rather than a fallback: `ca` is a legitimate hostname, and an operator whose host is called that needs to be told why it cannot also be the certname instead of finding a CA issued under some other name. **The provisioning steps themselves now have coverage**, which is the other half of the same verdict and what makes the fix a regression test rather than a patch. The whole script runs against a scratch tree with stub binaries standing in for the CA -- the script is what the packages ship and what no CI leg installs, so it is what is under test. Asserted: the three directories with their modes, including private_keys at 0750 rather than created 0755 and narrowed; the bootstrap; the mint; all four aliases as symlinks with their targets; no marker when the name resolved; and that a second run adopts rather than re-minting, by comparing the credential before and after. The aliases are asserted as symlinks-with-targets rather than as paths that exist, because "does it exist" is exactly the check the bug passed. Mutation-checked: disabling the reserved-name gate fails both collision entries and nothing else. This was reachable only because the previous round added the PATH-stubbed binary technique for `resolve_certname`'s tiers. The seam built to close one coverage finding is what made the next one testable. Refs #250
Acts on the sixth Review Council verdict, at 731448c. Eight findings, six of them coverage, none reporting a defect in shipped behaviour. All real, all fixed. **A regression test that was a source-text grep.** The check for "an upgrade must not re-enable the oneshot" read packaging/scripts/postinstall and asserted the *shape* of its condition, justified by systemd being absent on the test host. That justification was stale the moment it was written: the SYSTEMD_RUNTIME seam added in the same commit made the branch executable, and nobody went back. A test that inspects source text cannot fail for the property it names -- it passes for a script whose condition is spelled correctly and does the wrong thing. It now runs the script and reads the call log, across four entries covering both package managers' spellings of install and upgrade. Mutated to confirm: making the enable unconditional fails exactly the two upgrade entries, with the message about overriding an operator's disable. **Directory modes asserted for existence only.** The packaged ssl tree was checked with HaveKey, so 0771 and 0770 -- an agent's group traversing the root without listing it, and the CA directory closed to everyone else -- were never checked at all. This is the same weakness that let a regular file stand in for the certs/ca.pem symlink last round; the map already carried the modes. Mutated: widening the CA directory to 0755 fails on the mode. **The ownership and permission hardening had no path to it.** chown --no-dereference, the symlink refusal that exists because chmod has no portable -h, and mode 0640 on a file that will hold credentials -- all unreachable from a spec, because they operate on absolute paths. SSLDIR and CONFIG are now overridable on the same terms as SYSTEMD_RUNTIME, and each branch is exercised, including that a failed fixup warns rather than failing the install. Mutated: removing the symlink refusal fails the symlink spec. **Also now covered:** postinstall's groupadd/useradd fallback for hosts without systemd-sysusers, and that it creates nothing when the account already exists; and Build.Packages' orchestration on its success path, through a buildPackagesInto seam so no spec writes into the repository's dist/. That second one asserts the FIPS variants stay unpackaged with all four tarballs present, so it would notice them starting to be. **The puppet.conf tier was untested, and the specs around it were not isolated from this machine.** first-boot read /etc/puppetlabs/puppet/puppet.conf by absolute path, so the tier could not be reached -- and the fallback-tier specs were reading whatever puppet.conf the developer happened to have. A spec that silently picks up the local certname passes for the wrong reason. The path is overridable now, the tier has four specs, and every spec around it pins the file it reads. **The SYSTEMD_RUNTIME block was duplicated verbatim three times.** The assignment stays duplicated and the ten-line explanation does not. A maintainer script has to be self-contained -- postrm runs when this package's files are partly or wholly gone, so sourcing anything shipped would make removal depend on the files being removed. One assignment repeated is cheaper than that. postinstall carries the canonical explanation; the other two point at it. **go-rpmutils was missing from this PR's own dependency trace.** It is a direct dependency this branch adds, and the trace it was absent from is my artefact. The PR body now covers both additions in a table, says plainly that go-rpmutils is test-only, and checks the shipped binaries against both plus everything they pulled in. Refs #250
The fallback spec added last commit passed on macOS and failed on the Linux runner, and the difference was the platform rather than the code. `PATH` was `stubBin:/usr/bin:/bin`, so removing the `systemd-sysusers` stub did not make the command absent on a host that ships a real one. CI's `command -v` found /usr/bin's, ran it, and it exited non-zero on a conf file the test never installs -- so `set -e` took the script down. On a machine with no systemd at all the removal genuinely did make it absent, and the spec passed for that reason instead of the one it claims. `PATH` now holds the stub directory and nothing else, with /bin/sh invoked by absolute path because of it. Every external command postinstall calls is stubbed, so what is exercised is the script's branching rather than the host's idea of which tools exist. The spec also asserts its own premise -- that `systemd-sysusers` really is unreachable -- so a future widening of PATH fails there, naming the reason, instead of quietly testing the host's real one again. The assertions are unchanged: `groupadd`/`useradd` must be *called*. Fixing this by relaxing them to "the script did not crash" would have retired the finding while leaving the fallback unexercised, which is the failure this round was about. **postinstall's own behaviour is deliberately not changed.** Exiting non-zero when `systemd-sysusers` fails is right: the file it is handed is part of this package's payload, and both package managers unpack the payload before running the scriptlet -- dpkg configures after unpack, rpm runs %post after file installation -- so a failure there means the install is already broken, and the account the service runs as does not exist. That is worth failing loudly for. It is a deliberate asymmetry with the ownership fixups below it, which warn and continue: without the account nothing works at all, whereas an ownership problem is visible and repairable. The CI failure came from the test skipping the unpack step, not from the script mishandling a case a real install can reach. Refs #250
…ctor First batch from the Review Council run at 732ad09. These are the findings that change what an installed package does. **Provisioning was never enabled on an install without a running systemd.** `systemctl enable` sat inside the `[ -d /run/systemd/system ]` guard, but enabling is on-disk symlink work -- it writes into /etc/systemd/system and succeeds in a chroot, a debootstrap or mmdebstrap target, a container image build, or any install into a mounted root. Only `daemon-reload` needs a running systemd. So on every one of those hosts the symlink was never written, and because the oneshot's only [Install] directive is RequiredBy=, a unit that is never enabled is a unit that never runs: the first `systemctl start openvox-ca` then exits on the serving credential provisioning would have created, and nothing retried on the next boot. Image builds are not an edge case for a package like this. The enable now sits outside the guard, and its outcome is reported rather than sent to /dev/null. That failure decides whether provisioning ever runs: swallowed, it left a host whose first start bootstraps a CA inside the server process under a name none of first-boot's checks ever saw -- not the reserved-name refusal, not the certname allow-list, not the unresolved-name marker. **A remove-then-reinstall left the oneshot disabled.** dpkg passes the previously-configured version in $2 for a package that was removed but not purged as well as for an upgrade, so the arguments alone cannot tell a reinstall from an update -- and our own preremove had disabled the unit on the way out. The decision is now a marker this package writes when it enables and preremove removes on removal, so a reinstall enables again while an operator's deliberate `systemctl disable` still survives an upgrade. **Account creation was the one command still fatal under `set -e`**, against this script's own stated policy, and it aborted *before* the ownership fixes -- so a host where the account already existed but sysusers exited non-zero for an unrelated reason ended up with a configuration file the service could not read. It warns and continues, like everything else here. **`hostname` is used by first-boot and was declared by neither format.** It is a separate package on Debian and EL and is absent from minimal and container base images. Missing, both `hostname -f` and `hostname -s` fail into /dev/null, resolver tiers 2 and 3 are skipped in silence, and provisioning mints a CA under `localhost` whose documented recovery is destroying and re-minting it. **extractTarGz's path-traversal guard could not fire.** The allowlist skip ran first, so an archive entry named `../../openvox-ca` failed the allowlist and was skipped by `continue`; the guard was reachable only when the *caller's* own list named a traversing path, which is exactly what the spec did. It proved the branch compiles, not that the guard defends anything. The check now runs before the allowlist, over every entry. The same loop never inspected Typeflag, so a symlink, hardlink or directory entry named `openvox-ca` wrote zero bytes, marked the name satisfied, and caused a later real entry of that name to be skipped -- an installable package containing an empty binary, from a build that reported success. Regular files only now. And the G110 suppression there gave the entry allowlist as its reason, which answers a different question: G110 is about how far the gzip stream expands. Replaced with an actual bound. **verifyPackagesWritten censused the directory rather than the run.** build:distVariant does not clear dist/ the way build:dist does, and it is what CI, the release workflow and this target's own error message all tell you to run -- so a version bump followed by distVariant and packages left the previous version's packages in place, the count came to four, and a correct build failed while naming a filename collision that had not happened. buildVariantPackages now returns what it wrote and the check verifies those paths: each exists, none written twice, the right number per format. The collision it was actually for is detected directly rather than inferred from a census. Mutation-checked: putting the enable back inside the runtime guard fails the spec written for it, and nothing else. Refs #250
The Council's prose-accuracy set: eleven places where a comment or a paragraph described something the code no longer does, or never did. The two that could mislead an operator: - "A takeover does nothing at all" was wrong in the direction that matters. Provisioning is guarded on absence, so it adopts rather than replaces -- but on a host that already has a CA it still mints a node certificate, still creates the certs/ca.pem and crl.pem aliases, and still links the serving credential. That is the point: it is what makes an install over an existing cadir produce a service that starts. Corrected in the script header, the oneshot's header and systemd.md. - The oneshot did not run "under the same hardening as the service, so provisioning cannot reach anything the service could not". Its ReadWritePaths= is the whole ssl tree rather than the CA directory alone, deliberately, because it links above the CA directory. Said so, and said what re-running it actually takes: RemainAfterExit=yes means the next boot or an explicit restart of the oneshot, not a restart of the service. Documented the other edge of Requires= too -- stopping the oneshot stops the CA with it, long after it has finished. The rest: the unit's "this file is a TEMPLATE" header shipped verbatim in both rendered copies, where it was no longer true of the file the reader had open; the port comment named 8140 as the default a packaged install runs on; the config file said it set "two things" and set four; postinstall said the configuration file was "not in this package"; the packaged field pointed at counts release.yml does not have yet; and one spec claimed to be the only test exercising nfpm, with two others building through the same function. The docs the packages made stale: - storage-backends.md pointed nine examples at cadir /var/lib/puppet-ca, which the unit no longer creates and does not grant. - configuration.md's serving-certificate procedure predates provisioning that mints one, told you to stop a service "on port 8140" that a package puts on 8141, and never mentioned that a packaged install arrives with /etc/puppet-ca/config.yaml already written. - systemd.md gained the missing half of the account trade: installation chowns the ssl tree to puppet:puppet even where an agent created it as root, and directory write permission is what governs replacing the trust anchors inside it. - README had no package channel at all. No behaviour change: comments, documentation and one reworded spec comment. mage dev:check is 0 issues and mage test:magefile is green.
The Council's coverage set. Every spec here checks something a package does that no existing spec could have failed for. What the packages carry that nothing opened: - **Maintainer scripts.** dpkg reads them from the control archive and rpm from header tags, neither of which is the payload -- so every existing assertion held for a package that shipped none. That package installs cleanly and leaves no `puppet` account, no enabled oneshot, and a configuration file the service cannot read. Both formats are now compared byte for byte against the scripts in packaging/scripts/. - **The deb's conffiles entry**, the dpkg half of the one `config|noreplace` line; the rpm half was already asserted, and one declaration produces both, so reading only one of them would not notice the other stopping. - **The deb's ownership** of /etc/puppet-ca/config.yaml. dpkg writes the owner it is given without correcting one it cannot resolve, and the file is 0640: root:root means openvox-ca exits at startup. What the units say, which only a reader was checking: RequiredBy= with no WantedBy=, Before= rather than After=, Type=oneshot with RemainAfterExit=yes, an ExecStart that agrees with the path nfpm.yaml installs the script to, the deliberate ReadWritePaths= split between the two units, User=/Group= on both, and equality across the 24 hardening directives the two files maintain by hand. Drift in any of those is silent: the unit still starts, it is just less confined than its own comment claims. The postinstall's comment said magefile_test.go asserted that the sysusers declaration and the useradd fallback create the same account. It did not; now it does, home directory and shell included. Three more: - postremove asserted only that it exits 0, which is equally true of a script that deletes the `puppet` account. Its call log is now matched exactly against `systemctl daemon-reload` -- with userdel and groupdel stubbed so a regression shows up rather than failing to resolve. - The unresolved-name marker tells the operator to `rm -rf $CADIR`. It is written only when this run minted the CA, and nothing checked the takeover half of that. - stageDocTree enumerates through `git ls-files` precisely so untracked drafts under docs/ are not packaged. Asserted, premise included. And two isolation fixes to specs that could pass for the wrong reason: the certname fallback tiers now pin OPENVOX_CA_PUPPET_CONF, without which a host with a certname in /etc/puppetlabs/puppet/puppet.conf answers every tier from that file; and the SOURCE_DATE_EPOCH spec pairs "two builds agree" with "a different epoch differs", because the first alone passes for two builds inside the same second -- it did, with the variable unset. That last spec found a real defect, so one behaviour change comes with it: stageDocTree now stamps the staged documentation with SOURCE_DATE_EPOCH when it is set. nfpm stamps everything it generates itself from that variable, but the docs go in as a tree of real files and a tree entry keeps the mtime it finds -- written moments earlier, so "now". Every other byte was already reproducible, which made the doc tree the only thing between these packages and a verifiable rebuild. Unset is left alone: that variable is the caller saying this build is meant to be reproducible, and inventing a timestamp otherwise would put a wrong date on installed documentation. Recorded in releasing.md. The unit specs, the takeover guard and the reproducibility pair were each mutation-checked. mage dev:check is 0 issues; mage test:magefile is green at 345 specs.
Round seven, the two findings that change what an installed package does.
**RemoveIPC=yes had to go from both units.** It removes every System V
and POSIX IPC object owned by the unit's UID when the unit stops -- the
UID, not this unit's processes. That was harmless while the service ran
as a private `puppet-ca`. It is not once the account is `puppet`, shared
with openvox-agent and openvox-server by design, because a co-located CA
and Server is precisely the deployment that sharing exists for. Stopping
the CA -- including the `systemctl restart openvox-ca-first-boot
openvox-ca` this branch's own provisioning prints as remediation --
could reap IPC belonging to a neighbouring service.
Dropped rather than justified. The Council offered the alternative of
documenting that openvox-server holds no SysV IPC under that UID, and I
have not taken it: that is another product's internals, it can change
without us, and a hardening directive should not depend on it. The
stronger fact is on our side of the line -- openvox-ca creates no System
V or POSIX IPC object at all. Its only IPC is the AF_UNIX socketpair the
launcher hands the isolated signer on fd 3, which RemoveIPC= does not
cover and the kernel reclaims when the last descriptor closes. So the
directive had no cleanup to do here, and the only thing it could ever
have acted on belonged to somebody else.
The hardening-equality spec added last commit caught this itself, with
the message written for exactly this case ("the service no longer sets
RemoveIPC, so this list is stale"). Its absence is now pinned on both
units, so a later hardening sweep cannot quietly restore it.
**The compatibility contract needed to say what it means.** AGENTS.md
listed `/var/lib/puppet-ca` among preserved "default paths" while this
branch makes the packaged and systemd default
`/etc/puppetlabs/puppet/ssl/ca`. It is a contract about *names*, not
about which path is the default, so both statements were true and read
as contradictory. Said so, in AGENTS.md and in CONTRIBUTING.md's
summary of it: a default may move, a `puppet-ca` spelling may not be
rebranded.
Verifying that turned up something the finding did not raise. This
branch introduces `/var/lib/openvox-ca`, and it is the *only* path in
the tree spelled that way -- every sibling says `puppet-ca`. Recorded as
the deliberate exception it is, with the reason it cannot simply move
under `/var/lib/puppet-ca`: that is a cadir on any host upgraded from an
earlier release, and a marker file dropped inside somebody's CA
directory is its own confusion.
**And the marker write no longer swallows its failure.** Both
`mkdir -p "$STATEDIR"` and `: >"$ENABLED_MARKER"` ended in `|| :` in a
script whose whole design is that failures are reported. If either
fails, the unit is still enabled but nothing records it -- so the next
upgrade re-enables, silently undoing an operator's deliberate
`systemctl disable`. That is the one outcome the marker exists to
prevent. It now warns, naming the consequence and the remedy, and still
does not fail the install.
mage dev:check is 0 issues; mage test:magefile is green.
Round seven's remaining eight findings. The one with teeth was rated LOW and is the reason this commit exists. **Provisioning now refuses when it finds a CA at the old default.** The default cadir moved this release: earlier releases shipped `StateDirectory=puppet-ca` and told operators to put `cadir` under `/var/lib/puppet-ca`. A host that followed that advice and then installs this package has a real CA the new default cannot see -- and `ensure_ca` only ever looked at the new one, so it would have bootstrapped a SECOND CA. Every agent already enrolled against the first would stop verifying, both CAs would exist, and nothing in the resulting system would say which was live. It refuses rather than migrating, deliberately. Moving a cadir is not a copy -- it carries the private key, the inventory and the CRL, the service may be running against it, and the right destination depends on what the operator set `cadir` to. A maintainer script that got any of that wrong would be unrecoverable in a way that stopping is not. The message carries both routes out and says what each costs. The guard reads `/var/lib/puppet-ca`, which is a real path on any machine that ran an earlier release -- a developer's laptop included -- so `runFirstBootScript` pins it away from the host. Left unset, a CA sitting there would make every provisioning spec refuse, and the failure would look like a defect in the script rather than in the fixture. Same shape as the `puppet.conf` isolation fixed last commit. **And the two coverage HIGHs.** The declared runtime dependencies had no spec, on either format: drop `hostname` from a `depends` list in a future edit and first-boot's resolver tiers both fail into /dev/null on a minimal image, minting a CA under `localhost` that no agent can use -- a silent wrong answer, not a failure. Both lists are now read out of built packages, deb from the control archive and rpm from REQUIRENAME. Mutation-checked by removing `hostname` from the deb list alone: the deb spec failed, the rpm spec stayed green, which is the point of asserting the two `overrides:` blocks separately. The rest: - first-boot's two "the package is incomplete" guards had no spec for their failure branch -- stubCA always writes both binaries executable and every caller creates $SSLDIR first, so the true branch ran everywhere and the false branch nowhere. Four cases now: each binary absent, each non-executable, plus the missing ssl tree. - $STATEDIR was never removed on uninstall, only the marker inside it. `rmdir`, not `rm -rf`: it takes an empty directory and refuses a non-empty one, so anything somebody else put there survives. Asserted in all three states. - The output-file failure path is covered, and its error is now wrapped like every other error in that function -- bare, it surfaced as an open(2) message naming a path, with nothing to say which format or variant had stopped. `packager.Package` failing stays uncovered, and the spec says so rather than implying otherwise: reaching it needs an nfpm-internal fault after the output file is created, and nfpm has no input that produces one -- it sanitises even a version of "not a version". Forcing it would mean a seam in buildVariantPackages existing only for the test, which is a worse trade than the gap. mage dev:check is 0 issues; mage test:magefile is green at 361 specs.
The third instance of a wrong number I have been carrying, and the one that matters most, because it is in shipped code rather than in a PR body: `requiredMageTargets`' comment said the packaging job "calls it twice". It calls it once, at `release.yml:155` on #266's branch; the other match is a comment describing the target. Same cause each time: `git grep -c` counts matching LINES, not occurrences, and prose clusters next to the code it describes -- so a well-commented call site inflates its own count. The corrected comment now states the count with its file and line, and says why it is stated at all, because the next person to check it will reach for the same grep and get the same 2. The consequence here is worse than a wrong figure in a PR description. That comment exists to stop someone deleting the entry on the evidence of a grep through release.yml. A reader who checks its central claim finds one invocation, concludes the comment is wrong about the very thing it is warning them not to touch, and deletes the entry -- which is exactly the outcome it was written to prevent. Checking the rest of the same sentence found a second false clause, and this one I falsified myself: it said docs/development/releasing.md "does not mention packages at all". It has since acf6f57, two commits ago, where I added the section describing `mage build:packages` and SOURCE_DATE_EPOCH. What that file still lacks is a release *job* that builds them, which is the thing this entry stands in for, so the clause is now stated that way instead. "release.yml here has three jobs (verify, build, release) and no packaging job" was checked and is correct. Swept the branch for other instances of the count; the remaining uses of "twice" are unrelated. Not touched: the forward reference at magefile.go:1139 ("...asserts once #266 has added its packaging job"). It is correct today and becomes wrong on #266's merge, so #266's driver is carrying it as theirs to re-apply -- they are the one whose merge falsifies it, and two sessions editing one line to fix it is worse than one. Comment-only. mage dev:check is 0 issues; mage test:magefile is green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chris's ruling: no "upgrading" documentation until 1.0.0 is tagged, because there is no installed base for it to address -- but tell him about breaking changes, in the PR body, which is the venue he reads. The premise is checkable and I checked it before acting: zero git tags, zero GitHub releases, `internal/version.Version = "0.9.0-dev"`. So `docs/systemd.md`'s "Upgrading an install made before this release" is gone. Its three-step recipe moves to #282's PR body as a breaking-change note for anyone running a systemd install from `main`, which today is one person. **Checking the rest of that section turned up something worse than the policy breach.** It opened "Earlier releases shipped `User=puppet-ca`, `Group=puppet-ca` and `StateDirectory=puppet-ca`" -- and no release has ever shipped anything. Those settings are on `main`, and `main` is not a release. The prose asserted a release history that does not exist, which is false today and stays false until the tag. That claim had spread past the section being removed, so this corrects the class rather than the instance -- five more sites, found by grepping the branch's own diff for the assertion rather than for the word "upgrading": - `packaging/scripts/first-boot`'s failure message, the one an operator actually reads, said `/var/lib/puppet-ca` "was the default before this release". - Two comments in the same script, on `LEGACY_CADIR` and in `ensure_ca`. - The isolation comment in `magefile_test.go`, and the exception note in `AGENTS.md`. Each now says where that path actually comes from, which is checkable and does not expire at 1.0.0: it is the Helm chart's `persistence.mountPath` default, it is what `StateDirectory=puppet-ca` on `main` creates, and it is a common hand-install choice. **The behaviour stays documented, unqualified.** Provisioning's refusal to bootstrap over a CA at `/var/lib/puppet-ca` is present-tense behaviour that applies to anyone, not advice to an upgrader, so it moves into the provisioning steps beside the other stopping conditions rather than out of the docs. Deliberately not swept on the word: `docs/helm-chart.md`'s `## Upgrading` is about `helm upgrade`, `releasing.md`'s is about pre-release tags, and `locking.md`'s describes a one-time conversion as a fact of the code. All three are true and none addresses an installed base. The ruling is about upgrade prose, not the string. mage dev:check is 0 issues; mage test:magefile is green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six findings at 658cac9: one HIGH, five MEDIUM. Each fix carries its own spec in this commit rather than in a later one -- two of these findings were against code that round seven's own fixes introduced, and a remediation that adds a branch without adding its spec just buys another round. **The HIGH is a guard that was never driven.** extractTarGz refuses a tar entry that is not a regular file, because a symlink, hardlink or directory entry carries no payload -- io.Copy would write nothing, the wanted name would be marked found, and extraction would "succeed" while producing a package holding a zero-byte binary. Every existing spec built archives of TypeReg entries only, so the guard was read but never fired. Now driven with all three flag types under a wanted name, each asserting the refusal names the reason and that no empty file is left behind. The guard itself is unchanged: it was correct, it was untested. **The TTL is not a coverage item and is not fixed with one.** first-boot's `NODE_TTL=43800h` deliberately matches internal/ca's `certValidity = 5 * 365 * 24 * time.Hour`, and nothing but a person remembering kept them together. The drift is silent in the worst way: node certificates minted at provisioning would expire on a different schedule from every certificate the running CA issues, surfacing years later as an unexplained expiry. So `mage dev:check` now derives both sides and compares them -- `verifyNodeTTL`, beside the other cross-file agreement guards. Derived rather than asserted against 43800h, because a spec pinning today's literal must be edited whenever the policy legitimately changes, which is exactly the moment someone edits one side and not the other. A deliberate change passes as soon as both files agree, with no edit here at all. Mutation-checked in both directions: changing the shell value alone fails, changing the Go value alone fails. **The doc-tree fixture no longer writes into the working tree.** The spec whose subject is "a stray untracked file under docs/ must never reach a package" created exactly such a file in the real checkout, with DeferCleanup to remove it -- which holds for a pass and for an assertion failure, but not for a timeout, a Ctrl-C or an OOM kill. It now builds a scratch git checkout and runs against that, through a new `stageDocTreeFrom(repoRoot, dest)` seam in the same shape as renderUnitFrom and buildPackagesInto. That sandbox also made the umask finding testable: the fixtures are committed 0600 so a copy preserving the source mode fails, which pins copyStagedFile's 0644. Without it, one commit yields packages whose documentation is world-readable on one build host and not on another. Mutation-checked by making copyStagedFile preserve the source mode. The last two are branches this PR added and did not cover: stampStagedFile's malformed-SOURCE_DATE_EPOCH fallback, which must degrade to unstamped rather than fail the build (it mirrors nfpm's own leniency, and being stricter than the tool it mirrors would be a defect of its own); and postinstall's enable-succeeded-but-marker-write-failed path, forced by pointing $STATEDIR at a regular file. That one asserts the enable still happened -- a spec that passed with no enable at all would be asserting the wrong failure. mage dev:check is 0 issues; mage test:magefile is green at 369 specs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four HIGH findings from our own Council round. Three are defects in code this PR added in rounds seven and eight, and two of them make a documented remedy impossible rather than merely awkward. **first-boot never read the configuration the server reads.** CADIR was fixed at $SSLDIR/ca, and nothing resolved `cadir` from /etc/puppet-ca/config.yaml -- the file this package ships and explicitly invites the operator to edit. Move it, and the service reads the new location while provisioning still bootstraps at the old one, then points certs/openvox-ca-server.pem at a leaf signed by the CA nobody is using. Worse, the legacy-cadir refusal added in round seven tells the operator to set `cadir` and restart the oneshot. On that restart it re-read the same hard-coded constant, found no CA there, found one at the legacy path, and failed identically -- for ever. Since the oneshot is RequiredBy=openvox-ca.service, the service could then never start. The remedy I shipped could not work. It now resolves `cadir` from the server's own file, falling back to $SSLDIR/ca. The reader is deliberately not a YAML parser: it reads two top-level scalars, declines anything it cannot read confidently, and every caller treats no answer as a reason to fall back or stop. A wrong answer would be worse than none -- it would point provisioning at a directory the service does not use. **Bootstrap and mint addressed different stores.** `openvox-ca-ctl setup` builds storage.New(cadir) and reads no configuration at all; `openvox-ca generate` resolves the config file and mints through whatever backend it names. On a host configured for etcd, redis or SQL, step 2 wrote a second CA to the filesystem, step 3 minted from the real one, and step 4 pointed $SSLDIR/certs/ca.pem -- where every agent on the host reads the CA certificate -- at the stray one. The same "two CAs and nothing says which is real" hazard the legacy-cadir block exists to prevent, reached by another route and with no refusal at all. Provisioning now refuses when a non-filesystem backend is configured, naming the backend and the steps that do work. Refuse rather than guess, consistent with the rest of the script. **And provisioning could not write into an agent-created ssl tree.** The postinstall handed over two directories; first-boot writes into three more -- certs/, private_keys/, public_keys/ -- which are not packaged. On the co-existence case this package advertises, openvox-agent created them as root, ensure_ssl_tree deliberately leaves an existing directory alone, and a `puppet` process cannot create a file in any of them. The run died on a bare "ln: Permission denied" three steps later. Closed at both ends, as the finding suggested: the postinstall now chowns those three where they exist (still --no-dereference, still not recursive, absent paths skipped rather than created), and ensure_ssl_tree tests each for writability by the running account and fails with the exact chown to run. Belt and braces because the chown covers the install-time case and the check covers every other way the ownership can be wrong. **The test fixture was reintroducing a bug this suite already paid for.** The scratch checkout added last round execs git with the ambient environment inherited. git exports GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and GIT_OBJECT_DIRECTORY to its hooks and they outrank `-C`, so under a pre-push hook those five calls write into the repository being pushed -- which is exactly what magefile_chart_test.go records happening once before, and why gitIn/fixtureEnv exist in this same package. It now uses them. stageDocTreeFrom had the same exposure in production code, flagged by the reviewer as the same class: a `mage build:packages` run under a leaked GIT_DIR would enumerate a different checkout than the one it is packaging. It now strips GIT_* for that one fixed question. Ten specs added, and OPENVOX_CA_CONFIG pinned away from the host in the first-boot helpers -- the new reader defaults to a real path on any machine with a packaged CA, which is the same leak already fixed for puppet.conf and the legacy cadir. The cadir resolution is mutation-checked: restoring the hard-coded constant fails the spec written for it, and nothing else. mage dev:check is 0 issues; mage test:magefile is green at 379 specs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ential
The substantive MEDIUM findings from our own Council round. Two of them
mean specs on this branch were asserting things that could not fail.
**The certname allow-list never delivered the input it claimed to.**
`fmt.Sprintf("is_safe_certname %q", name)` uses GO quoting where SHELL
quoting is required, so the newline entry sent the two characters
backslash-n and the guard was never asked about a real newline. Both
call sites now use the file's own shellQuote. The guard was already
correct -- it rejects a literal newline -- but the spec was not
establishing that.
**Every run in the hardening block took the marker-failure branch.**
That block sets PATH to stubBin alone, and `mkdir` was never stubbed, so
`mkdir -p "$STATEDIR"` failed on every invocation. The "warns rather
than failing" assertion was therefore satisfied by the marker warning
whatever the script did with the chown it was meant to be testing.
`mkdir` now logs and execs the real tool, and the spec is
mutation-checked: with chown restored to success it fails, which it
could not do before.
**A credential the CA did not issue could become the identity it
serves.** ensure_node_certificate treated "a pair exists under this
certname" as "this host already has its credential", and
link_serving_credential then pointed tls_cert/tls_key at it. On a Server
compile master -- the deployment the config file says a package lands on
-- /etc/puppetlabs/puppet/ssl already holds an openvox-agent credential
signed by the estate's remote CA and no local cadir, so provisioning
minted a new CA and then served a certificate that CA did not issue.
Every client that verifies rejects the handshake; nothing upstream
notices.
Adoption is now conditional on this run NOT having created the CA, which
is the takeover case it was written for. The refusal names both routes
out, because an operator told only to move the credential aside would
strand a host that should be joining the estate's CA. `ca_existed` is
read without a `:-` default deliberately: under `set -u` a caller that
forgets to establish it fails loudly rather than silently adopting.
**The unresolved-name marker told operators to delete an agent's
credentials.** Its remediation ran `rm -rf $SSLDIR/certs
$SSLDIR/private_keys $SSLDIR/public_keys`, on the host most likely to
see it -- one that already ran an agent -- destroying material issued by
another CA and recoverable only by re-enrolment. That contradicted the
script's own stated invariant. It now names only this run's own files,
and says why the rest is off limits.
**An upgrade left the old binary serving.** preremove deliberately does
not stop the service on an upgrade, which is right, but nothing then
picked up the new one: `apt upgrade` replaced the binary and both units,
reported success, and the old process ran on indefinitely with nothing
saying a restart was outstanding. The postinstall now issues
`systemctl try-restart` on the upgrade path only -- try-restart so a CA
deliberately left stopped stays stopped, and an install still never
starts one.
Also: copyStagedFile now chmods after writing, because os.WriteFile's
mode is masked by the umask and the comment promising umask independence
was simply false under 0027; extractTarGz refuses an over-sized entry
instead of silently truncating it, which is the same short-binary hazard
the Typeflag guard already refuses; verifyNodeTTL gained a
verifyNodeTTLIn seam and six specs, having been the one new dev:check
guard with no coverage at all; and four documentation corrections --
the packaging recipe no longer tells readers to build FIPS tarballs the
packages never use, the deb filename carries its `-1` release component,
the migration guide stops citing a commented-out ReadWritePaths= line
that no longer exists, and it now says a packaged CA serves 8141.
mage dev:check is 0 issues; mage test:magefile is green at 397 specs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The coverage findings from our own Council round, and one of them turned
a mutation check on its head.
**Nothing asserted the script's central invariant.** "Nothing already on
disk is overwritten, moved or re-signed" rests entirely on
link_if_absent, and no spec exercised it. Three now do: an existing
certs/ca.pem holding another CA's bytes must survive untouched and must
still be a regular file rather than a symlink; an operator's own
serving-credential symlink must still point where it did; and the modes
of directories the script did not create must be unchanged.
Mutation-checking those taught me something about what the promise
actually rests on. The finding suggested `ln -s` becoming `ln -sf` as
the regression to catch, so I made that change -- and every spec still
passed. `ln` never runs on an existing path, because link_if_absent
returns early when one is there. The flag is unreachable while the guard
stands, so the guard is the protection and the flag is not. Mutating the
guard instead fails both specs, each with the message written for it.
A mutation that does not fire is worth as much as one that does: it says
the property lives somewhere other than where you looked.
**The resolved certname was never shown to reach either command.** Four
resolver tiers decide it and two commands consume it, and the specs
asserted only that provisioning succeeded -- so a resolver change could
have left the bootstrap and the mint running under a different name.
stubCA's two stubs now log their argument lists, and a spec asserts
`setup --cadir <cadir> --hostname <name>` and `--certname <name>` are
what they actually received.
**`--now` is the one flag preremove argues for, and the table could not
see it.** ContainSubstring("openvox-ca.service") passes for a plain
`disable`, which removes the symlink and leaves the oneshot active with
no unit file behind it -- reported as "not-found" until reboot. The
acting cases now assert the exact two call lines, in order.
**And purge stopped being asserted by grep.** It was a source-text spec
sitting thirty lines above this file's own rule that such a spec cannot
fail for the property it names. It is an Entry in the table now: dpkg
sends purge to postrm and never to prerm, so doing nothing is the
correct behaviour and running it is how you find out.
Also: the systemd-sysusers failure path now has a spec, including the
half that was actually at stake -- that the run carries on to the
ownership fixes rather than aborting before them, which is the
regression the `|| warn` was added for.
mage dev:check is 0 issues; mage test:magefile is green at 402 specs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last of our own Council round: the low-severity set, plus the test duplication the Guard flagged. One finding is declined, and the test suite is what told me to decline it. **Declined: aligning the workflow-parse floor with the scan.** The finding is right that the two halves match over different inputs -- the scan strips comments, the floor reads raw bytes -- and recommended narrowing the floor to the stripped text. I made that change and it broke the spec that encodes the floor's purpose, which is the signal. The floor is a tripwire, not a second scan. It fires when a workflow says "mage" anywhere and the parse found no invocation, and the case it exists for -- steps moving into a composite action or a reusable workflow, where the parse can no longer reach them -- characteristically leaves a comment behind naming the command that used to run there. Narrowing it would trade a hypothetical false positive for blindness to the real case: a workflow mentioning mage only in a comment AND invoking it nowhere, which no workflow here is -- release.yml invokes it at line 64. The asymmetry is now documented as deliberate, with the spec named as what pins it. **The rest are fixed.** `build:unit` joins requiredMageTargets: it is named in docs/systemd.md and AGENTS.md, invoked by no workflow, and nothing in Go would notice its loss, which is that list's criterion exactly. The workflow glob follows both extensions, because GitHub accepts `.yaml` and the `len(paths) < 2` floor could never have noticed the gap -- the five existing `.yml` files keep it satisfied. Two specs stopped asserting less than they read. The subset table asserted two hard-coded names against `distVariants()` and never consulted `packagedDistVariants()` at all, so the relation in its title was not what it checked; it now derives from the packaged set. And `Build.Packages`' only spec skipped itself whenever `dist/` held this version's tarballs -- the state of any machine that has just run `mage build:dist`, which is precisely the machine exercising that path. It now drives `buildPackagesInto` against a temporary directory unconditionally. I wrote a second spec there asserting `Build.Packages` is a bare call to the seam, then deleted it: it asserted source formatting, broke on a gofmt line wrap rather than on a behaviour change, and this file argues against source-text specs elsewhere. The consequence is stated in a comment instead. The dist tarball fixture was copy-pasted into four setups and is now one helper. The prefix is deliberately not a parameter: the tarball carries the unit rendered for /usr/local/bin and the packaging path must re-render it for /usr/bin, so a fixture that quietly staged the package prefix would make that distinction untestable -- which is most of what the payload specs are for. Smaller corrections: the unresolved-name marker's doc pointer is the installed path rather than a repo-relative one that does not exist on the host; its write is guarded like every other write in that script; AGENTS.md no longer claims /var/lib/openvox-ca is the only path spelled the new way, which the package's own /usr/bin, /usr/libexec and /usr/share paths contradict; releasing.md no longer credits coverage of the unset SOURCE_DATE_EPOCH case; CONTRIBUTING documents build:unit and build:packages; the systemd docs mention the documentation tree the shipped config file points operators at; and a comment justifying the binary precheck no longer cites text the failure message stopped carrying. mage dev:check is 0 issues; mage test:magefile is green at 403 specs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fail `read_config_key` declined any value carrying a trailing comment, and both callers read that decline as "key absent" and fell back. So `storage_backend: redis # or valkey` -- the exact line docs/storage-backends.md hands operators to paste -- silently defeated the guard that exists to refuse a non-filesystem backend, and a relocated `cadir` written the same way was provisioned at the shipped default. Worse in the other direction: `cadir: >` came back as the literal string ">". Being non-empty it defeated the fallback outright, and provisioning bootstrapped a CA into a directory named ">". This branch had done exactly that in the working tree before the fix landed. An inline comment is now stripped rather than declined; a block scalar, an anchor, an alias, a flow collection or an unmatched quote is declined; and `config_key_set` lets the callers tell "absent, so use the default" from "present and unreadable, so stop and name the line". Provisioning also resolves `PUPPET_CA_CONFIG`, `PUPPET_CA_CADIR` and `PUPPET_CA_STORAGE_BACKEND`, because the server resolves them and a drop-in that reached only one of the two left them checking different files. `require_filesystem_backend` accepts every spelling `ParseBackendKind` accepts. It matched `filesystem` alone, so `storage_backend: local` was an ordinary filesystem deployment to the server and a hard refusal here -- and because the oneshot is `RequiredBy=`, a correctly configured host that would not start. `mage dev:check` now compares the two lists. The trust-anchor aliases follow the resolved cadir instead of hardcoding `../ca/`, which on a relocated cadir pointed `certs/ca.pem` at an empty directory or at a Puppet Server CA the service does not serve from. Documentation caught up with the code it describes: the ownership disclosure lists five directories rather than counting two, step 3 states its third stop case, the README no longer promises coexistence provisioning refuses, and the build recipe names both variants because `build:packages` requires both. Counted enumerations became lists, which is what drifted. Several assertions could not fail on what they named. The postinstall chown was pinned by a prefix substring satisfied by any list starting with the ssl root; the rpm block asserted no file contents at all while claiming parity with the deb; both fixture binaries carried one body, so shipping the server as the operator CLI was undetectable; the certname allow-list was never driven on either hostname tier; and the workflow-glob floor sat above its own seam where no spec could reach it. Each is now driven, and each was mutation-checked to fail for the reason it names. Also: staged documentation directories take 0755 rather than the build host's umask -- the file half of that promise was kept, the directory half was not, and nfpm reads a tree entry's mode from disk; a failed `Close` removes the half-written package and says which format and variant; and the 2 GiB extraction fixture is now 64 KiB, since the property under test never depended on the bound's magnitude. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolve_certname's comment states that puppet.conf outranks the hostname tiers, and no spec could see it. One block points OPENVOX_CA_PUPPET_CONF at a file that does not exist, so only the hostname tiers ever run; the other stubs hostname to fail on every call, so only puppet.conf does. Neither ever had both able to answer, which is the only arrangement in which precedence means anything. It decides a real outcome on the host these packages are advertised for. An agent-enrolled machine has a certname in puppet.conf and a resolvable FQDN, and they need not agree -- taking the hostname would mint under a name the estate does not know this host by. Mutation-checked: making the puppet.conf tier decline fails the new spec by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
packaging/systemd/openvox-ca.service said "charts/openvox-ca gives Init the same 300s through its startupProbe". The chart gives 30 x 2s = 60s, and charts/openvox-ca/values.yaml says so in terms: 60s "does not cover that worst case", the gap "is deliberate and settled", and the chart "is deliberately not held to the same floor". It did not drift. 8796fb4 added that sentence, and failureThreshold: 30 was already in the chart at that same commit -- so the commit that recorded the ruling in values.yaml misstated it in the unit, in the direction that hides the gap rather than the one that overstates it. Two files in the tree have contradicted each other since, and the unit was the wrong one. The clause now says what values.yaml says and points there. TimeoutStartSec and the chart's own values are untouched: the gap between them is a live question, and correcting a false comment should not pre-empt it. Reported by the lock/startup timeout investigation; verified here against the tree and against the history before taking it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
39c4f6a to
f698492
Compare
…stack is now three deep voxpupuli#323 merged and origin deleted its branch, so that entry no longer resolved. voxpupuli#344 is new. Ten entries; only Renovate is left out. voxpupuli#344 is stacked on BOTH voxpupuli#322 and voxpupuli#336 - each is an ancestor of it, giving a forced chain voxpupuli#322 -> voxpupuli#336 -> voxpupuli#344. Its PR body states no ordering constraint at all, so the body check that caught voxpupuli#266's and voxpupuli#336's would have missed this one. Ancestry caught it. Check both. voxpupuli#282 now has four collision partners: voxpupuli#325, voxpupuli#266, and both voxpupuli#336 and voxpupuli#344 on README.md and the systemd unit. It sits between the two it can reach; the stack is upstream and out of reach, which is the same best-effort adjacency already recorded rather than a new situation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shared half of the deb and rpm packages: everything both formats install, the provisioning that makes an installed CA actually serve, and the mage target that builds them.
Scope note, because this changed mid-review. #250 originally excluded the configuration file, on the grounds that it is where the two formats diverge. Chris then ruled that the 8141 default belongs here rather than being set twice downstream, and the exclusion did not survive contact with nfpm: one
config|noreplacedeclaration renders as a dpkg conffile and rpm%config(noreplace), so the divergence the exclusion assumed does not exist here. The packages therefore ship/etc/puppet-ca/config.yaml, and #251/#252 extend it rather than each creating one — which makes their scope smaller than those issues currently describe.This unblocks #266. That PR is approved and mergeable and has been held solely on this one: its packaging job calls
mage build:packagesonce (release.yml:155), and until now that target had zero occurrences onmain. Both constraints recorded in #266's body — do not merge #266 before #250, and do not push av*tag until #250 lands — are lifted by this.Closes #250
mage build:packagesSits beside
build:dist, and matches the contract #266 already committed to: it reads the variant tarballs already indist/and writes one package per format for each packaged variant into the same directory. The filenames are nfpm's conventional ones, which is what apt and dnf expect, and they carry no variant name.It does not build binaries, and that is the point of it. The ones inside
openvox-ca_VER_amd64.debare literally taken out ofopenvox-ca_VER_linux_amd64.tar.gz, so a package is never a second compilation of the same source that might differ from the artefact that was tested, checksummed and attested. A missing tarball is an error naming the target that produces it, not a silent rebuild.The FIPS variants are not packaged: nfpm runs neither
dpkg-shlibdepsnor rpm's automatic requires, so a dynamically linked package's dependencies would have to be hand-written and kept true.nfpm, not goreleaser. The FIPS binaries are cgo and dynamically linked; goreleaser would mean either dropping FIPS or reinstating cgo cross-compilation, and importing externally-built binaries needs its Pro-only
prebuiltbuilder. nfpm is what goreleaser'snfpms:block uses underneath, so what release infrastructure ingests is unchanged either way.Crypto trace for the new dependency
This branch adds two direct dependencies, both build-tooling only, and here is the trace rather than an assertion. Everything below is diffed against
mainand re-run after each rebase that touchedgo.mod.github.com/goreleaser/nfpm/v2.deband.rpminmage build:packagesgithub.com/sassoftware/go-rpmutils.rpmback so its payload, modes and%config(noreplace)flags are asserted against the artefact rather than cited from nfpm's sourceBoth are reachable only from
magefile.goandmagefile_test.go, which are behind//go:build mage. Verified for both, and for everything they dragged in:1. Neither shipped binary's package set changes at all. Not "nfpm is absent" — the whole set is byte-identical:
That is the strongest form of the claim: nfpm does not reach the product, and neither does anything it dragged in.
magefile.gois behind//go:build mage, which is why.2. The non-stdlib crypto importers are unchanged. Reverse-traced through the shipped binaries' graphs:
diffagainst the same command onmainis empty. These are the four known pre-existing exceptions outside the boring module — the Argon2id key-at-rest KDF (internal/ca/keyenc.go),x/crypto/ocspencoding, the mysql driver's edwards25519, and mellium's SASL pbkdf2. No new one is introduced, and none is removed.3. The FIPS build still is one. Built as the release builds it, in a
linux/amd64container:The boring module is linked in and doing the work; nothing from the packaging dependency is in the binary.
The dependency bump that did bite, and where
Worth recording because the obvious check missed it. Adding nfpm raised
charmbracelet/x/ansito v0.11.0 module-wide, andcharmbracelet/x/cellbuf@v0.0.13— reached vialipglossfrommfridman/tparse— calls the pre-0.11 API and stopped compiling. tparse is atooldirective ingo.modandtest:unitpipesgo test -jsoninto it, so the entire unit suite became unrunnable; the CI job sat at "Run unit tests" for two hours against a 3m49s baseline rather than failing cleanly. Fixed by raisingx/cellbufto v0.0.15 (its own commit).The generalisation for whoever adds the next dependency:
go list -deps ./cmd/...proves nothing about the build tooling. It was correct here and the product really is untouched — but the tooling shares the module graph, and atooldirective is built from it. A dependency can be absent from everything shipped and still stop the tests from running.Sharing the format list with #266
packageFormats,packageExtensions(),packagedDistVariants()and thepackagedfield ondistVariantSpecare defined here with the names and semantics #266 gives them. #266 adds the same symbols and lands immediately after this, so its rebase is a deletion of its own copies rather than a conflict to resolve — its call sites are unchanged, and the specs on both sides are what make the deletion safe.Both branches carry a spec for each property, under different titles, and this section previously cited one of each without saying so — recounted here rather than patched:
It("packages the two pure-Go variants and neither FIPS variant")packageExtensions()derives frompackageFormatsrather than restating itIt("derives the extensions from packageFormats rather than restating them")It("follows packageFormats rather than restating it")The second title in the earlier version of this section was #266's, quoted as though it were here — so a reader following it into this branch would not have found it. The coverage it pointed at is real and is in both places; only the citation was wrong. Whichever of the two survives the rebase, the property stays pinned; they differ in wording, not in what they assert.
The alternative was for the packaging code to carry a private format list, which is exactly the drift #266's own comment warns about:
verifyDistVariantsholdsrelease.ymltopackageFormats, but it reads the workflows and cannot see the producer.The unit becomes a template
ExecStartcarries a placeholder rendered to/usr/local/binfor the release tarballs — what an unmanagedinstallprefix means — and to/usr/binfor the packages, which must not write outside/usr.mage build:unit <bindir>renders it for a from-source install.The file keeps its path,
packaging/systemd/openvox-ca.service, rather than becoming an.in: #261 is editing the same file (comment-only, aroundTimeoutStartSec), and a rename would turn a probable non-conflict into a certain one.Rendering refuses a template with no placeholder. Without that check, deleting the placeholder or hard-coding a path back into
ExecStartwould render "successfully" and ship a unit naming the wrong prefix, with nothing at all to say so.Storage, hardening and the account
StateDirectory=goes; the storage default becomes/etc/puppetlabs/puppet/ssl/ca, the Clojure CA's own layout.The long-running service keeps a narrow
ReadWritePaths=/etc/puppetlabs/puppet/ssl/ca. UnderProtectSystem=stricta filesystem-backend CA with no writable path cannot write a signed certificate, a serial or a CRL — it could not issue at all. The wider parent/etc/puppetlabs/puppet/sslis granted to the oneshot alone, which needs it to linkcerts/ca.pemandcrl.peminto the tree above the CA. That is the reading of "ReadWritePaths=… on the oneshot only" that makes both halves of the sentence do work.The service account is
puppet, created by the package. OpenVox Server already createspuppet:puppetand chowns the ssl tree to it, so the tree openvox-ca writes is already expected to be owned by that account. openvox-ca must work on a host running neither openvox-agent nor openvox-server, so it creates the account itself:sysusers.dis the declaration, the postinstall callssystemd-sysusersbecause Debian wires no reliable trigger for that directory, and it falls back togroupadd/useraddwheresystemd-sysusersis absent. Every branch is idempotent, which matters on upgrade and on a host where Server got there first.Provisioning
A
Type=oneshot,RemainAfterExit=yesunit ordered before the service, running aspuppet, with every step guarded on absence — so it is idempotent and a takeover does nothing.Its
[Install]carries onlyRequiredBy=openvox-ca.serviceand noWantedBy=. Enabling it at install time therefore installs one symlink and provisions nothing: a CA is created the first time an operator runssystemctl start openvox-ca, not the first time the host reboots after an install. Installing a package is not consent to create a certificate authority. It also means provisioning that fails stops the service rather than letting it start against a half-provisioned directory.Before the service, not beside it (#275). It writes to storage directly, and the packages default to the
filesystembackend, which coordinates no writes across hosts and cannot append to its inventory atomically; two writers there leave an integrity record covering a state that never existed, after which the server refuses to start. The ordering is what makes that unreachable. #189's own per-backend table reaches the same verdict from the other direction — forfilesystemandsqliteit says stop the server. A packaged install is single-instance by definition, and nothing in the unit, the oneshot or the docs implies otherwise.The #189 dependency, and what step 3 does before it lands
Step 3 mints this host's node certificate with
openvox-ca generate, which is #189: open, rebased, and not schedulable. Nothing here vendors its code and nothing blocks on it.The choice made: probe for the subcommand, and fail with the reason named. Not a silent skip, and not an assumption that it is present.
The reasoning is that a skip is not actually the gentler option. Without a node certificate there is nothing for
tls_certto point at, andopenvox-carefuses to start on a non-loopback address without TLS. So the choice is not between "works" and "fails" — it is between a failure insystemctl status openvox-ca-first-bootthat names the missing subcommand and the issue number, and a failure two units later that says only that plain HTTP on a non-loopback address is refused. The first is the one that tells an operator what to do. The CA itself is bootstrapped and intact either way, and the message says so, along with how to supply a certificate out of band.Once #189 lands, the mint path works with no change here: the probe simply finds the subcommand.
The probe reads the command listing, and the obvious probes do not work. Both
openvox-ca generate --helpandopenvox-ca help generateexit0on a build with no such subcommand — cobra answers--helpfrom the root command before it validates arguments, and an unknown help topic is not an error to it. A probe on either would report every build as capable and turn the check into a no-op, which is worse than not checking. Verified in both directions against a real build rather than by inspection.Flags used, from #189 as documented on that branch:
--cadir,--certname,--ttl 43800h(the built-in leaf default),--key-outand--cert-out.--cert-outrather than redirecting stdout — it refuses a path that already exists and writes nothing on failure, where a shell redirection creates the file either way.The
mage build:packagesexistence guardAdded here, as #266's driver asked.
verifyMageTargetsis wired intomage dev:checkand asserts that every mage target named outside Go resolves: the ones inrequiredMageTargets(today,build:packages), and every statically resolvablemage <target>in the workflows.release.ymlnames the target as a string, so deleting or renaming it would compile cleanly, pass every test, and fail at tag time — after the tag is pushed, and whilecontainer-images.ymlandhelm-chart.ymlpublish their images anyway, including the mutablelatesttags. On #266 this check would have been red from the moment it was written and so indistinguishable from a broken guard; here it is green on arrival.It has two floors, because a guard made entirely of membership tests passes vacuously when its parse returns nothing. The magefile parse must find
build:dist; and a workflow whose text invokes mage must yield at least one invocation from its parsedrun:steps. The second is matched with the invocation pattern rather than by searching for"mage "—"image "contains that substring, and the first version of the floor fired oncontainer-images.yml, which is the other way a floor fails: it stops being believed.The invocation scan reads parsed
run:steps with comment lines stripped, not raw file bytes. The workflows carry long comments quoting the very commands the check looks for, includingmage build:packagesitself, so a byte search would be satisfied by prose describing a step instead of by the step.Two things worth a maintainer's eye
Port 8141 is in this PR, by Chris's ruling on #250 — defined once here so #251 and #252 inherit it rather than each setting it. (This paragraph previously recorded the opposite as an assumption; the ruling settled that the value lives here, and the mechanism below is mine.)
The packages ship
/etc/puppet-ca/config.yaml, andport: 8141is one of the four keys in it (cadir,tls_cert,tls_key,port— the first three are what a packaged install cannot start without at all). Three things make a configuration file the right mechanism for the port rather than merely a tidy one:applyServerEnvoverwrites the parsed file, and theChanged("port")branch overwrites that. So--port 8141inExecStart, orPUPPET_CA_PORTin a drop-in, would beat the operator's own config file and ignore an edit made in the obvious place, silently. A default has to sit at the layer an operator can override.type: "config|noreplace"is one declaration that becomes a dpkg conffile and rpm%config(noreplace). Both halves are now asserted against built packages rather than against nfpm's source: a spec reads the.deb's control archive and requiresconffilesto list/etc/puppet-ca/config.yamland nothing else, and another reads the.rpm's file flags for%config(noreplace). Neitherapt upgradenordnf updateoverwrites an operator's edit, on either format.@BINDIR@templates the path would have moved both channels — which is the trap this deliberately avoids.The file sets those four and no more, on purpose: what a packaged CA does should otherwise be the documented behaviour of the binary, not a second set of values to look up. It is
0640 root:puppetbecause it is the file that will hold credentials once a backend needs them (etcd_password, an inline OpenBaorole_id), and getting the mode right now avoids a permissions change on upgrade.Specs pin both halves, and the packaged-port one is mutation-checked: setting the file to 8140 fails it on the expected value rather than incidentally.
The documentation enumerates from
git ls-files, not from a directory walk.docs/legitimately holds untracked working files, and a walk would package them — an artefact whose contents depend on the tree it happened to be built in, complete in CI and carrying a maintainer's private drafts when built on a laptop, with nothing in the build output to distinguish the two. There is now a spec for that, premise included: it writes an untracked file underdocs/, checks git agrees it is untracked, and requires it to be absent from the staged tree while its tracked neighbour is present.The packages are byte-reproducible when
SOURCE_DATE_EPOCHis set. This came out of writing the spec for it. nfpm stamps everything it generates from that variable, so the binaries, both units, the config file and all the metadata were already reproducible — but the documentation goes in as atreeof real files, and a tree entry keeps the mtime it finds on disk, which for filesstageDocTreewrote moments earlier is "now". The doc tree was the only thing between these packages and a verifiable rebuild, sostageDocTreenow stamps what it stages withSOURCE_DATE_EPOCHwhen that is set, and leaves it alone when it is not — the variable is the caller saying this build is meant to be reproducible, and inventing a timestamp otherwise would put a wrong date on installed documentation. #266's release job should export it; noted indocs/development/releasing.md.The spec asserts both halves, because "two builds agree" is true on its own of two builds inside the same second — it passed with the variable unset — so it is paired with a build at a different epoch, which must differ.
What the review rounds produced, and one thing worth carrying forward
Six Review Council rounds plus a full-council pass on the finished branch.
Every finding accepted was real; two remedies were declined with public
reasoning and evidence —
chmod -h, which coreutils 8.32 on RHEL/Rocky 9rejects outright (and which
|| warnwould have swallowed, leaving the modeunset), and the account-provisioning branch matrix, which needs a real
/etc/passwdand belongs to #254.The last pass produced three things worth naming, all now fixed. A HIGH:
systemctl enablefor the provisioning oneshot sat behind the running-systemdguard, so a chroot, image build or mounted-root install enabled nothing — and
since the oneshot's only
[Install]directive isRequiredBy=, it then neverran. Enabling is on-disk symlink work; it is now outside that guard, with a
marker under
/var/lib/openvox-carecording the first successful enable so anupgrade cannot re-enable a unit the operator disabled, and a reinstall can. A
set of prose corrections where a comment or a paragraph described something
the code no longer did — "a takeover does nothing at all" chief among them,
which is wrong in the direction that matters. And the coverage above.
Two coverage findings on this PR turned out to matter, and they matter in
different ways — the pair is the argument, not either one alone.
Build.Unit's success path was untested. Writing that test exposed a livedefect immediately: the target printed the untrimmed bindir, reporting
ExecStart=/opt/bin//openvox-cafor a unit it had rendered correctly.resolve_certname's fallback tiers were untested. Writing those tests exposednothing at the time. What they produced was a technique — stubbing
hostnameand the CA binaries onPATH— and one round later that seam is whatmade the certname-collision HIGH reachable at all: a bug where a host named
casilently ended up serving its own leaf certificate from
certs/ca.pem, the pathevery agent reads the CA certificate from.
The second is the harder one to argue for at triage, because at the moment you
take it there is nothing to show. That is exactly why it is worth stating: the
useful claim is not "coverage findings sometimes find bugs" but the finding
that returns nothing today is often the one that makes tomorrow's finding
possible. Neither of these would have survived a triage pass that asked which
findings sounded interesting.
Not in scope
cadir,tls_cert,tls_keyandport.)verify-dist-artifactverifies the tarballs, and that remains true.openvox-ca-fipspackage name withProvides/Conflicts. Not designed in, not designed out.🤖 Generated with Claude Code