Convert system.vendor to the typesafe port pattern
## Context
system.vendor was an old-style service with inline BaseModel args/results and @api_method-wrapped methods. All three methods (name, unvendor, is_vendored) are private with no over-the-wire surface and there is no datastore, so it fits the fully-private port pattern — a lean private shim delegating to plain, fully type-annotated module functions — rather than a Generic*/Pydantic conversion. The wire shapes (str | None, bool, None) are unchanged.
## Solution
- vendor.py is now a plain typed logic module (get_vendor, remove_vendor_file, is_vendored); get_vendor stays importable there for scripts/vendor_service.py. The lean VendorService shim in __init__.py keeps the try/except + logging orchestration and delegates to it, with Config private. unvendor keeps the etc.generate string call since that's CtxMethod dynamic dispatch.
- Registered VendorService on SystemServicesContainer in main.py so it resolves as self.s.system.vendor.
- Switched the six in-process callers (support x2, usage x2, nvmet.subsys, device_ netlink events) from string middleware.call to call2/call_sync2 against the typed method handle.
- Added the plugin to the mypy workflow list.
Consolidate license feature checks into truenas.license.feature_available
This commit adds changes to route every "is the system licensed to use feature X" check through a single truenas.license.feature_available method, with the surrounding hardware/product gating captured as a FeaturePolicy enum (ANY, ENTERPRISE, HA_APPLIANCE, IX_HARDWARE) instead of being re-implemented at each call site. system.feature_enabled and system.sed_enabled now just delegate to it.
As part of this the SED check becomes legacy-license aware (it previously consulted only the daemon) and feature expiry is now honored everywhere, while the license is still never consulted on hardware where a feature was never gated, so apps and VMs stay unrestricted off HA/iX appliances exactly as before.
Legacy on-disk licenses grant their feature bits perpetually -- contract_end there is only the support-contract end, after which the system stays fully functional -- so those features are no longer stamped with that date as their expiry; only SUPPORT tracks contract_end. Without this, enforcing expiry would have wrongly disabled SED, FC, VMs and apps on legacy-licensed systems past their support contract.
Consolidate license feature checks into truenas.license.feature_available
This commit adds changes to route every "is the system licensed to use feature X" check through a single truenas.license.feature_available method, with the surrounding hardware/product gating captured as a FeaturePolicy enum (ANY, ENTERPRISE, HA_APPLIANCE, IX_HARDWARE) instead of being re-implemented at each call site. system.feature_enabled and system.sed_enabled now just delegate to it.
As part of this the SED check becomes legacy-license aware (it previously consulted only the daemon) and feature expiry is now honored everywhere, while the license is still never consulted on hardware where a feature was never gated, so apps and VMs stay unrestricted off HA/iX appliances exactly as before.
Convert cloud_backup plugin to the typesafe pattern
This commit adds changes to convert the cloud_backup plugin to the typesafe service/part pattern, so query and get_instance return Pydantic models, public methods use @api_method(check_annotations=True), and same-process calls go through call2/call_sync2.
The shared CloudTaskServiceMixin is left untyped since cloud_sync still depends on it, with a single sibling-safe edit to its zvol validation path. All in-process consumers were updated for model access: the cloud_sync credential delete check, the cron.d mako, and the path-resolution migration. Since the password is a Secret field, the create/update and restic paths dump with expose_secrets so an unchanged password isn't written back as the redaction string.
Consolidate license feature checks into truenas.license.feature_available
This commit adds changes to route every "is the system licensed to use feature X" check through a single truenas.license.feature_available method, with the surrounding hardware/product gating captured as a FeaturePolicy enum (ANY, ENTERPRISE, HA_APPLIANCE, IX_HARDWARE) instead of being re-implemented at each call site. system.feature_enabled and system.sed_enabled now just delegate to it.
As part of this the SED check becomes legacy-license aware (it previously consulted only the daemon) and feature expiry is now honored everywhere, while the license is still never consulted on hardware where a feature was never gated, so apps and VMs stay unrestricted off HA/iX appliances exactly as before.
Convert support plugin to typesafe pattern
## Context
The support plugin was an old-style dict-based `ConfigService`. This converts it to the typesafe pattern: a lean `GenericConfigService[SupportEntry]` service class delegating to a `ConfigServicePart`, with `generic = True`, `check_annotations=True` on every public method, and typed `call2` for same-process calls.
## Solution
- **Package split**: `plugins/support.py` becomes `plugins/support/` with `__init__.py` (lean service), `config.py` (`SupportModel` + `SupportConfigServicePart` holding `do_update`/`validate`), and `execute.py` (the `post` helper plus the `similar_issues`/`new_ticket`/`attach_ticket` logic as `ServiceContext`-typed functions).
- **API models**: decoupled `SupportAttachTicketArgs` from `@single_argument_args` into an explicit `SupportAttachTicket` inner model plus a plain wrapper (wire shape unchanged) so the method param can be annotated and field-accessed under `check_annotations`; exported every directly-imported model in `__all__`.
- **Registration**: registered the service in `main.py`'s `ServiceContainer` and added the plugin dir to `mypy.yml`.
- **Internal consumers**: `alert/source/proactive_support.py`, `alert/runtime.py`, and `truenas/tn.py` now use attribute access on the returned `SupportEntry` and typed `call2`/`context.call2` (constructing `SupportNewTicketEnterprise`) instead of dict access and string `middleware.call`.
The public wire shape is unchanged; live verification on the test VM confirmed read-only outputs, the update round-trip, the required-field validation path, and the ProactiveSupport alert consumer all behave identically to before.
Apply ruff formatting to new support package files
## Context
ruff's `format --diff` CI check only runs on git-added files and, with no quote-style configured, enforces its default (double quotes), so the newly added `support/` package needs reformatting once committed.
## Solution
Ran `ruff format` on the three new files in `plugins/support/`; pure style changes (quote style and call-argument wrapping), no logic changes.
Convert support plugin to typesafe pattern
## Context
The support plugin was an old-style dict-based `ConfigService`. This converts it to the typesafe pattern: a lean `GenericConfigService[SupportEntry]` service class delegating to a `ConfigServicePart`, with `generic = True`, `check_annotations=True` on every public method, and typed `call2` for same-process calls.
## Solution
- **Package split**: `plugins/support.py` becomes `plugins/support/` with `__init__.py` (lean service), `config.py` (`SupportModel` + `SupportConfigServicePart` holding `do_update`/`validate`), and `execute.py` (the `post` helper plus the `similar_issues`/`new_ticket`/`attach_ticket` logic as `ServiceContext`-typed functions).
- **API models**: decoupled `SupportAttachTicketArgs` from `@single_argument_args` into an explicit `SupportAttachTicket` inner model plus a plain wrapper (wire shape unchanged) so the method param can be annotated and field-accessed under `check_annotations`; exported every directly-imported model in `__all__`.
- **Registration**: registered the service in `main.py`'s `ServiceContainer` and added the plugin dir to `mypy.yml`.
- **Internal consumers**: `alert/source/proactive_support.py`, `alert/runtime.py`, and `truenas/tn.py` now use attribute access on the returned `SupportEntry` and typed `call2`/`context.call2` (constructing `SupportNewTicketEnterprise`) instead of dict access and string `middleware.call`.
The public wire shape is unchanged; live verification on the test VM confirmed read-only outputs, the update round-trip, the required-field validation path, and the ProactiveSupport alert consumer all behave identically to before.
Convert cloud_backup plugin to the typesafe pattern
This commit adds changes to convert the cloud_backup plugin to the typesafe service/part pattern, so query and get_instance return Pydantic models, public methods use @api_method(check_annotations=True), and same-process calls go through call2/call_sync2.
The shared CloudTaskServiceMixin is left untyped since cloud_sync still depends on it, with a single sibling-safe edit to its zvol validation path. All in-process consumers were updated for model access: the cloud_sync credential delete check, the cron.d mako, and the path-resolution migration. Since the password is a Secret field, the create/update and restic paths dump with expose_secrets so an unchanged password isn't written back as the redaction string.
Convert truecommand plugin to typesafe pattern
This commit adds changes to convert the truecommand plugin to the typesafe pattern, splitting the old compound ConfigService into a lean GenericConfigService that delegates to a ConfigServicePart with Pydantic models, while the portal/wireguard/state logic moves into plain context-first functions and same-process calls use call2. In-process consumers of truecommand.config (truenas and security) switch from dict access to typed attribute access.
Restrict TOTP interval to supported values
This commit adds changes to restrict the per-user two-factor TOTP interval to 30 or 60 seconds, since the OATH users file consumed by pam_oath only understands those time-steps and any other value silently breaks 2FA for the user. A migration clears the secret and resets the interval for existing rows holding an unsupported value so affected users re-enroll, and the render-time coercion is dropped now that the input is validated at the API.
NAS-141350 / 27.0.0-BETA.1 / Reject and normalize non-colon NIC MAC addresses (#19154)
## Problem
A custom NIC MAC entered with dash, no-separator, or mixed separators
(e.g. `10-66-6A-1F-F1-B1`) passed the permissive `mac` pattern but
libvirt's `defineXML` only parses colon-separated MACs, so the
container/VM saved fine and then failed to start with `XML error: unable
to parse mac address`. The colon-only `MACAddr(separator=':')` guard the
VM plugin used through electriceel was dropped when devices moved to the
pydantic models at fangtooth, and containers (26.0+) never had it, so
these values can already be sitting in `vm_device` and
`container_device`.
## Solution
- Tightened the shared `MACAddress` type to colon-only with a clear
message, and switched the v27 VM and Container NIC `mac` fields to use
it (removing the duplicated permissive inline pattern). Frozen API
versions are left as-is.
- Added a migration that normalizes existing NIC MACs in both
[5 lines not shown]
Reject and normalize non-colon NIC MAC addresses
## Problem
A custom NIC MAC entered with dash, no-separator, or mixed separators (e.g. `10-66-6A-1F-F1-B1`) passed the permissive `mac` pattern but libvirt's `defineXML` only parses colon-separated MACs, so the container/VM saved fine and then failed to start with `XML error: unable to parse mac address`. The colon-only `MACAddr(separator=':')` guard the VM plugin used through electriceel was dropped when devices moved to the pydantic models at fangtooth, and containers (26.0+) never had it, so these values can already be sitting in `vm_device` and `container_device`.
## Solution
- Tightened the shared `MACAddress` type to colon-only with a clear message, and switched the v27 VM and Container NIC `mac` fields to use it (removing the duplicated permissive inline pattern). Frozen API versions are left as-is.
- Added a migration that normalizes existing NIC MACs in both `vm_device` and `container_device` to libvirt's canonical lowercase colon form, regenerating the rare value that isn't a real MAC. This is required because `*.device.query` re-validates rows through the model, so an un-normalized non-colon MAC would otherwise make `query` fail once the pattern is tightened. Normalization preserves the user's intended address and heals instances that were stuck failing to start.
Convert SSH plugin to typesafe pattern
## Context
Migrates the `ssh` plugin from the legacy dict-based `SystemServiceService` to the typesafe pattern, matching the `ups`/`ftp` shape.
## Solution
Split the single `ssh.py` into a package: a lean `SSHService` (`generic = True`) in `__init__.py` delegating to `SSHServicePart` in `config.py`, with the host-key helpers moved to plain functions in `keys.py`. `config`/`update` now return the `SSHEntry` Pydantic model in-process, so every internal consumer was updated: the `sshd_config` mako and the SSH `config.py` renderer `.model_dump()` the model at the top, and the in-process callers (`keychain`, `failover` nftables, the `service_` start/reload hooks, and the plugin's own `setup()`) were switched from string `middleware.call('ssh.…')` to typed `call2`/`call_sync2`. The only remaining string call is `etc.py`'s dynamic `CtxMethod` dispatch, which has no static method handle. Registered the service in `main.py` and added the package to the mypy workflow.
Convert SSH plugin to typesafe pattern
## Context
Migrates the `ssh` plugin from the legacy dict-based `SystemServiceService` to the typesafe pattern, matching the `ups`/`ftp` shape.
## Solution
Split the single `ssh.py` into a package: a lean `SSHService` (`generic = True`) in `__init__.py` delegating to `SSHServicePart` in `config.py`, with the host-key helpers moved to plain functions in `keys.py`. `config`/`update` now return the `SSHEntry` Pydantic model in-process, so every internal consumer was updated: the `sshd_config` mako and the SSH `config.py` renderer `.model_dump()` the model at the top, and the in-process callers (`keychain`, `failover` nftables, the `service_` start/reload hooks, and the plugin's own `setup()`) were switched from string `middleware.call('ssh.…')` to typed `call2`/`call_sync2`. The only remaining string call is `etc.py`'s dynamic `CtxMethod` dispatch, which has no static method handle. Registered the service in `main.py` and added the package to the mypy workflow.
Convert cloud_backup plugin to the typesafe pattern
This commit adds changes to convert the cloud_backup plugin to the typesafe service/part pattern, so query and get_instance return Pydantic models, public methods use @api_method(check_annotations=True), and same-process calls go through call2/call_sync2.
The shared CloudTaskServiceMixin is left untyped since cloud_sync still depends on it, with a single sibling-safe edit to its zvol validation path. All in-process consumers were updated for model access: the cloud_sync credential delete check, the cron.d mako, and the path-resolution migration. Since the password is a Secret field, the create/update and restic paths dump with expose_secrets so an unchanged password isn't written back as the redaction string.
Convert truecommand plugin to typesafe pattern
This commit adds changes to convert the truecommand plugin to the typesafe pattern, splitting the old compound ConfigService into a lean GenericConfigService that delegates to a ConfigServicePart with Pydantic models, while the portal/wireguard/state logic moves into plain context-first functions and same-process calls use call2. In-process consumers of truecommand.config (truenas and security) switch from dict access to typed attribute access.
Convert cloud_backup plugin to the typesafe pattern
This commit adds changes to convert the cloud_backup plugin to the typesafe service/part pattern, so query and get_instance return Pydantic models, public methods use @api_method(check_annotations=True), and same-process calls go through call2/call_sync2.
The shared CloudTaskServiceMixin is left untyped since cloud_sync still depends on it, with a single sibling-safe edit to its zvol validation path. All in-process consumers were updated for model access: the cloud_sync credential delete check, the cron.d mako, and the path-resolution migration. Since the password is a Secret field, the create/update and restic paths dump with expose_secrets so an unchanged password isn't written back as the redaction string.
NAS-141465 / 27.0.0-BETA.1 / Adds ARM64 guest VM support (#19167)
Adds aarch64 guest VM support to TrueNAS. Users can now create and run
ARM64 VMs on either x86_64 or aarch64 hosts, and x86_64 VMs on aarch64
hosts. Same-architecture guests use KVM acceleration; cross-architecture
guests fall back to QEMU software emulation.
Packaging, firmware, XML generation, CPU model selection, and
create-time validation are all updated to handle both architectures
correctly.
Add unit tests for aarch64 VM validation rules
15 parametrized cases covering the three x86-only flag rejections
(UEFI_CSM, hyperv_enlightenments, hide_from_msr) on aarch64 guests,
their acceptance on x86 guests, and the cross-arch HOST-PASSTHROUGH /
HOST-MODEL cpu_mode guard including the i686-on-x86_64 family exception.
Make VM secboot block arch-aware and tighten arch-compat validation
The secure boot path in do_create() previously assumed an x86 guest and
rejected aarch64 + secure_boot configurations with confusing errors. It
now picks arch-appropriate machine and firmware defaults and accepts
AAVMF secure-boot variants.
Also reject combinations that can never produce a working VM: x86-only
features (UEFI_CSM, Hyper-V enlightenments, hide_from_msr) on aarch64,
and KVM-only CPU modes (HOST-PASSTHROUGH, HOST-MODEL) when the guest
architecture doesn't match the host. These previously slipped through
schema validation and failed later at libvirt define-time; catching them
up front yields a useful error message.
Includes a unit test for the secboot firmware-name detection helper.