Repository navigation
Releases: eth-man/nexuspuppet
Release list
v1.13.0 — an LDAP form with server, port, LDAPS/STARTTLS and bind type
LDAP connection fields an operator recognises (ADR-0030). An operator configuring LDAPS against a real Active Directory asked for the form every other LDAP client has — server name, port, bind type, User DN, password, protocol — instead of typing ldaps://url:port and choosing a directory type. That is the form now, STARTTLS is new, and the directory type is detected. No migration.
Added
STARTTLS. Choose Protocol: STARTTLS (port 389) or set LDAP_STARTTLS=true with an ldap:// LDAP_URL. Every connection is upgraded before its first bind — service account, anonymous search or user — with the same CA, verification and SNI as LDAPS. It fails closed: a refused or failed upgrade ends the operation with nothing sent, and Test connection says The server refused STARTTLS … No credentials were sent. The client also refuses to reconnect a dropped STARTTLS session, because the library's transparent reconnect is plain TCP.
Bind types: Regular, Simple, Anonymous. Regular (default) is a service account — User DN and Password. Anonymous searches without one. Simple binds each person directly through a User DN pattern built from the email they sign in with — {email} is the whole address, {username} the part before the @: {email} for AD (the address is the UPN), uid={username},ou=people,… for OpenLDAP — then reads their own entry, as them, for email, name and groups (nested groups via the AD matching rule when enabled). Values are escaped for DN context (RFC 4514) so no address can change which DN is bound; for a UPN, anything that could name another account is refused. The session is always the account signed in with, and with {username} an entry's mail must match the address, so a same-named account in another domain cannot be opened with someone else's password. Environment: LDAP_BIND_TYPE, LDAP_USER_DN_PATTERN.
A directory that refuses anonymous searches is named as such. Test connection with Anonymous against AD used to show 000004DC: LdapErr: DSID-0C090CA2 …; it now says This directory does not allow anonymous searches. Use Regular with a service account (User DN and Password)., with the server's text kept beneath. The same for OpenLDAP's authentication required, and its hidden-subtree noSuchObject is reported as "not found, or hidden from anonymous readers".
The directory type is detected. Test connection and Save read the server's RootDSE; 1.2.840.113556.1.4.800 in supportedCapabilities means Active Directory, anything else readable means OpenLDAP. It is stored with the configuration and shown read-only (Detected: Active Directory). A RootDSE hidden from anonymous readers is retried as the service account; still unknown is treated as OpenLDAP and the Test result says so. A directory unreachable at Save keeps what was detected before for the same server. LDAP_DIALECT still decides it for the environment.
Changed
The LDAP form: Server name or IP, Port (follows the protocol — 636/389 — until you type one), Protocol (LDAPS | STARTTLS, no unencrypted choice), Bind type, then User DN + Password (Regular) or User DN pattern (Simple). The Server URL field and the Directory type select are gone. Test results name the server, protocol, bind type and detected directory type, and explain certificate, name, port and protocol mistakes in those terms.
PUT/POST …/settings/auth/ldap(/test) take host, port, protocol, bindType, userDnPattern. A body with the old url is still accepted for ldaps://. detectedDialect in a body is ignored. Regular bind with no password typed and none stored is refused (BIND_PASSWORD_REQUIRED) instead of being saved as a configuration that would search anonymously. Switching to Simple or Anonymous discards the stored service-account password.
An empty password is refused in the client as well as the provider, for every bind type, without opening a connection.
Compatibility
Rows saved by 1.11/1.12 (url, dialect, bindDn) are read as they are and open in the new fields: ldaps://h:p as LDAPS, the chosen dialect as the detected one, a bind DN with a stored password as Regular (without one, as the anonymous search it always was). A row with unencrypted ldap:// keeps signing people in exactly as before and is shown as Unencrypted (legacy); saving any change requires choosing LDAPS or STARTTLS. LDAP_URL is read unchanged.
Fixed
The login field said "Username" for Active Directory, but only an email works. NexusPuppet finds the account by the address typed before it asks the directory, so jdoe was refused exactly like a wrong password. The field is now labelled Email for every directory; AD users type their UPN, as they already had to.
Upgrading
Re-run sudo ./scripts/deploy.sh. Nothing to reconfigure.
Full changelog: v1.12.0...v1.13.0
v1.12.0 — reset a lost admin password, and a documentation site
A lost admin password is recoverable from the server, and the documentation is a short website. sudo ./scripts/deploy.sh --reset-admin <email> sets a new password for a local account, unlocks it and records it in the audit log. The guide is now five plain pages at https://eth-man.github.io/nexuspuppet/. No migration.
Added
A documentation site at https://eth-man.github.io/nexuspuppet/: Home, Install & upgrade, Using the console, Sign-in & users, Troubleshooting & support — written for operators, built from docs/guide/ with MkDocs Material and published by GitHub Actions on every merge. The reference documents (DEPLOYMENT.md, the user guide, the ADRs) stay in the repository and are brought up to date with 1.11, with every screenshot regenerated. (#273)
scripts/deploy.sh --reset-admin <email> recovers a lost local password. An operator lost the only administrator's password, and nothing supported could get them back in: BOOTSTRAP_ADMIN_* only seeds an empty users table, and the console's reset needs an administrator who can sign in. The command prompts twice without echo (or reads one line from a non-terminal stdin), applies the console's password rule, and in one transaction sets the password, clears the lockout, reactivates the account, revokes its refresh tokens and writes a user.password.reset audit record with actor cli:deploy.sh@<host> — forwarded like any other record when audit forwarding is configured. It starts only the database and runs in a one-off container, so it works with the API stopped or crash-looping. The password reaches the container on stdin only, never argv, the environment or a file. Directory accounts are refused (exit 3), an unknown email exits 2. Access tokens already issued stay valid until ACCESS_TOKEN_TTL. See DEPLOYMENT.md §5, Lost the admin password.
Fixed
--reset-admin on an image built before it existed said nothing useful. The command lives in the API image; a checkout updated without re-running the upgrade asked for the password and then failed with a MODULE_NOT_FOUND stack trace. It now checks the image first and, before any prompt, says to run the upgrade once. (#275)
scripts/deploy.sh --help shows every option. It printed a fixed range of lines that stopped mid-way through --check and never reached --tls or --skip-preflight.
Upgrading
Re-run sudo ./scripts/deploy.sh. --reset-admin ships inside the rebuilt API image, so it exists only after that upgrade, not merely after updating the checkout.
Full changelog: v1.11.0...v1.12.0
v1.11.0 — enable LDAP/AD and SSO from the console
A directory is enabled from the console (ADR-0029). An install upgraded from core showed LDAP and SSO as Not enabled — set LDAP_URL and restart the API. Both directories are now configurable and enable-able entirely from Settings → Directory / Auth, effective at the next sign-in, with no .env edit and no restart. Local accounts are never affected. No migration.
Added
Both directory providers are always registered. Each resolves its configuration per sign-in: a row saved in the console, else the LDAP_* / OIDC_* environment, else none — dormant. A dormant directory is not offered on the login page, and its accounts are refused exactly like a wrong password, inside the login timing floor, with one log line saying LDAP sign-in is not configured. Accounts can be created for a dormant directory in advance; the create-user dialog labels it not configured yet and never defaults to it.
The LDAP CA certificate can be pasted as PEM (caPem) in the console, so ldaps:// against an internal CA needs no mounted file. It is public, so it is stored in clear and shown back with each certificate's subject, issuer and expiry. A private key, anything that is not X.509, and a CA beside disabled verification are refused. LDAP_CA_PATH is unchanged, and the environment still never takes inline PEM.
scripts/deploy.sh generates CONFIG_ENCRYPTION_KEY when .env has none, on first install and on upgrade, by appending one commented line — the one deliberate exception to "an existing .env is never touched". Without it the console could not store a bind password or client secret. An existing or empty line is never edited.
GET /users/auth-sources (users:manage) lists every registered sign-in source with whether it is configured.
Changed
GET /auth/mode lists configured sources only. On a deployment with no directory it answers exactly as before: local.
Test before save works on a fresh deployment. The OIDC check now tests the candidate configuration, with its own clients; it used to check the boot configuration, which a fresh deployment does not have.
Saving directory settings writes the change and its audit record in one transaction, as discarding already did.
Without CONFIG_ENCRYPTION_KEY, the directory settings say so before anybody types a password: Saving a bind password needs CONFIG_ENCRYPTION_KEY. Re-run scripts/deploy.sh, which generates it, or set it in .env and restart. The API refuses the save with the same words.
Removed
The Not enabled card, the "restart required" notices, and verifyLdap's "Set LDAP_URL and restart once" answer.
Upgrading
Re-run scripts/deploy.sh. On an install with no CONFIG_ENCRYPTION_KEY it appends one to .env and prints a line saying so; back .env up afterwards. A present but malformed LDAP_*, OIDC_* or AUDIT_EXPORT_* still stops the API at boot, as in 1.10.
Full changelog: v1.10.1...v1.11.0
v1.10.1 — the support bundle has everything the API printed
The support bundle now has everything the API printed. A bundle downloaded from staging was compared line by line with docker logs of the same container: 122 of 124 lines matched. No migration.
Fixed
Node's own warnings and the stack trace of a crash were missing from the support bundle. Node writes those straight to stderr, past the logger that feeds the API's local log copy, so the bundle never saw them. The crash trace is the thing support needs most. They are now copied into the log file as context: "Process" lines, warn for a runtime warning and fatal for an uncaught exception or unhandled rejection, with the stack and its origin. Node's behaviour is unchanged: it still prints them, and a crash still exits non-zero. A failed bootstrap, once the log file is open, is recorded there too, so a container restarting in a loop leaves its reason in the bundle. (#269)
A downloaded support bundle could be committed by accident. .gitignore now covers nexuspuppet-support-*.tar.gz and nexuspuppet-host-support-*.tar.gz. (#269)
Full changelog: v1.10.0...v1.10.1
v1.10.0 — one product, and a support bundle
One product, and one file to send to support. There is no enterprise layer and no edition any more: LDAP/AD, OIDC, custom roles and audit forwarding are part of the API, always present, and inert until configured (ADR-0027, superseding ADR-0002). A new support bundle collects the console's logs, status and non-secret configuration into one archive, with an opt-in for personal data (ADR-0028). No migration.
Added
Support bundle (ADR-0028). Settings → General → Support bundle downloads one .tar.gz for the last 1, 6, 24 or 72 hours: the API's own logs from every replica, system status, every operational condition with how long it has been open, propagation, non-secret configuration, queue and migration summaries, and audit actions without actors. summary.txt leads with what needs attention — a condition open for six weeks is the first line, not line 90,000. Secrets, identities, client addresses and audit payloads are excluded, and every file is scanned for secret values, private keys, URL credentials, tokens and email addresses before archiving. Needs settings:manage; each export is audited. Also GET /system/support-bundle?hours=N.
Opt-in: configuration and personal data. A per-download tick (&includePersonalData=true) adds the audit trail with actors, addresses and before/after values, the user list with session counts, the full classification including parameter values, and saved queries. The file is named …-with-personal-data.tar.gz, the manifest says so first, and the export's audit row records the choice. Environment secrets, stored credentials, password hashes, tokens and private keys stay out either way.
scripts/support-bundle.sh collects what the console cannot see — container logs, Docker state, host facts, the nexuspuppet-* timers and the journal — on the console VM or a Puppet server, and embeds the console's archive with --include so support gets one file. It never runs docker compose config or a full docker inspect, filters .env through an allow-list, and masks the values of its secrets everywhere.
The API keeps a bounded copy of its own log in the new api-logs volume (/var/log/nexuspuppet): JSON lines, rotated at LOG_FILE_MAX_BYTES (20 MiB) keeping LOG_FILE_KEEP (5) — at most 120 MiB per replica. Stdout is unchanged and remains the log; ADR-0016 is amended to say so. LOG_FILE_MAX_BYTES=0 switches the copy off.
Changed
packages/enterprise moved into apps/api. The directory providers are now apps/api/src/directory/{ldap,oidc}, and forwarding is apps/api/src/audit-forwarding. They are wired in app.module.ts like everything else, and their tests moved with them. ldapts is a dependency of @nexuspuppet/api. Removed with it: the runtime loader, the capability registry, CONTRACTS_VERSION, the descriptor types, EnterpriseLoadError, the CAPABILITIES constant, the ESLint enterprise boundary and the core no-op audit transport. The interfaces and DI tokens in @nexuspuppet/contracts stay, because they keep these testable.
Role editing and audit forwarding are always available. The rbac.custom and audit.export checks are gone. POST/PATCH/DELETE /roles and the /settings/audit/* writes never answer 501. The console's roles table is always editable for settings:manage, and the Syslog and Webhook cards are always real forms.
Directory settings cards key on configuration, not capabilities. Without LDAP_URL or OIDC_ISSUER, the card reads Not enabled and names the variable that enables it. It no longer shows a padlock reading "Enterprise". The deployment card no longer has an Edition row.
Malformed directory or audit-export configuration still stops the API from booting. The message now names the integration: LDAP is configured but its settings are invalid, so the API will not start rather than run without it. …
CI's load-bearing job is renamed from "Core builds without the enterprise layer" to "Build, typecheck, lint, unit tests". It no longer greps for enterprise imports, and it keeps the committed-certificate and private-key checks.
Fixed
After an API restart during a PuppetDB outage, the console said PuppetDB "is not answering and never has". The last successful contact was held only in memory, so a restart erased it. The condition summary, the system status card and the inventory screens' unreachable state now fall back to the newest ManagedNode.projectedAt — a contact that really happened — and say "last successful query …". Production showed "never has" for three weeks of a six-week outage. (#263)
An install that followed DEPLOYMENT.md built an image without LDAP, OIDC, custom roles or audit forwarding. The API image had an EDITION build argument defaulting to core, which left packages/enterprise out, and scripts/deploy.sh never set it. Since 1.9.0 made every feature part of the open-source product, that default meant the documented install path produced the smaller image, and nothing said so — role editing and audit forwarding answered 501 and the directory panels showed as unavailable.
NOTICE said the directory and audit integrations were under a commercial licence and not in this repository. Both stopped being true in 1.9.0.
Removed
The EDITION build argument. Every image now contains the whole product and is installed with npm ci against the committed lockfile — the separate npm install stage and the ldapts reinstall it needed on every rebuild are gone. ldapts loses a stale optional-peer entry and is an ordinary dependency only.
npm run test:e2e:core, npm run test:e2e:enterprise and scripts/dev/e2e-edition.sh, which ran the browser suite with the layer hidden. There is no smaller product left to test.
The NEXUSPUPPET_ENTERPRISE_REPO / _REF block in .env.example, which described a fetch script deleted in 1.9.0.
GET /capabilities. It was a public endpoint that reported an edition, an enterpriseVersion and a list of licensed capabilities, and after ADR-0027 it could only have returned a constant. Use GET /auth/mode for the sign-in sources a deployment offers.
SystemStatus.auditForwarding.available, from GET /system/status. Forwarding is always available. The remaining fields say whether it is on and whether it can send.
Upgrading
EDITION is gone; remove it from .env at your leisure. A leftover line is ignored.
If your .env set EDITION=enterprise, nothing changes. You were already building this image.
If it did not — the default — your next build gains LDAP/AD, OIDC, custom roles and audit forwarding. None of them does anything until it is configured:
- the LDAP provider is registered only when
LDAP_URLis set, and OIDC only whenOIDC_ISSUERis; - audit forwarding queues and sends nothing until a transport is configured, by
AUDIT_EXPORT_URLor from Settings → Integrations; - custom roles become creatable by holders of
settings:manage; the three built-in roles are unchanged and nobody's access moves until someone defines a role.
Check .env before rebuilding a former core image. LDAP_*, OIDC_* and AUDIT_EXPORT_* were ignored by an image without the layer; they now take effect, and a malformed value stops the API from booting — deliberately, so a deployment that believes it has a directory never silently runs without one:
grep -E '^(LDAP_|OIDC_|AUDIT_EXPORT_)' .envAnything that calls GET /capabilities now gets 404. Monitoring or scripts that probed it to learn the "edition" should stop. There is nothing to learn. A health check belongs on GET /healthz.
Anything that expected 501 from role or forwarding writes now gets the real answer.
Consumers of GET /system/status lose auditForwarding.available. It was only ever false on a core image.
docker compose up -d creates the api-logs volume, already owned by the API's uid. Nothing to configure. If you replace it with a bind mount, the host directory must be writable by uid 100 (DEPLOYMENT.md, "Support bundles").
Full changelog: v1.9.0...v1.10.0
v1.9.0 — fully open source
NexusPuppet is now fully open source. Directory authentication (LDAP and Active Directory), single sign-on (OIDC), custom roles and audit forwarding move from a private repository into this one, under Apache-2.0. There is no paid tier, no licence key, and no feature held back.
No migration.
Changed
The enterprise layer ships here. packages/enterprise is part of this repository. It carried its own "Proprietary and Confidential" LICENSE and declared "license": "UNLICENSED"; both are gone, and the root Apache-2.0 licence governs the whole tree.
Licensing is removed, not disabled. LICENSE_SERVICE, ILicenseService and LicenseStatus are deleted from @nexuspuppet/contracts, and the enterprise layer no longer filters what it registers by entitlement. An unused entitlement checker is exactly the "documented field that nothing populates" that ADR-0014 was written to end.
ADR-0014 is Rejected, never ratified. What it got right is worth keeping if licensing ever returns: offline verification with no phone-home, and degradation that can never touch classification or the ENC. What it got wrong was timing — the product had no paying users, so the gate cost adoption and protected nothing.
ADR-0002 is amended, not superseded. Core still does not import the enterprise package. It depends on interfaces in @nexuspuppet/contracts, and the layer registers implementations at runtime; ESLint still forbids a direct import. That seam is now an internal boundary keeping the auth and audit integrations independently testable, rather than a commercial one.
One npm install builds everything. packages/enterprise is a real workspace member: ldapts moves from an optional peer dependency to an ordinary one, and its private lockfile is gone.
Removed
npm run enterprise:fetch, scripts/enterprise.mjs, NEXUSPUPPET_ENTERPRISE_REPO and NEXUSPUPPET_ENTERPRISE_REF. They could not survive the code being in-repo: with packages/enterprise tracked, the script's existing-checkout branch would run git -C packages/enterprise fetch, and with no nested .git that walks up and operates on the NexusPuppet repository itself.
Upgrading
Core deployments: nothing changes. EDITION=core still builds an image without packages/enterprise, and CI still proves core builds without it.
If your pipeline calls enterprise:fetch, remove that step. Node on the deployment host is no longer required for the enterprise edition.
To switch a deployment from core to enterprise — no licence, no repository access:
git fetch --tags && git checkout v1.9.0
sed -i 's/^EDITION=core/EDITION=enterprise/' .env
docker compose build api
docker compose up -dSwitching edition and configuring a directory are separate changes. Flip the edition, confirm the API boots and local login still works, then point it at a directory. Local accounts are never displaced: AuthProviderResolver dispatches on each account's authSource and the local provider is never overridden (ADR-0015 §3).
Verified on staging before tagging: enterprise layer loads from the in-repo package, login, node inventory, node groups, resource search, LDAP settings and the console all healthy; no errors at boot.
v1.8.0 — what your nodes actually run
The console can say what a node does get, not only what it should.
Classification has always described intent thoroughly — every class on a node, which group set it, why that group matched. The catalog is the result, and nothing read it. So "is /etc/ssh/sshd_config the same on all 190 servers?" had no answer here.
That is not a lookup. It is a consistency question, and configuration management exists to make an estate uniform.
Added
Estate-wide resource search
A new Resources page, peer to Nodes. Results group by resource and lead with variance:
⚠ File[/etc/ssh/sshd_config] production 12 nodes 2 variants
✓ File[/etc/motd] production 12 nodes 1 variant
Disagreements sort to the top. A flat list of 190 identical rows hides the three that differ, which is the opposite of what an operator opened the screen to find.
Variance without fetching a single parameter
PuppetDB's resource field is a SHA-1 over type, title and parameters, so identical hashes mean byte-identical parameters. The list query uses extract to omit parameters outright — a value never fetched cannot leak through a rendering bug, a log line, an error page or a screenshot.
Counted within an environment, never across it
A development node and a production node legitimately differ. Counting that as drift would flag a two-environment estate as entirely inconsistent on day one, and an alert that cries wolf gets muted — taking the one that mattered with it.
Expand to see who, compare to see what
Expanding a row names which nodes carry which configuration. Comparing parameters fetches one representative per variant and diffs them:
− app18.nexuspuppet.test + cache35.nexuspuppet.test
# managed by puppet
− PermitRootLogin no
+ PermitRootLogin yes
Multi-line values get a line diff with unchanged bulk collapsed, so a 200-line config file shows the stanza that changed rather than two columns to compare by eye.
Composes with fact filtering
"File[/etc/resolv.conf] on Ubuntu 22.04 nodes" is one query, reusing the inventory subquery from v1.7.7. Filtering by parameter value is supported too — "every node where sshd_config permits root login" — which needs no parameter to be displayed.
resources:read, a new and privileged permission
Deliberately not inventory:read. Facts describe a machine; resource parameters are its configuration payload — a File's content is the whole file body, and a class parameter may hold a credential.
Read-only audit events
Expanding parameters, and any query filtering on a parameter value, write an AuditLog row. Ordinary browsing does not — burying the events that matter under thousands that do not is how a trail stops being read.
The row is written before the read, so a crash cannot lose the evidence while keeping the disclosure. And it records the question, never the answer: no parameter value is ever written into the audit log, because that would copy the estate's secrets into a table that gets forwarded off-box.
Upgrading
One migration, admin_resources_read. No configuration changes.
Every existing ADMIN gains the ability to read managed file contents. The migration grants resources:read to the built-in ADMIN role. It is granted because otherwise nobody could hold it: creating a custom role answers 501 without the enterprise layer, so leaving it unheld would make the feature unreachable in every core deployment rather than merely restricted. VIEWER and OPERATOR are unaffected.
A deployment that needs "an admin who manages users but must not read file contents" needs the enterprise layer to express it, by granting the permission to a custom role and narrowing ADMIN.
AuditLog consumers must tolerate null before and after. Read events have neither. This is visible on the wire, including to syslog and webhook forwarding.
A note on how this was built
Every slice shipped with a defect that only a real environment found, and CI was green on all of them: a lowercase resource type answering 500 instead of 400; built-in role defaults living in three places rather than two; a screen that could report drift but never name the machine; and a parameter comparison that pushed one node's column off-screen entirely.
The last was found by copying the table and pasting it, revealing a value that had never been on screen. The API, the audit rows, the diff logic and the built bundle had all passed.
Full changelog: v1.7.7...v1.8.0
v1.7.7
Fact filtering works. v1.7.6 shipped it doing nothing at all.
Fixed
The facts parameter reaches the query (#246). It was dropped in four places between the browser and the PQL. Unknown query parameters are ignored, so the request succeeded and returned the unfiltered estate — the filter appeared to work, and filtered nothing. If you tried "which nodes are Ubuntu 22.04" on v1.7.6 and got your whole estate back, this is why.
The value field offers the values it is sent. /fact-paths already returned a list of observed values per fact; the input only ever read the fact name. Knowing the fact is called os.name does not tell you whether this estate spells it Ubuntu or ubuntu — and a misspelt value renders identically to an empty estate. High-cardinality facts like networking.ip stay free text, deliberately.
The dev PuppetDB stand-in no longer hides this class of bug. Its query evaluator ended in default: return true, so any operator it did not implement matched every row — and fact filters compile to a subquery it had never implemented. Staging reported 48 of 48 nodes matching os.name = Nonsense, which reads as a broken feature and was a broken test fixture. An unknown operator is now a 400, the way real PuppetDB rejects one. Affects local development and CI only.
Verified
Checked on staging before tagging, with negative controls:
os.name is Ubuntu 10
os.name is Nonsense (MUST be 0) 0
nosuchfact is reported (MUST be 0) 0
Ubuntu AND major 24.04 10
Ubuntu AND major 9 (MUST be 0) 0
malformed facts JSON http 400
Upgrading
No migrations. No configuration changes.
Full changelog: v1.7.6...v1.7.7
v1.7.6 — filter nodes by fact
Which nodes are Ubuntu 22.04
That question had no answer anywhere. The node list filtered on certname, environment, status and staleness only, and a classification group gives a count and one exemplar certname — which answers how many, never which ones.
The Nodes page now takes fact rows:
os.name is Ubuntu
os.release.major is 22.04
…and returns the node list itself — sortable, paginated, with the report status and environment already there.
The operator vocabulary is the same one classification rules use — is, is not, matches, is one of, is reported, is not reported — so there is one grammar to learn, and a filter that finds a set of nodes reads like the rule that would classify them. The path picker is fed by the same source as classification, carrying per-path coverage counts, so a fact nothing reports can be seen for what it is before anybody filters on it.
Queried through PuppetDB, not the local projection
ManagedNode holds an allow-listed subset of facts, chosen for rule evaluation (ADR-0004). Filtering there would have answered questions about a fraction of the facts while looking authoritative.
Facts arrive as an inventory subquery on the nodes endpoint rather than by querying inventory directly, because the node list needs nodes fields — report status, the three environment columns — which inventory does not carry. The Nodes page already depended on PuppetDB, so nothing new breaks when it is unavailable.
Two details that matter:
is notrequires the fact to exist first. A bare!=reports a node missing the fact as "not equal to X" — it has no opinion, and conflating those is how a filter quietly loses machines.- The fact path becomes a field name, the one position a typed query builder cannot protect, so its grammar is constrained by schema and a test asserts a hostile string survives as a value and closes nothing.
Fixed: the Matching rules row
The fact path and value collapsed to slivers while the operator took the whole line. Select carries w-full in its base classes — correct in a form column, wrong in a flex row, where width: 100% makes it demand the whole container while flex-basis: 0 siblings get what is left.
Upgrading
No migrations.
Next
Phase 2 — save, name and share a filter (private by default). Phase 3 — CSV export.
v1.7.5 — undef is a default
A parameter defaulting to undef is optional, not required
Reported from a live console. A class declaring
Optional[String[1]] $cloud = undef,was shown as required — along with three more like it, above a header announcing "6 required parameters" for a class that has two.
= undef is a default. It is precisely what makes such a parameter omissible. What makes one required is the absence of any =, which arrives as neither default_literal nor default_source.
The old reasoning, written in a comment, was half right:
undefis Puppet for "no default value", not a default of the string "undef".
The second clause is true. The first is not.
And a test asserted the mistake — "treats an undef default as required" — converting it into a guarantee the suite would have defended against anybody trying to fix it. A test that pins wrong behaviour is worse than no test. It is inverted now, and the reported class is held as a fixture: two required, four optional, enum default still read correctly.
The console drops the red required badge on these, says "optional — leave blank to omit", and stops counting them toward the required total.
Two conditions for a fault found in our own lab
Configuration pulled from one instance, telemetry pushed to another. A Puppet server fetched the tree 2705 times and reported nothing, while a second deployment banked receipts for a tree it never served. The estate converged perfectly the whole time; only the answer to "did it land" was broken, and the propagation front sat at compiled 0/N indefinitely with no way to tell that from "no agent has run yet".
replication.not-reporting— a peer that has held a tree for four run intervals and never reported. Names the two causes: a collector pointed at another origin, or one that is not running.replication.unexpected-receipts— a peer reporting compiles without ever having fetched, which it therefore could not have run.
Both are warnings. Nothing is misconfigured on the estate and no node is wrong; what is lost is visibility, and calling that critical teaches people to ignore critical.
Neither compares revisions, which was the obvious design and does not work: the only stored record of a served revision is the peer's last etag, so a differing revision cannot be told apart from an agent legitimately running behind — and flagging every lagging agent as foreign would be worse than the bug.
Upgrading
No migrations. No schema change; the new conditions are derived from data already stored.