Skip to content

Package payload, first-boot provisioning and mage build:packages - #282

Open
bootc wants to merge 28 commits into
mainfrom
feature/package-payload
Open

bootc wants to merge 28 commits into
mainfrom
feature/package-payload

Conversation

@bootc

@bootc bootc commented Aug 31, 2026

Copy link
Copy Markdown
Member

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|noreplace declaration 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:packages once (release.yml:155), and until now that target had zero occurrences on main. Both constraints recorded in #266's body — do not merge #266 before #250, and do not push a v* tag until #250 lands — are lifted by this.

Closes #250

mage build:packages

Sits beside build:dist, and matches the contract #266 already committed to: it reads the variant tarballs already in dist/ 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.deb are literally taken out of openvox-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-shlibdeps nor 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 prebuilt builder. nfpm is what goreleaser's nfpms: 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 main and re-run after each rebase that touched go.mod.

Added Why Reaches a shipped binary?
github.com/goreleaser/nfpm/v2 builds the .deb and .rpm in mage build:packages no
github.com/sassoftware/go-rpmutils test only — reads the built .rpm back so its payload, modes and %config(noreplace) flags are asserted against the artefact rather than cited from nfpm's source no

Both are reachable only from magefile.go and magefile_test.go, which are behind //go:build mage. Verified for both, and for everything they dragged in:

$ go list -deps ./cmd/openvox-ca ./cmd/openvox-ca-ctl \
    | grep -Ec "nfpm|rpmpack|blakesmith/ar|go-git|go-rpmutils|cavaliergopher/cpio|DataDog/zstd|xi2/xz"
0

1. Neither shipped binary's package set changes at all. Not "nfpm is absent" — the whole set is byte-identical:

$ go list -deps ./cmd/openvox-ca | sort > head.txt      # 864 packages
$ git archive origin/main | tar -x -C main/ && (cd main && go list -deps ./cmd/openvox-ca | sort > ../main.txt)
$ diff main.txt head.txt && echo identical
identical
$ # and the same for ./cmd/openvox-ca-ctl -- 514 packages, also identical

That is the strongest form of the claim: nfpm does not reach the product, and neither does anything it dragged in. magefile.go is behind //go:build mage, which is why.

2. The non-stdlib crypto importers are unchanged. Reverse-traced through the shipped binaries' graphs:

$ go list -deps -json ./cmd/openvox-ca ./cmd/openvox-ca-ctl \
    | jq -r 'select(.Imports != null) | . as $p | .Imports[]
             | select(startswith("golang.org/x/crypto/") or startswith("filippo.io/"))
             | "\($p.ImportPath) -> \(.)"' | sort -u
filippo.io/edwards25519        -> filippo.io/edwards25519/field
github.com/go-sql-driver/mysql -> filippo.io/edwards25519
.../internal/api               -> golang.org/x/crypto/ocsp
.../internal/ca                -> golang.org/x/crypto/argon2
.../internal/ca                -> golang.org/x/crypto/ocsp
golang.org/x/crypto/argon2     -> golang.org/x/crypto/blake2b
mellium.im/sasl                -> golang.org/x/crypto/pbkdf2

diff against the same command on main is 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/ocsp encoding, 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/amd64 container:

$ GOEXPERIMENT=boringcrypto CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath ./cmd/openvox-ca
$ go version -m openvox-ca | grep build
	build	CGO_ENABLED=1
	build	GOARCH=amd64
	build	GOEXPERIMENT=boringcrypto
	build	GOOS=linux
$ go tool nm openvox-ca | grep -c _goboringcrypto_
474
$ go tool nm openvox-ca | grep -c crypto/internal/boring
488
$ go tool nm openvox-ca | grep -cE "goreleaser/nfpm|google/rpmpack|go-git/go-git"
0

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/ansi to v0.11.0 module-wide, and charmbracelet/x/cellbuf@v0.0.13 — reached via lipgloss from mfridman/tparse — calls the pre-0.11 API and stopped compiling. tparse is a tool directive in go.mod and test:unit pipes go test -json into 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 raising x/cellbuf to 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 a tool directive 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 the packaged field on distVariantSpec are 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:

Property On this branch On #266
Only the two pure-Go variants are packaged It("packages the two pure-Go variants and neither FIPS variant") same title
packageExtensions() derives from packageFormats rather than restating it It("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: verifyDistVariants holds release.yml to packageFormats, but it reads the workflows and cannot see the producer.

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.

The file keeps its path, packaging/systemd/openvox-ca.service, rather than becoming an .in: #261 is editing the same file (comment-only, around TimeoutStartSec), 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 ExecStart would 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. Under ProtectSystem=strict a 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/ssl is granted to the oneshot alone, which needs it to link certs/ca.pem and crl.pem into 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 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. 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 calls systemd-sysusers because Debian wires no reliable trigger for that directory, and it falls back to groupadd/useradd where systemd-sysusers is absent. Every branch is idempotent, which matters on upgrade and on a host where Server got there first.

Provisioning

A Type=oneshot, RemainAfterExit=yes unit 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=. Enabling it at install time therefore installs one symlink and provisions nothing: a CA is created the first time an operator runs systemctl 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 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, 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 — for filesystem and sqlite it 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_cert to point at, and openvox-ca refuses to start on a non-loopback address without TLS. So the choice is not between "works" and "fails" — it is between a failure in systemctl status openvox-ca-first-boot that 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 --help and openvox-ca help generate exit 0 on a build with no such subcommand — cobra answers --help from 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-out and --cert-out. --cert-out rather 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:packages existence guard

Added here, as #266's driver asked. verifyMageTargets is wired into mage dev:check and asserts that every mage target named outside Go resolves: the ones in requiredMageTargets (today, build:packages), and every statically resolvable mage <target> in the workflows.

release.yml names 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 while container-images.yml and helm-chart.yml publish their images anyway, including the mutable latest tags. 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 parsed run: 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 on container-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, including mage build:packages itself, 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, and port: 8141 is 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:

  • A flag in the unit or a variable in the environment would not be a default. The server resolves the port as file → environment → flag: applyServerEnv overwrites the parsed file, and the Changed("port") branch overwrites that. So --port 8141 in ExecStart, or PUPPET_CA_PORT in 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.
  • The reason the config file was out of scope does not survive contact with nfpm. 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). Both halves are now asserted against built packages rather than against nfpm's source: a spec reads the .deb's control archive and requires conffiles to list /etc/puppet-ca/config.yaml and nothing else, and another reads the .rpm's file flags for %config(noreplace). Neither apt upgrade nor dnf update overwrites an operator's edit, on either format.
  • The tarball channel is untouched. It ships no configuration file and the shared unit gains no port, so tarballs, containers and CA-only hosts stay on the binary's 8140. Templating the port the way @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:puppet because it is the file that will hold credentials once a backend needs them (etcd_password, an inline OpenBao role_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 under docs/, 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_EPOCH is 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 a tree of real files, and a tree entry keeps the mtime it finds on disk, which for files stageDocTree wrote moments earlier is "now". The doc tree was the only thing between these packages and a verifiable rebuild, so stageDocTree now stamps what it stages with SOURCE_DATE_EPOCH when 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 in docs/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 9
rejects outright (and which || warn would have swallowed, leaving the mode
unset), and the account-provisioning branch matrix, which needs a real
/etc/passwd and belongs to #254.

The last pass produced three things worth naming, all now fixed. A HIGH:
systemctl enable for the provisioning oneshot sat behind the running-systemd
guard, so a chroot, image build or mounted-root install enabled nothing — and
since the oneshot's only [Install] directive is RequiredBy=, it then never
ran. Enabling is on-disk symlink work; it is now outside that guard, with a
marker under /var/lib/openvox-ca recording the first successful enable so an
upgrade 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 live
    defect immediately: the target printed the untrimmed bindir, reporting
    ExecStart=/opt/bin//openvox-ca for a unit it had rendered correctly.
  • resolve_certname's fallback tiers were untested. Writing those tests exposed
    nothing at the time. What they produced was a technique — stubbing
    hostname and the CA binaries on PATH — and one round later that seam is what
    made the certname-collision HIGH reachable at all: a bug where a host named ca
    silently ended up serving its own leaf certificate from certs/ca.pem, the path
    every 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

🤖 Generated with Claude Code

@bootc bootc added this to the 1.0.0 milestone Aug 31, 2026
@bootc bootc added the enhancement New feature or request label Aug 31, 2026
@bootc bootc self-assigned this Aug 31, 2026
bootc added a commit that referenced this pull request Aug 31, 2026
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.
@trevor-vaughan-ai

This comment has been minimized.

@bootc

bootc commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Thanks — worked through all eleven at f74ea058f986. I checked each against the code before accepting it, and all eleven were real. Dispositions below; nothing is declined.

🟠 HIGH — mage build:packages has no test

Fixed. The point stands and it was the right thing to lead with: this is the branch's deliverable and the only code exercising nfpm end to end, and I had verified it by unpacking a .deb by hand — which leaves nothing behind for the next person.

  • checkPackagingInputs, extracted from Packages() so both emptiness branches are reachable with synthetic input. The real lists are never empty, so a test that could not supply its own inputs would be asserting nothing. Both error messages are asserted, not merely that an error occurred.
  • buildVariantPackages end to end against the real packaging/nfpm.yaml, from a staged tarball, with no compilation — the fixtures are the "binaries", which is what makes it cheap.
  • The .deb's payload is read back (a small ar reader in the test file) 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.
  • One assertion earns its place beyond coverage: the fixture tarball deliberately carries the unit rendered for /usr/local/bin, so shipping the tarball's copy instead of re-rendering for /usr/bin now fails. I mutation-tested that one — swapping renderUnit(packageUnitBindir) for renderUnit(tarballUnitBindir) fails on the ExecStart=/usr/bin/openvox-ca assertion specifically, not on something incidental.

Named separately from #266's verifyPackageSetNonEmpty on purpose: that one takes workflow bytes and does the gate-side half. Two same-named functions across two branches collide at rebase; two differently-named ones are two checks.

🟡 Certificate name reaches a path unsanitised (CWE-22)

Fixed, and the diagnosis was exactly right. I confirmed it rather than taking it on trust — driving the old script with ../../../etc/x.example.com produced:

--cert-out .../ssl/certs/../../../etc/x.example.com.pem

The severity calibration is right too, and worth restating: the sandbox bounds it (unprivileged, ProtectSystem=strict, ReadWritePaths=/etc/puppetlabs/puppet/ssl) but does not bound it to nothing, because the CA's own ca_crt.pem lives inside that subtree.

is_safe_certname is an allow-list, not 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, including the two the original skipped entirely (OPENVOX_CA_CERTNAME and puppet.conf, which bypassed is_usable_fqdn altogether). An unsafe explicit answer stops rather than falling through: it is a mistake in a hand-written file, and provisioning under a different name would hide it.

🟡 Stale docs (×4)

All four fixed: the cadir note in docs/configuration.md (the unit's default and the example are now the same path, so the contrast it drew is backwards), the two sudo -u puppet-ca recipes whose own justification is "matching the User= in the unit", and the README documentation-table row.

One deliberate non-change: the cadir: /var/lib/puppet-ca examples elsewhere in docs/storage-backends.md are left alone. They are one valid choice of directory rather than a claim about the unit's default, and docs/systemd.md now states that cadir and ReadWritePaths= must name the same place.

🟡 requiredMageTargets comment misstates release.yml

Fixed, and this was the most useful finding after the traversal — it is my own prose drifting from the code, which is harder to see than a code defect. grep -c build:packages .github/workflows/release.yml is 0 in this tree, and docs/development/releasing.md does not mention packages at all; both are #266's. The comment asserted them in the present indicative, so a maintainer checking it would conclude the guard's justification was wrong rather than early. It now says the entry is deliberately ahead of its caller, and says why the workflow scan cannot substitute for it today: the scan can only see callers that exist.

🟡 stageDocTree floor untested · 🟡 Build.Unit validation untested · 🔵 DescribeTable

All fixed. The floor is extracted as checkDocTreeFloor so it can be exercised without renaming directories in the working tree — the enumeration is the hard part to arrange, the check over it is not — and both it and the happy path are now tested. Build.Unit's rejection path is tested; the asymmetry with Build.DistVariant was not explained by cost, as the finding said. The two in-test loops are now DescribeTable/Entry per AGENTS.md:120.

🟡 first-boot has no automated test

Partly addressed, and honestly labelled. The security fix above is exercised — I drove the real script with stub binaries against a scratch OPENVOX_CA_SSLDIR/OPENVOX_CA_BINDIR and confirmed it discriminates: the old script accepts the traversal name and writes to the traversed path, the new one exits 1. That is how I know the fix works rather than merely looks right.

That check is not committed as a CI leg, and the rest of the script — account creation, the CA bootstrap, the four-way $NAME fallback, the marker-file path — still has no automated coverage. It stays #254's, as the PR body already said. The finding is right that the script was designed to be driven this way; what is missing is somewhere to run it, not the ability to.


Local state at f74ea058f986: mage dev:check 0 issues, mage test:magefile and mage test:unit green.

bootc added a commit that referenced this pull request Aug 31, 2026
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.
@bootc
bootc force-pushed the feature/package-payload branch from f74ea05 to 95a5055 Compare August 31, 2026 18:58
@bootc

bootc commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current main at 95a50555db33, now that #189 has merged.

Step 3's mint path is exercised rather than assumed. Built both binaries from this branch on top of main and ran the real first-boot against a scratch ssldir:

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

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 openvox-ca#189, which now points at a merged PR and reads as a missing feature. Every build shipping this script has the subcommand, so what the probe catches from here is a mismatched pair — an older binary left on PATH by a tarball install, or a partial upgrade — which a package cannot prevent and which produces the same unusable install. The message says that now, and names the binary it actually probed. The probe itself is unchanged: it reads the root command listing, because generate --help and help generate both exit 0 on a build without the subcommand.

One thing operators will now see that needs explaining. The mint prints openvox-ca generate's warning that the filesystem backend coordinates no writes across processes and that the server should be stopped first. On a first boot that is expected, and it is exactly why this unit is ordered Before=openvox-ca.service: at the moment it runs there is no server to stop. Unexplained it looks like provisioning reporting that it should not have run, so docs/systemd.md now says when that warning is benign and when it is not.

Rebase checked for silent reverts — git diff origin/main...HEAD shows only my own deletions, and #261's TimeoutStartSec comment on the shared unit survived intact.

mage dev:check 0 issues, test:magefile green. Remaining open question for @bootc is unchanged: whether port 8141 belongs to #251/#252's config file, which is recorded in the body as an assumption.

@bootc
bootc requested a review from trevor-vaughan August 31, 2026 19:24
bootc added a commit to bootc/openvox-ca that referenced this pull request Aug 31, 2026
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.
@trevor-vaughan-ai

This comment has been minimized.

@bootc
bootc force-pushed the feature/package-payload branch from 79829cf to 00561e5 Compare August 31, 2026 23:06
@bootc

bootc commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Worked through all 22 at 00561e5eaf97 (rebased onto current main). Each was checked against the code before being accepted. All 22 were real. 21 are fixed; one is deferred with a reason.

🔴 CRITICAL — packaged install cannot start

Fixed, and the finding understated it. Confirmed first: there is no built-in default for CADir (it is absent from the defaults block in config.go), and both runtime.go:83 and main.go:358 refuse without one.

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 cadir exposed a second instance of the same defect, which the review did not find. With cadir set the server got one step further and exited again — it binds 0.0.0.0 and refuses plain HTTP on a non-loopback address, so tls_cert/tls_key were missing for exactly the same reason. Fixing only what was reported would have left the package still unbootable and this comment claiming otherwise.

The credential provisioning mints is named for the host, which no shipped file can know. So the config names two stable paths (certs/openvox-ca-server.pem and the matching key) and the oneshot links them to this host's credential — which also makes re-minting under a corrected name a relink rather than a config edit.

Verified by running it, not by reading it. Provision into a scratch tree, start with the shipped config 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 config names are ones the script actually links — if those drift, the service starts and exits.

🟠 HIGH (5) — all fixed

  • Config owned by an account that does not exist at unpack time. Correct, and my existing postinstall chown did not cover it — it handled the ssl tree only. It now sets the config's owner too, after sysusers has created puppet.
  • Removal behaviour undocumented. Added, including the consequence that matters more than the reassurance: removing the package does not decommission the CA. The key stays readable by anything running as puppet until someone deletes it deliberately.
  • Unguarded chmod breaks guarded-on-absence. Right on both counts — it would change a mode this package did not set, or fail under set -e and abort provisioning that had nothing to do. Directories are now created with their final mode and existing ones are left alone.
  • renderUnit's guard had no failure test. Split as renderUnitFrom so the branch is reachable without editing the real unit.
  • Certname allow-list had no coverage. Fixed, and it runs the shipped shell function rather than a Go restatement — a table over is_safe_certname and is_localhost_name driven through sh. Mutation-checked: weakening the allow-list to accept everything fails every rejection entry.

🟡 MEDIUM (11) — 10 fixed, 1 deferred

Fixed: the puppet uid trust-boundary rationale (see below); chown now --no-dereference on both paths; step 3's two hard-failure paths documented; the nfpm header that still said the config file was not in this package; docTreeEntries consumed by fixed index — a fourth path really would have been silently unpackaged; verifyMageTargets now globs the workflow directory with a floor instead of a hand-maintained list of five; the unresolved-name marker now written only when this run created the CA; postinstall gained a maintainer-script argument guard; Build.Unit's success path; extractTarGz's traversal refusal.

On the shared puppet uid: the finding was right that the consequence was unstated, and right that it is a documentation matter rather than a design one — Chris ruled on the account. The docs now say plainly that on a host also running OpenVox Server the CA's private key sits inside Server's trust boundary, what reduces that (key encryption at rest; better, not co-locating), and why a private puppet-ca account was rejected.

Deferred: the account-provisioning branch matrix has no test. It needs a container to exercise systemd-sysusers against a real /etc/passwd, which is #254's leg. I would rather say that than add a test that stubs systemd-sysusers and asserts my own stub was called.

🔵 LOW (5) — all fixed

private_keys is created 0750 rather than narrowed afterwards; the README no longer calls the template the shipped unit; the OPENVOX_CA_SSLDIR/BINDIR seam is now genuinely exercised by the specs above, so its comment is true; the duplicated localhost list is one function — the copies had already drifted, so *.localdomain was rejected as an FQDN and accepted as a short hostname; preremove stops the oneshot as well as disabling it.

One defect found while fixing, not in the review

Writing Build.Unit's success-path test exposed what 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. Cosmetic, but it is the kind of thing a missing test is for.


mage dev:check 0 issues, test:magefile and test:unit green. Rebased onto main and checked for silent reverts — only my own deletions.

@trevor-vaughan-ai

This comment has been minimized.

@bootc
bootc force-pushed the feature/package-payload branch from 00561e5 to daf7a9b Compare September 2, 2026 06:30
@bootc

bootc commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Worked through all 11 at daf7a9b3f65c (rebased onto current main). Each checked against the code first. All 11 were real; all 11 are fixed. No declines this round.

🟠 HIGH (3)

RPM payload never inspected. Correct, and the asymmetry was worse than "one format untested": the PR body verified nfpm's config|noreplace rpm mapping by citing nfpm's source while verifying the deb half empirically. 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, plus RPMFILE_CONFIG/RPMFILE_NOREPLACE on the config file and their absence everywhere else.

Node-certificate path untested. Fixed: has_generate_subcommand in both directions, ensure_node_certificate adopting a pair, refusing half a credential either way round, and stopping when the build cannot mint. The probe reads the root command listing precisely because generate --help exits 0 on a build without the subcommand — the subtlety that was unprotected is now the thing two specs pin.

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 systemd-sysusers creates the account it is handed needs a real /etc/passwd, which is #254. A test that stubbed systemd-sysusers and then asserted the stub had been called would be asserting its own fixture.

🟡 MEDIUM (5) — all fixed

resolve_certname's precedence now has end-to-end coverage, including that an unsafe explicit answer stops rather than falling through. Build.Unit's success path goes through writeRenderedUnit against a temp dir instead of the repository's dist/.

checkDocTreeFloor is the "guard that cannot fail" class, and it was exactly that. It 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 packaged the two files beside an empty tree. Verified before accepting: the floor's own error message named docs while checking only stageDocTreeFloor.

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

preremove accepted an argument dpkg cannot send. Confirmed against dpkg's actual argument set rather than taking it on trust: purge goes to postrm, never prerm. The branch was unreachable and read as though purge were handled here, when the file that handles it is postremove. Accepted set is now exactly dpkg's remove and rpm's remaining-count 0, with a spec pinning it.

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 saying why. They now warn, naming the consequence — and remain non-fatal, because 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.


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.

go.mod gains go-rpmutils (test-only) plus two indirects already in the graph via nfpm. Re-verified rather than assumed, because the PR body makes a claim about it: go list -deps ./cmd/openvox-ca is still byte-identical to main at 864 packages.

mage dev:check 0 issues; test:magefile and test:unit green.

@trevor-vaughan-ai

This comment has been minimized.

@bootc

bootc commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Worked through all of these at 3c072b89817f. Each checked against the code first. All were real; all are fixed. One remedy is deliberately not the one recommended — reasoning below, with evidence.

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 magefile.go:1487, both from Tester, and describe the same gap. Answering it once rather than inventing a second response: so eight distinct findings, not nine.

🟠 HIGH — resolve_certname tiers 2–4 and write_unresolved_marker unexercised

Fixed, and the finding's read of why was the useful part — a spec had no way to control what hostname returns, so tiers 2–4 were unreachable rather than overlooked. A hostname stub on PATH (the technique the maintainer-script specs already use) unlocks all of them: the dotted answer, the short-hostname warning, a localhost form correctly refused as an FQDN, and the localhost fallback.

write_unresolved_marker now has coverage too, and it matters more than the tiers: it is the only thing that says a CA is unreachable while systemctl status says it is healthy. Its assertions include that the marker states what re-minting costs, since its recovery steps destroy a CA.

Covering it exposed something the finding did not mention: the function sat below the -- Run -- banner, so it was part of the executable section rather than the definitions, and no spec could have reached it however it was written. Moved up with the other step functions.

🟡 MEDIUM — the chmod symlink hole: diagnosis accepted, remedy declined

The diagnosis is right and is fixed. Plain chmod follows a symlink, so postinstall was unguarded exactly where the adjacent chown --no-dereference was guarded.

chmod -h is not the fix, and applying it would have been worse than shipping the hole. Verified in a container rather than reasoned about:

$ docker run --rm --platform linux/amd64 rockylinux:9 chmod --version | head -1
chmod (GNU coreutils) 8.32
$ ... chmod -h 0640 /tmp/link
chmod: invalid option -- 'h'          exit 1

RHEL and Rocky 9 are primary targets here, and Debian bookworm's coreutils 9.1 rejects it too. The call site is chmod … || warn, so on those platforms the rejection is swallowed into a warning and 0640 is never applied at all — trading a symlink hole for not setting the mode on the most common targets.

Applied instead: the [ -L "$CONFIG" ] check the finding itself proposed and then talked itself out of ("rather than the more involved [ -L … ] check originally proposed"). It refuses when the path is a symlink, says why, and tells the operator what to set. POSIX, portable to every target, independent of coreutils vintage. -L is tested before -f because -f follows the link.

🟡 MEDIUM — the preremove table cannot tell its two cases apart

Fixed, and this is the one I would have most wanted caught. shouldAct was declared and never read; the body asserted only that the script exited 0, which a stubbed systemctl and a script wrapping every call in || : make true whichever way it behaves. Six entries, three of each polarity, none distinguishable — a table named "acts only on a real removal" that would have passed had preremove done nothing at all, or stopped the service on every upgrade.

It now reads shouldAct and asserts on the call log. Making the positive half assertable required the systemd runtime path to be overridable: the scripts guard their systemctl calls on /run/systemd/system, so on a host without systemd they correctly do nothing and that half could not 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 trusted twice: making preremove accept upgrade and 1 fails exactly the two upgrade entries and nothing else.

🟡 MEDIUM — remaining

Build.Packages itself now has a spec for the promise that matters: that it refuses when the tarballs it consumes are absent, rather than silently rebuilding. AGENTS.md's command table and the Build namespace's inline listing both gained build:packages and build:unit.

The PR body contradiction is fixed in both places, which is the part worth noting: correcting only the "Not in scope" bullet would have left the same false claim standing in the opening sentence. The body now carries a scope note explaining that the exclusion did not survive Chris's ruling or contact with nfpm — one config|noreplace declaration renders as a dpkg conffile and rpm %config(noreplace), so the divergence the exclusion assumed does not exist here.

🔵 LOW

docs/systemd.md step 4 described the symlinks backwards: they live in the ssl tree and point into the CA directory, not the reverse.


Verified after the changes: provisioning still runs end to end against real binaries — bootstrap, mint, all four links including the serving credential, no marker when the name resolves, and an idempotent re-run that adopts. mage dev:check 0 issues; test:magefile green.

The Dependabot alert on this push (google.golang.org/grpc) is pre-existing and not from this branch: v1.82.1 on main and v1.82.1 here.

bootc added a commit to bootc/openvox-ca that referenced this pull request Sep 2, 2026
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.
bootc added a commit to bootc/openvox-ca that referenced this pull request Sep 2, 2026
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.
bootc added a commit to bootc/openvox-ca that referenced this pull request Sep 2, 2026
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.
bootc added a commit to bootc/openvox-ca that referenced this pull request Sep 2, 2026
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.
@trevor-vaughan-ai

This comment has been minimized.

@bootc
bootc force-pushed the feature/package-payload branch from 3c072b8 to 7a2be5a Compare September 4, 2026 15:58
bootc and others added 28 commits September 14, 2026 11:57
…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>
@bootc
bootc force-pushed the feature/package-payload branch from 39c4f6a to f698492 Compare September 14, 2026 11:02
bootc added a commit to bootc/openvox-ca that referenced this pull request Sep 14, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Package payload, first-boot provisioning and mage build:packages

2 participants