Keep the FIPS misconfiguration alert on licensed Minis and whiteboxes
This commit fixes an issue where the FIPS misconfiguration alert stopped running on licensed Minis and licensed whiteboxes even though those systems can still enable FIPS: the STIG vector is key-only on both hardware sides and STIG is injected into every legacy licence, so they are entitled while TRUENAS_HARDWARE excluded them by construction.
EXPECTED_TO_BE_LICENSED is a strict superset of both the old product_type gate and the hardware gate, so nothing loses coverage, unlicensed appliances keep the check that catches FIPS being active when it was never configured, and licensed whiteboxes that could never see this alert are now covered too.
Say which systems the enclosure alerts newly reach
This commit adds changes to test the failover run gates and to correct what the enclosure change was described as affecting. post_failover_blackout and require_stable_peer move out of the run loop into source_run_gates_pass so both can be checked without a middleware object or a running service, and a frozen inventory test pins which sources carry them and what each one is gated on.
failover_related was gating enclosure_status on an HA licence, which is a licensing predicate in front of a hardware question. Enclosure faults are now reported on every iX appliance that lacks an ENTERPRISE_HA licence, both unlicensed appliances and single controller licensed ones (ENTERPRISE_SINGLE, FREENAS-CERTIFIED). The earlier note said only "unlicensed appliances", which undercounts the fleet by the whole licensed single controller half. EnclosureHealthy is INFO and is raised once per enclosure, expansion shelves included, so affected systems will see several new informational alerts.
The matrix legend claimed a source row meant the source is ran, which was never true: it models applies_to only, and never post_failover_blackout, require_stable_peer, the schedule or source locks. That is the only change to the golden file.
Document alert applicability and the frozen inventory
This commit adds changes to document the applicability axes and the frozen inventory in the alert plugin docs, since the only record of how to regenerate that file lived in the test module's own docstring and you only found it after CI had already failed. The populations are pulled in with automodule so a new one documents itself, and the page spells out what does and does not require a regeneration plus what each guard test means when it fails.
Only report a missing license on HA capable systems
This commit fixes an issue where an unlicensed R-series or Z-series started getting told to contact support about a license it was never sold. Those chassis are appliances so they satisfy the hardware rule, but they cannot fail over, and on 26 the product_type disjunct meant a system with no license only reached this alert when it was HA capable. HA capability is a chassis probe the applicability axes deliberately do not carry, so the test sits in check_sync rather than in applies_to.
Keep uncorrected memory error alerts on Minis
This commit fixes an issue where the uncorrected memory error alert stopped running on Minis even though they carry ECC memory and populate the same EDAC counters the source reads. TRUENAS_HARDWARE excludes MINI by construction, so a licensed Mini lost a proactive support warning it used to get, and any alert it was already sitting on was purged on upgrade without a cleared notification.
TRUENAS_OR_MINI_HARDWARE is the union of the two hardware names, composed from them rather than defined as "not GENERIC" so a hardware class added later has to be added here deliberately. MemorySizeMismatch stays on TRUENAS_HARDWARE because its own check returns early without an HA licence.
Remove the product_type gate from alerts
This commit adds changes to delete `products` from every alert declaration now that the hardware and license axes carry the gating, along with the `product_types` field it fed in `alert.list_categories` and the unused `AlertService.product_type` helper. The applicability matrix loses its old-versus-new comparison and becomes a frozen inventory of what each declaration covers; the declared answers are unchanged from the previous commit, cell for cell.
The API field is removed outright rather than deprecated because nothing consumes it - it exists in no earlier API version and has no client in the WebUI, api_client or midcli, so the downgrade adapter's strip loop is already a no-op here. Forward-porting this to master will need a `to_previous` on `AlertCategoryClass`, because there v26_0_0 is frozen with the field required and the version adapter only backfills fields that are not required.
Remove the broken SATA DOM wear alert
This commit adds changes to delete the SATA DOM wear alert source and its two classes. The source calls disk.sata_dom_lifetime_left, which was removed in 25.10 and has no implementation anywhere, so it raises on every run on any M or Z series chassis and produces nothing but alert source failures.
Alerts already persisted on existing systems clear on the next start through the stale source check in initialize, which runs before the policies are built, so nothing is announced as cleared. No migration is needed, matching how every previous alert source removal was handled.
Move alert gating onto hardware and license axes
This commit adds changes to give every declaration under alert/source its own applicability rule on the decoupled hardware and license axes, and points the enforcement sites in plugins/alert.py at those rules instead of system.product_type. The products lines stay for now because the API field still reads them. HA classes take a hardware applies_to alongside listed_when on purpose, so on iX hardware whose HA licence is absent or lapsed they leave the settings catalogue without being silenced, and a source that no longer applies now has its stored alerts dropped rather than stranding them undismissable. The two scheduled-reboot classes are the exception: they are gated on the HA licence itself, so a system without one stops seeing them at all.
sata_dom_wear is knowingly shipped in a broken state: its source calls disk.sata_dom_lifetime_left, which was removed in March 2025, so appliances will see the check fail. That is a deliberate, signed-off decision and not an oversight.
The checked-in matrix under pytest/unit/alert/golden spells out old versus new applicability per population for every declaration, and its test regenerates and diffs it so no population change lands unread.
Drive the alert enforcement sites from unit tests
This commit adds changes to test the four sites in plugins/alert.py that consume an applicability rule, rather than only the rules themselves. The run and send axes had no execution coverage at all: emptying the excluded-source set, deleting the alert purge loop, or making the send filter answer True all left the suite green. These drive __run_alerts and send_alerts against stub sources through a shared harness, and record what the policies delete, since dropping the purge loop still leaves the alert list correct and nothing else can see it.
The purge is worth pinning on its own because it destroys state: a source whose rule stops admitting this system loses the alerts it had persisted, and when it applies again they come back with a fresh uuid and their dismissed flag cleared. There is also an asymmetry three lines apart in that loop, deliberate but unasserted until now, where a rule-excluded source is purged and a gate-blocked one is only skipped.
Also fixes both AST scanners. BondStatus is a shipped source that does not carry the AlertSource name suffix, so the black hole check never saw it or the three classes it creates; keying off the subclass relation the loader itself uses picks it up and keeps the two modules from disagreeing. Reading attribute-spelled bases stops a class written as base.AlertClass dropping out of the frozen inventory with nothing to show for it. Both scanners now carry an invariant test, so a shape they cannot model fails instead of silently shrinking what is covered.
Clarify the alert applicability docs and inventory naming
This commit adds changes to address review feedback on the alert applicability work: the frozen inventory moves out of `golden/` into `inventory/` so the directory name says what it holds, the documentation drops the parts that duplicate the inventory file's own header along with the aside about rules we do not accept, and the black hole scan loses its `alert.oneshot_create` branch since no alert source names a class by string. Regenerating the inventory is a no-op, so no population moved.
Type alert applicability against AlertSource and AlertClass
This commit adds changes to drop the Declaration protocols in favour of TYPE_CHECKING imports of AlertSource and AlertClass, which is what review asked for, and to tighten the surface while we are in there: rule_name is no longer re-exported now that declaration_rule_name is the only entry point production goes through, and the Rule docstring keeps just the part that constrains how you write one, which is to name it so the black-hole diagnostic has something to report.
Dropping the protocols makes alert.applicability depend on alert.base, and import-linter counts TYPE_CHECKING imports, so that broke the package layering contract: engine reached vocabulary and snapshot through alert.base importing the whole package. alert/base.py now takes Rule from the engine module directly, which is the only thing it ever wanted, and the contract holds without an ignore.
Express alert applicability rules as functions and cache them once
This commit adds changes to replace the applicability rule dataclasses with plain functions that take facts, and to route every applicability answer through a single Applicability object holding one reading of those facts and memoizing per declaration. Nothing in the tree ever introspected rule structure, so the isinstance dispatch and the AnyOf/AllOf combinators go away with it, and because the seven vocabulary names are unchanged no alert declaration moves and the golden matrix is byte for byte identical.
The five separate get_facts() reads in the alert plugin become one, invalidated on system.post_license_update so an upload takes effect on both nodes at once. A None license is never cached, since get_license() returns None both for "no license" and "the daemon did not answer" and we do not want a hiccup to stick until restart.
Name alert applicability rules and detect black-holed alerts
This commit adds changes to give the alert applicability engine one name per population instead of thirty open-coded rule constructions, and to catch alerts that are created but can never be displayed.
The rules alert declarations gate on now live in a vocabulary module next to the engine, so TRUENAS_HARDWARE is defined once rather than built at eighteen sites in two competing conventions. LicenseRule collapses into EntitlementRule, which asks the entitlement policy about any feature rather than the two the old enum could spell, so STIG and everything after it is a one-line declaration. AllOf joins AnyOf in the algebra, listed_when becomes listed_only_when to say that it narrows, and AlertFacts is gone in favour of the identical EntitlementFacts that applies() was converting to anyway.
A source whose rule is satisfied where its class's rule is not creates alerts that are stored and never shown. Nothing checked for that, and since the failover declarations deliberately straddle the two axes it is no longer something a reviewer can spot by eye. A test now infers the source to class relation out of check() and asserts the implication across every population, with a runtime guard logging the cases static analysis cannot see. It found four pre-existing ones in memory_errors and sensors, which are fixed by narrowing the sources so nothing user visible moves.
One behaviour change worth a release note: failover_related was gating enclosure_status on an HA licence, which is a licensing predicate in front of a hardware question, so unlicensed appliances will now report enclosure faults they were previously silent about. The flag is renamed post_failover_blackout to name what it actually contributes.
Name the license expiry alerts after the support contract
This commit adds changes to retitle the two expiry alerts, which said the license was expiring when what actually expires is the support contract — the alert bodies and the renewal mail have said so all along, and a license carries no expiry of its own. Class names are deliberately unchanged so user set alert levels and policies keep matching.
Run the local account expiration alert everywhere
This commit fixes an issue where the local account expiration alert stopped running on Minis even though they can still turn on password aging: max_password_age sits behind the STIG entitlement, whose vector grants on the key column of either hardware side, and that key is injected into every legacy licence. The source is also the admin lockout recovery, since it regenerates shadow a day before the last full admin password ages out, so excluding a system that can arm password aging leaves it with no way back in short of a reboot.
check() already returns early unless max_password_age is set, so that early return is the applicability test and the rule is dropped rather than widened. On a system that cannot set the option the daily cost is one config read.
Add an applicability engine for alerts
This commit adds changes to give alerts a way to state which systems they are meaningful on in terms of two independent axes, hardware class and license, instead of the conflated product_type predicate they lean on today. The engine is pure and delegates the HA question to the entitlement policy so there stays one definition of it in the tree.
Nothing declares the new applies_to/listed_when slots yet and no enforcement point reads them, so this is inert on its own; the declarations and the call sites follow.
Give a working command for regenerating the alert inventory
This commit fixes an issue where the documented way to regenerate the alert applicability inventory does not work: run from the repository root it imports the installed middlewared rather than the working tree, and collection fails before anything is regenerated. Both the docs and the module docstring now give the invocation CI uses, from `src/middlewared` with `PYTHONPATH=.`.
Register license reconcile delegates through call2
This commit adds changes to register the license reconcile delegates through call2 so the delegate argument is type checked instead of being resolved from a namespace string at plugin load time. The two entitlement lookups that were reaching for self.middleware.call2 are also shortened back to self.call2, which is what the rest of these gates use.
Merge the license reconcile service into the license namespace
This commit fixes an issue where the reconcile methods declared in license_reconcile.py did not exist at runtime. The truenas.license namespace is pre-instantiated by ServiceContainer, and the loader only builds a compound service for a namespace it resolves itself, so the second class declaring that namespace was collected and then never instantiated -- every setup() calling register_reconcile_delegate raised MethodNotFoundError and aborted startup. license.py's service now inherits it, which is the same merge pool.snapshottask already relies on, and the unit test is repointed at the composed class so it can no longer pass while the composition is broken.
Type the entitlement lookup boundary
This commit adds changes to give the two vocabularies a policy lookup can be keyed by - the license feature keys, and the entitlements derived from license type or tier - a single name, EntitlementKey, and to use it on get_entitlement and the truenas.entitlements.check endpoint so a bogus or misspelled key is caught there rather than raising out of the engine whenever a user first happens to hit that gate. LicenseFeature is re-exported from the entitlements package as well, so callers no longer need to know that half the vocabulary is defined outside this tree.
The endpoint's field carries a validator that resolves a name to its member first, because our API models are strict and a feature arrives over the wire as a plain string, which a bare enum field would refuse outright. check_entitlement itself still takes a str on purpose: it is what decides whether a key is known at all, so its own unknown-key error path would be unreachable if the type could not express an unknown key.
Gate the mission critical update profile through the entitlement engine
This commit adds changes to resolve the mission critical update profile through the entitlement engine rather than system.is_ha_capable. The old gate had dropped the license half of the is_enterprise check it replaced, so licensed single controller appliances, Minis and whiteboxes quietly lost the profile. MISSION_CRITICAL is already injected into every legacy license so the existing fleet keeps it across an upgrade, and HA capable but unlicensed systems losing it is a deliberate difference.
Adapt dict of model fields when converting between API versions
This commit fixes an issue where a field declared as dict[str, Model] was never version adapted, so an older client silently got the raw payload back rather than a converted one. The new clause has to come after the single nested model check because both shapes are a plain dict at runtime, and reversing them feeds map values into the model adapter.
Give HardwareClass an is_appliance predicate
This commit adds changes to move the appliance-vs-not test onto HardwareClass itself so the entitlement engine stops open-coding the TRUENAS_HW comparison, and to declare the hardware, entitlement and license package layerings as import-linter contracts.
Reconcile license derived config when the license changes
This commit adds changes to converge every subsystem whose configuration is derived from the license once that license changes. `truenas.license.upload` regenerated exactly one etc group and then fired `system.post_license_update` detached. That was survivable while gates asked `is_enterprise`, which expanded to `is_ha_capable OR (licence AND model AND not freenas)` -- on appliance hardware the first limb was already true before any license was installed, so those gates did not change their answer when one arrived and never re-rendering them cost nothing. Now that they are feature key checks they do change their answer, and a spread of etc groups were left silently stale until something unrelated regenerated them or the box rebooted. The plainest case is sudoers, where sudo command auditing simply did not turn on when an appliance was licensed.
Each affected subsystem now registers a LicenseReconcileDelegate from its own setup, naming the etc groups it owns and what should happen once they have been re-rendered, and a runner walks the eight of them on the hook. Doing this per plugin rather than centrally in etc matters because the right action differs between them, and not always for the obvious reason. The block target delegates only render, but they still decline to run when their service is stopped, because for nvmet and LIO writing the config is itself the reconfiguration rather than a file something reads later. smb and discovery reload a real daemon. The user delegate reloads a pseudo service that has no daemon behind it at all, so there the reload is simply how the group gets regenerated. And ctdb gets a restart because the presence of its config file is what decides whether the daemon can start. Registration refuses a duplicate name or a group another delegate already claims, so two plugins cannot quietly both own one.
The hook is still fired detached, so the upload itself does not wait on the pass. failover.status is dropped from its cache ahead of it, since a good half of these groups read that while rendering and would otherwise reconcile against the status cached under the old license, and truesearch is registered async so that waiting on its service job cannot hold the other consumers up behind it.
The cron template also goes back to rendering unless failover.status is definitively BACKUP. Keying it on SINGLE or MASTER instead, which is where it had drifted to, meant a transient ELECTING, IMPORTING or ERROR read emptied the whole schedule -- every user cronjob, rsync task, cloud sync, scrub and update check -- with nothing to put it back until the group happened to be regenerated again.