Compare commits

..
19 Commits
Author SHA1 Message Date
sandwich 0ce2590568 fix(validation): accept an empty string as the unset value of an int 2026-10-05 19:25:02 +02:00
sandwich e52696a509 feat(validation): name a keyed collection as such when a list is given 2026-10-05 18:57:22 +02:00
sandwich 8f1c1a775a fix(validation): say no keys are accepted when a dict declares none 2026-10-05 18:55:22 +02:00
sandwich 881c20e4da feat(validation): read a raw option with options as a bool-or-dict toggle 2026-10-05 18:52:59 +02:00
sandwich b6620260d5 fix(validation): refuse a specs argument that is not a list 2026-10-05 18:50:09 +02:00
sandwich 7129ceb515 feat(validation): treat any <placeholder> sub-option as a keyed collection 2026-10-05 18:21:02 +02:00
sandwich fec3fbfa8e fix(global_defaults): type id, vlan, disk size and disk bus as the contract does 2026-10-05 18:17:09 +02:00
sandwich 27d0edf3f7 feat(global_defaults): declare every system key and installer variable the repos read 2026-10-05 18:15:34 +02:00
sandwich d0916e5d2b feat(validation): check the contents of a raw option that holds a dict or list 2026-10-05 18:12:41 +02:00
sandwich 75ea3f10e2 feat(validation): expand glob patterns in the spec list 2026-10-05 18:10:23 +02:00
sandwich 73136b7ebb feat(validation): refuse a variable declared by two input specs 2026-10-05 18:08:55 +02:00
sandwich 7253f6af96 docs(readme): document every system.users attribute and per-disk storage 2026-10-05 18:05:05 +02:00
sandwich 2d1a875577 feat(global_defaults): validate user input against one argument spec on both paths 2026-10-05 18:05:05 +02:00
sandwich f0b580cd3d feat(validation): add a strict validate_inputs action for user input 2026-10-05 18:05:05 +02:00
sandwich 8d4bc2a207 test(hardware): expect linux-firmware-intel for intel firmware 2026-10-05 16:54:28 +02:00
sandwich 96d2970dc3 fix(virtualization): pass a pre-hashed user password through cloud-init unchanged 2026-10-05 16:54:28 +02:00
sandwich 74caca915e fix(global_defaults): validate system.users as the documented dict on both paths 2026-10-05 16:54:28 +02:00
sandwich 9aad42a4c2 refactor(global_defaults): derive the primary user once for every consumer 2026-10-05 16:54:28 +02:00
sandwich ef8d50e6be chore: ignore the local ansible lint cache 2026-10-05 16:54:28 +02:00
23 changed files with 965 additions and 184 deletions

No files matched your search

+3
View File
@@ -9,3 +9,6 @@ vars_libvirt.yml
vars_proxmox.yml
.sisyphus/
.ansible/
__pycache__/
+3
View File
@@ -1,6 +1,9 @@
---
extends: default
ignore:
- .ansible/
rules:
document-start: disable
line-length:
+7 -1
View File
@@ -217,7 +217,7 @@ When `interfaces` is empty, the flat fields (`bridge`, `ip`, `prefix`, `gateway`
#### `system.users`
Dict keyed by username. At least one user must have a `password` (used for SSH access during bootstrap). Users without a password get locked accounts (key-only auth).
Dict keyed by username. At least one user must have a non-empty `password`: the first such user, in declaration order, is the account bootstrap logs in as after the reboot and the cloud-init and Proxmox `ciuser` primary user. Users without a password get locked accounts (key-only auth).
```yaml
system:
@@ -238,6 +238,11 @@ system:
| `password` | string | -- | User password (required for at least one user) |
| `keys` | list | `[]` | SSH public keys |
| `sudo` | bool/string | -- | `true` for NOPASSWD ALL, or custom sudoers string |
| `groups` | list | derived | Supplementary groups; empty means the platform sudo group when `sudo` is set |
| `shell` | string | `/bin/bash` | Login shell |
| `password_login` | bool | `false` | Read by the main repository's SSH hardening, ignored here |
| `uid` | int | -- | Read by the main repository's user actions; the bootstrap assigns `1000 + index` |
| `disabled` | bool | `false` | Read by the main repository's user actions, ignored here |
Users must be defined in inventory. The dict format enables additive merging across inventory layers with `hash_behaviour=merge`.
@@ -544,6 +549,7 @@ On VMware, `device` is resolved on the target from the SCSI address instead: ent
| ------------- | ------ | ------------------------------------------------------ |
| `size` | number | Disk size in GB (required for virtual) |
| `device` | string | Block device path (required for physical data disks) |
| `storage` | string | Proxmox storage for this disk; empty uses `hypervisor.storage` |
| `partition` | string | Derived from `device` during normalization (not user input) |
| `mount.path` | string | Mount point (additional disks only) |
| `mount.fstype`| string | `btrfs`, `ext4`, or `xfs` |
+2
View File
@@ -1,5 +1,7 @@
[defaults]
hash_behaviour = merge
roles_path = roles
action_plugins = plugins/action
interpreter_python = auto_silent
deprecation_warnings = False
host_key_checking = False
+3 -5
View File
@@ -119,15 +119,13 @@
when:
- post_reboot_can_connect | bool
no_log: true
vars:
_primary: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first) }}"
ansible.builtin.set_fact:
ansible_connection: ssh
ansible_host: "{{ system_cfg.network.ip }}"
ansible_port: 22
ansible_user: "{{ _primary.key }}"
ansible_password: "{{ _primary.value.password }}"
ansible_become_password: "{{ _primary.value.password }}"
ansible_user: "{{ bootstrap_primary_user.name }}"
ansible_password: "{{ bootstrap_primary_user.password }}"
ansible_become_password: "{{ bootstrap_primary_user.password }}"
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3
+152
View File
@@ -0,0 +1,152 @@
"""Strict validation of user-facing variable dicts against role argument specs.
Each spec file's ``argument_specs.inputs.options`` declares top-level variables. Every one the host
defines is checked without Ansible's lenient coercion: a string is never a list, bool or dict. A dict
option whose only sub-option is a placeholder such as ``<name>`` is a collection keyed by name; each
entry is checked against that sub-option, and a null entry means the entry is not managed. A ``raw``
option that declares ``options`` is a role toggle: ``true``, ``false``, or the dict those options
describe. Messages name the key path
and the types involved but never a value, so secrets cannot leak into job output.
"""
import glob
from collections.abc import Mapping
from ansible.errors import AnsibleError
from ansible.parsing.vault import EncryptedString
from ansible.plugins.action import ActionBase
SPEC_KEYS = {"type", "required", "default", "choices", "elements", "options", "description", "no_log"}
UNRESOLVED = object()
# Numbers pass where a string is declared and digit strings where an int is: the roles read those
# through `| string` and `| int` unchanged. An empty string is the contract's unset int, as in
# `system.vlan: ""`. A string posing as a list, bool or dict never passes.
TYPES = {
"str": lambda v: isinstance(v, (str, int, float, EncryptedString)) and not isinstance(v, bool),
"int": lambda v: (isinstance(v, int) and not isinstance(v, bool)) or (isinstance(v, str) and (v == "" or v.isdigit())),
"float": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
"bool": lambda v: isinstance(v, bool),
"list": lambda v: isinstance(v, list),
"dict": lambda v: isinstance(v, Mapping),
"raw": lambda _: True,
}
def _kind(value):
if value is None:
return "null"
# bool before int: bool is an int subclass.
for name, cls in (("bool", bool), ("dict", Mapping), ("str", (str, EncryptedString)), ("int", int),
("float", float), ("list", list)):
if isinstance(value, cls):
return name
return type(value).__name__
def _check_spec(options, path):
for key, spec in options.items():
unknown = set(spec) - SPEC_KEYS
if unknown:
raise AnsibleError(f"{path}.{key}: unsupported spec attributes {', '.join(sorted(unknown))}")
if spec.get("type", "str") not in TYPES:
raise AnsibleError(f"{path}.{key}: unsupported spec type {spec['type']}")
if "options" in spec:
_check_spec(spec["options"], f"{path}.{key}")
class _Validator:
def __init__(self, templar):
self.templar = templar
self.errors = []
def resolve(self, value):
# Task vars arrive untemplated. Render one node at a time so a vaulted leaf is never decrypted;
# a node that cannot render here (no vault secret, a runtime-only variable) is left unchecked.
if isinstance(value, str) and self.templar.is_template(value):
try:
return self.templar.template(value)
except AnsibleError:
return UNRESOLVED
return value
def check(self, spec, value, path):
value = self.resolve(value)
if value is UNRESOLVED:
return
kind = spec.get("type", "str")
if kind == "raw" and "options" in spec and not isinstance(value, (bool, Mapping)):
self.errors.append(f"{path}: expected bool or dict, got {_kind(value)}")
return
if not TYPES[kind](value):
keyed = len(spec.get("options", {})) == 1 and next(iter(spec["options"])).startswith("<")
self.errors.append(f"{path}: expected {'dict keyed by name' if keyed else kind}, got {_kind(value)}")
return
if "choices" in spec and value not in spec["choices"]:
shown = "" if spec.get("no_log") else f", got {value!r}"
self.errors.append(f"{path}: must be one of {', '.join(map(str, spec['choices']))}{shown}")
# Keyed on the value, not the declared type, so a `raw` option ("false, or a dict of...") is
# still checked whenever it holds a dict or a list.
if "options" in spec and isinstance(value, Mapping):
self.check_options(spec["options"], value, path)
elif "elements" in spec and isinstance(value, list):
element = {"type": spec["elements"]}
if "options" in spec:
element["options"] = spec["options"]
for index, item in enumerate(value):
self.check(element, item, f"{path}[{index}]")
def check_options(self, options, value, path):
if len(options) == 1 and next(iter(options)).startswith("<"):
entry_spec = next(iter(options.values()))
for name, entry in value.items():
if entry is not None:
self.check(entry_spec, entry, f"{path}.{name}")
return
for key in value:
if key not in options:
accepted = ", ".join(sorted(options)) or "no keys"
self.errors.append(f"{path}.{key}: unsupported key, {path} accepts {accepted}")
for key, spec in options.items():
if key in value:
self.check(spec, value[key], f"{path}.{key}")
elif spec.get("required"):
self.errors.append(f"{path}.{key}: required key is missing")
class ActionModule(ActionBase):
_requires_connection = False
def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
task_vars = task_vars or {}
validator = _Validator(self._templar)
declared = {}
paths = []
# A pattern (`.../*/roles/*/meta/argument_specs.yml`) picks up every role's spec without a list
# to keep in sync; one that matches nothing is a broken path, not an empty set.
specs = self._task.args["specs"]
if not isinstance(specs, list):
raise AnsibleError(f"specs must be a list of spec file paths, got {type(specs).__name__}")
for entry in specs:
matches = sorted(glob.glob(entry)) if glob.has_magic(entry) else [entry]
if not matches:
raise AnsibleError(f"no spec file matches {entry}")
paths.extend(matches)
for path in paths:
options = self._loader.load_from_file(path)["argument_specs"]["inputs"]["options"]
_check_spec(options, path)
for name, spec in options.items():
if name in declared:
raise AnsibleError(f"{name} is declared by both {declared[name]} and {path}")
declared[name] = path
if name in task_vars:
validator.check(spec, task_vars[name], name)
elif spec.get("required"):
validator.errors.append(f"{name}: required variable is not defined")
if validator.errors:
result.update(failed=True, errors=validator.errors,
msg="Invalid input variables:\n" + "\n".join(validator.errors))
else:
result.update(changed=False, msg="All input variables are valid")
return result
@@ -0,0 +1,261 @@
---
# Validated by the validate_inputs action, not by Ansible's role argument check: no tasks file is named
# `inputs`. A sub-option named `<name>` marks a dict keyed by name.
argument_specs:
inputs:
short_description: User input read by the bootstrap and, through the main repository, by every role
options:
boot_iso: {type: str}
rhel_iso: {type: str}
custom_iso: {type: bool}
thirdparty_tasks: {type: str}
partitioning_efi_size_mib: {type: int}
partitioning_boot_size_mib: {type: int}
partitioning_separate_boot: {type: bool}
partitioning_boot_fs_fstype: {type: str}
partitioning_use_full_disk: {type: bool}
cleanup_verify_boot: {type: bool}
cleanup_boot_timeout: {type: int}
cleanup_remove_on_failure: {type: bool}
note: {type: str}
hypervisor:
type: dict
options:
type: {type: str, choices: [libvirt, proxmox, vmware, xen, none]}
url: {type: str}
username: {type: str}
password: {type: str, no_log: true}
token_id: {type: str}
token_secret: {type: str, no_log: true}
node: {type: str}
cluster: {type: str}
datacenter: {type: str}
storage: {type: str}
folder: {type: str}
certs: {type: bool}
ssh: {type: bool}
system:
type: dict
options:
type: {type: str, choices: [virtual, physical, vm]}
os: {type: str}
version: {type: str}
filesystem: {type: str, choices: [btrfs, ext4, xfs]}
name: {type: str}
source: {type: str}
id: {type: int}
cpus: {type: int}
memory: {type: int}
balloon: {type: int}
cores: {type: int}
config: {type: str, choices: [none, current, pending]}
network_info: {type: bool}
network_config: {type: str}
guest_path: {type: str}
path: {type: str}
timezone: {type: str}
locale: {type: str}
keymap: {type: str}
packages: {type: list, elements: str}
network:
type: dict
options:
bridge: {type: str}
vlan: {type: int}
ip: {type: str}
prefix: {type: int}
gateway: {type: str}
dns:
type: dict
options:
servers: {type: list, elements: str}
search: {type: list, elements: str}
interfaces:
type: list
elements: dict
options:
name: {type: str}
bridge: {type: str}
vlan: {type: int}
ip: {type: str}
prefix: {type: int}
gateway: {type: str}
extra_addresses: {type: list, elements: str}
parent: {type: str}
method: {type: str}
bridge_ports: {type: raw}
bridge_vids: {type: raw}
bridge_stp: {type: bool}
bridge_fd: {type: int}
bridge_vlan_aware: {type: bool}
bridge_pvid: {type: str}
disks:
type: list
elements: dict
options:
size: {type: int}
device: {type: str}
storage: {type: str}
format: {type: str}
bus: {type: str, choices: ["", scsi, virtio, sata, ide]}
slot: {type: int}
path: {type: str}
target: {type: str}
mount:
type: dict
options:
path: {type: str}
fstype: {type: str, choices: ["", btrfs, ext4, xfs]}
label: {type: str}
opts: {type: str}
users:
type: dict
options:
"<name>":
type: dict
options:
password: {type: str, no_log: true}
keys: {type: list, elements: str}
sudo: {type: raw}
groups: {type: list, elements: str}
shell: {type: str}
password_login: {type: bool}
uid: {type: int}
disabled: {type: bool}
root:
type: dict
options:
password: {type: str, no_log: true}
shell: {type: str}
content:
type: dict
options:
source: {type: str, choices: ["", dvd, mirror, satellite, none]}
url: {type: str}
proxy: {type: str}
gpgcheck: {type: bool}
satellite:
type: dict
options:
host: {type: str}
ip: {type: str}
org: {type: str}
activation_key: {type: str, no_log: true}
ca_url: {type: str}
service_level: {type: str}
environment: {type: str}
install: {type: bool}
luks:
type: dict
options:
enabled: {type: bool}
passphrase: {type: str, no_log: true}
mapper: {type: str}
auto: {type: bool}
method: {type: str, choices: [tpm2, keyfile]}
tpm2:
type: dict
options:
device: {type: str}
pcrs: {type: raw}
keysize: {type: int}
options: {type: str}
type: {type: str}
cipher: {type: str}
hash: {type: str}
iter: {type: int}
bits: {type: int}
pbkdf: {type: str}
features:
type: dict
options:
cloud_init: {type: bool}
cis:
type: dict
options:
enabled: {type: bool}
profile: {type: str, choices: [default, l1, l2]}
rules: {type: dict}
params: {type: dict}
selinux:
type: dict
options:
enabled: {type: bool}
firewall:
type: dict
options:
enabled: {type: bool}
backend: {type: str, choices: ["", firewalld, ufw]}
toolkit: {type: str, choices: [nftables, iptables]}
ssh:
type: dict
options:
enabled: {type: bool}
zstd:
type: dict
options:
enabled: {type: bool}
swap:
type: dict
options:
enabled: {type: bool}
banner:
type: dict
options:
motd: {type: bool}
sudo: {type: bool}
chroot:
type: dict
options:
tool: {type: str, choices: [arch-chroot, chroot, systemd-nspawn]}
initramfs:
type: dict
options:
generator: {type: str, choices: ["", dracut, mkinitcpio, initramfs-tools]}
desktop:
type: dict
options:
enabled: {type: bool}
environment: {type: str, choices: ["", gnome, kde, sway, hyprland]}
display_manager: {type: str, choices: ["", gdm, sddm, plasma-login-manager, greetd, ly]}
autologin: {type: raw}
session: {type: str}
groups: {type: list, elements: str}
secure_boot:
type: dict
options:
enabled: {type: bool}
method: {type: str, choices: ["", sbctl, uki]}
firmware:
type: dict
options:
enabled: {type: raw, choices: [true, false, auto]}
microcode: {type: raw, choices: [true, false, auto]}
gpu:
type: dict
options:
enabled: {type: raw, choices: [true, false, auto]}
nvidia_driver: {type: str, choices: [auto, open, proprietary, nouveau]}
peripherals:
type: dict
options:
enabled: {type: raw, choices: [true, false, auto]}
fingerprint: {type: raw, choices: [true, false, auto]}
camera: {type: raw, choices: [true, false, auto]}
audio: {type: raw, choices: [true, false, auto]}
bluetooth: {type: raw, choices: [true, false, auto]}
displaylink: {type: bool}
hardware:
type: dict
options:
profile: {type: dict}
cpu: {type: str}
gpus: {type: list, elements: str}
wireless: {type: list, elements: str}
audio: {type: list, elements: str}
camera: {type: dict}
fingerprint: {type: bool}
bluetooth: {type: bool}
packages: {type: dict}
disable: {type: list, elements: str}
kernel_params: {type: list, elements: str}
@@ -1,7 +1,7 @@
---
- name: Build normalized system configuration
vars:
system_raw: "{{ system_defaults | combine(system, recursive=True) }}"
system_raw: "{{ system_defaults | combine(system | default({}), recursive=True) }}"
system_type: "{{ 'virtual' if (system_raw.type | string | lower) in ['vm', 'virtual'] else (system_raw.type | string | lower) }}"
system_os_input: "{{ system_raw.os | default('') | string | lower }}"
system_name: >-
@@ -1,57 +0,0 @@
---
- name: Ensure system input is a dictionary
ansible.builtin.set_fact:
system: "{{ system | default({}) }}"
- name: Validate system input types
ansible.builtin.assert:
that:
- system is mapping
- system.network is not defined or system.network is mapping
- system.users is not defined or system.users is mapping
- system.root is not defined or system.root is mapping
- system.luks is not defined or system.luks is mapping
- system.features is not defined or system.features is mapping
fail_msg: "system and its nested keys (network, root, luks, features, users) must be dictionaries."
quiet: true
- name: Validate DNS lists (not strings)
when: system.network is defined and system.network.dns is defined
ansible.builtin.assert:
that:
- system.network.dns.servers is not defined or (system.network.dns.servers is iterable and system.network.dns.servers is not string)
- system.network.dns.search is not defined or (system.network.dns.search is iterable and system.network.dns.search is not string)
fail_msg: "system.network.dns.servers and system.network.dns.search must be lists, not strings."
quiet: true
- name: Validate system.users entries
when: system.users is defined and system.users is mapping and system.users | length > 0
ansible.builtin.assert:
that:
- item.value is mapping
- item.key | string | length > 0
- ('keys' not in item.value) or (item.value['keys'] is iterable and item.value['keys'] is not string)
fail_msg: "Each system.users entry must be a dict keyed by username; 'keys' must be a list."
quiet: true
loop: "{{ system.users | dict2items }}"
loop_control:
label: "{{ item.key }}"
- name: Validate system features input types
when: system.features is defined
loop: "{{ system_defaults.features | dict2items | map(attribute='key') | list }}"
loop_control:
label: "system.features.{{ item }}"
ansible.builtin.assert:
that:
- (system_defaults.features[item] is not mapping) or ((system.features[item] | default({})) is mapping)
fail_msg: "system.features.{{ item }} must be a dictionary."
quiet: true
- name: Validate system LUKS TPM2 input type
when: system.luks is defined and system.luks is mapping
ansible.builtin.assert:
that:
- system.luks.tpm2 is not defined or system.luks.tpm2 is mapping
fail_msg: "system.luks.tpm2 must be a dictionary."
quiet: true
+19 -5
View File
@@ -1,6 +1,11 @@
---
# Normalizes all input dicts into system_cfg/hypervisor_cfg/etc. here, so downstream
# roles consume the computed facts directly with no per-role _normalize (except CIS).
- name: Validate user input
validate_inputs:
specs:
- "{{ role_path }}/meta/argument_specs.yml"
- name: Global defaults loaded
ansible.builtin.debug:
msg: Global defaults loaded.
@@ -47,6 +52,17 @@
ansible.builtin.set_fact:
system_cfg: "{{ system_cfg | combine({'path': hypervisor_cfg.folder | string}, recursive=True) }}"
# Guarded so a malformed users input reaches the validation message, not a filter error.
- name: Pick the primary user (first with a password)
vars:
_first: >-
{{ (system_cfg.users if system_cfg.users is mapping else {}) | dict2items
| selectattr('value', 'mapping') | selectattr('value.password', 'defined')
| rejectattr('value.password', 'equalto', '') | first | default({}) }}
ansible.builtin.set_fact:
bootstrap_primary_user: "{{ {'name': _first.key, 'password': _first.value.password} if _first else {} }}"
no_log: true
- name: Validate variables
ansible.builtin.include_tasks: validation.yml
@@ -89,14 +105,12 @@
when:
- system_cfg.type == "virtual"
- hypervisor_type != "vmware"
vars:
_primary: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first) }}"
ansible.builtin.set_fact:
ansible_host: "{{ system_cfg.network.ip }}"
ansible_port: 22
ansible_user: "{{ _primary.key }}"
ansible_password: "{{ _primary.value.password }}"
ansible_become_password: "{{ _primary.value.password }}"
ansible_user: "{{ bootstrap_primary_user.name }}"
ansible_password: "{{ bootstrap_primary_user.password }}"
ansible_become_password: "{{ bootstrap_primary_user.password }}"
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
no_log: true
-3
View File
@@ -5,9 +5,6 @@
- name: Normalize system and disk configuration
when: system_cfg is not defined
block:
- name: Validate raw system input types
ansible.builtin.include_tasks: _validate_input.yml
- name: Normalize system configuration
ansible.builtin.include_tasks: _normalize_system.yml
+5 -106
View File
@@ -26,90 +26,6 @@
fail_msg: "hypervisor.type must be one of: libvirt, proxmox, vmware, xen when system.type=virtual."
quiet: true
- name: Validate hypervisor schema
vars:
hypervisor_allowed_keys: "{{ hypervisor_defaults | dict2items | map(attribute='key') | list }}"
hypervisor_keys: "{{ (hypervisor | default({})) | dict2items | map(attribute='key') | list }}"
hypervisor_unknown_keys: "{{ hypervisor_keys | difference(hypervisor_allowed_keys) }}"
ansible.builtin.assert:
that:
- hypervisor_unknown_keys | length == 0
fail_msg: "Unsupported hypervisor keys: {{ hypervisor_unknown_keys | join(', ') }}"
quiet: true
- name: Validate system schema
vars:
system_allowed_keys: "{{ system_defaults | dict2items | map(attribute='key') | list }}"
system_keys: "{{ (system | default({})) | dict2items | map(attribute='key') | list }}"
system_unknown_keys: "{{ system_keys | difference(system_allowed_keys) }}"
ansible.builtin.assert:
that:
- system_unknown_keys | length == 0
fail_msg: "Unsupported system keys: {{ system_unknown_keys | join(', ') }}."
quiet: true
- name: Validate system sub-dict schemas
loop:
- network
- root
- luks
loop_control:
label: "system.{{ item }}"
vars:
sub_input: "{{ (system[item] | default({})) | dict2items | map(attribute='key') | list }}"
sub_allowed: "{{ system_defaults[item] | dict2items | map(attribute='key') | list }}"
sub_unknown: "{{ sub_input | difference(sub_allowed) }}"
ansible.builtin.assert:
that:
- sub_unknown | length == 0
fail_msg: "Unsupported system.{{ item }} keys: {{ sub_unknown | join(', ') }}"
quiet: true
- name: Validate system.luks.tpm2 schema
vars:
tpm2_input: >-
{{
(system.luks if (system.luks is defined and system.luks is mapping) else {}).tpm2
| default({})
}}
tpm2_allowed_keys: "{{ system_defaults.luks.tpm2 | dict2items | map(attribute='key') | list }}"
tpm2_unknown: "{{ (tpm2_input | dict2items | map(attribute='key') | list) | difference(tpm2_allowed_keys) }}"
ansible.builtin.assert:
that:
- system.luks is not defined or system.luks.tpm2 is not defined or system.luks.tpm2 is mapping
- tpm2_unknown | length == 0
fail_msg: "Unsupported system.luks.tpm2 keys: {{ tpm2_unknown | join(', ') }}"
quiet: true
- name: Validate system.features schema
vars:
features_allowed_keys: "{{ system_defaults.features | dict2items | map(attribute='key') | list }}"
features_unknown: >-
{{
((system.features | default({})) | dict2items | map(attribute='key') | list)
| difference(features_allowed_keys)
}}
ansible.builtin.assert:
that:
- features_unknown | length == 0
fail_msg: "Unsupported system.features keys: {{ features_unknown | join(', ') }}"
quiet: true
- name: Validate system.features leaf schemas
loop: "{{ system_defaults.features | dict2items | selectattr('value', 'mapping') }}"
loop_control:
label: "system.features.{{ item.key }}"
vars:
feature_input: "{{ (system.features | default({}))[item.key] | default({}) }}"
feature_allowed_keys: "{{ item.value | dict2items | map(attribute='key') | list }}"
feature_unknown: "{{ (feature_input | dict2items | map(attribute='key') | list) | difference(feature_allowed_keys) }}"
ansible.builtin.assert:
that:
- feature_input is mapping
- feature_unknown | length == 0
fail_msg: "Unsupported system.features.{{ item.key }} keys: {{ feature_unknown | join(', ') }}"
quiet: true
- name: Validate OS and version inputs
ansible.builtin.assert:
that:
@@ -317,7 +233,7 @@
_autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}"
ansible.builtin.assert:
that:
- _autologin is boolean and not _autologin or (_autologin is string and _autologin | length > 0 and _autologin in system_cfg.users | map(attribute='name'))
- _autologin is boolean and not _autologin or (_autologin is string and _autologin | length > 0 and _autologin in system_cfg.users)
fail_msg: >-
desktop.autologin must be false or a username string present in
system.users; got '{{ _autologin }}'. Bool true is not accepted - the
@@ -354,26 +270,12 @@
quiet: true
- name: Validate at least one user with a password is defined
vars:
_pw_users: "{{ system_cfg.users | selectattr('password', 'defined') | map(attribute='password') | map('string') | reject('equalto', '') | list }}"
ansible.builtin.assert:
that:
- system_cfg.users | default([]) | length > 0
- _pw_users | length > 0
fail_msg: "At least one user with a non-empty password must be defined in system.users."
quiet: true
no_log: true
- name: Validate DNS servers is a list
when:
- system_cfg.network.dns.servers is defined
- system_cfg.network.dns.servers | length > 0
ansible.builtin.assert:
that:
- system_cfg.network.dns.servers is iterable
- system_cfg.network.dns.servers is not string
- system_cfg.network.dns.servers is not mapping
fail_msg: "system.network.dns.servers must be a list."
- bootstrap_primary_user | length > 0
fail_msg: >-
At least one user in system.users needs a non-empty password: bootstrap logs in
as the first such user after the reboot.
quiet: true
- name: Validate all virtual disks have a positive size
@@ -467,13 +369,10 @@
loop_control:
label: "{{ item | to_json }}"
# A string here would iterate into one address per character in every network renderer.
- name: Validate network interfaces extra_addresses
when: item.extra_addresses is defined
ansible.builtin.assert:
that:
- item.extra_addresses is not string
- item.extra_addresses is iterable
- item.ip | default('') | string | length > 0
- item.extra_addresses | reject('match', '^(\\d{1,3}\\.){3}\\d{1,3}/\\d{1,2}$') | list | length == 0
fail_msg: "system.network.interfaces[].extra_addresses must be a list of IPv4 CIDRs (192.0.2.10/24) on an entry that also sets 'ip'."
+2 -2
View File
@@ -35,8 +35,8 @@
{%- endfor -%}
{{ out }}
community.proxmox.proxmox_kvm:
ciuser: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first).key }}"
cipassword: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first).value.password }}"
ciuser: "{{ bootstrap_primary_user.name }}"
cipassword: "{{ bootstrap_primary_user.password }}"
ciupgrade: false
vmid: "{{ system_cfg.id }}"
name: "{{ hostname }}"
@@ -14,8 +14,9 @@ users:
{% elif _sudo | bool %}
sudo: "ALL=(ALL) NOPASSWD:ALL"
{% endif %}
{% if attrs.password | default('') | length > 0 %}
passwd: "{{ attrs.password | password_hash('sha512') }}"
{% set _pw = attrs.password | default('') %}
{% if _pw | length > 0 %}
passwd: "{{ _pw if (_pw | string)[:1] == '$' else _pw | password_hash('sha512') }}"
lock_passwd: false
{% else %}
lock_passwd: true
+1 -1
View File
@@ -34,4 +34,4 @@ merge_fixtures:
disable: [nvidia, intel] # drop the nvidia GPU and every intel-vendor contribution
expect_profile: { fingerprint: false, bluetooth: false }
expect_contains: [mesa, vulkan-icd-loader]
expect_excludes: [nvidia-open-dkms, intel-ucode, vulkan-intel, sof-firmware, linux-firmware-other]
expect_excludes: [nvidia-open-dkms, intel-ucode, vulkan-intel, sof-firmware, linux-firmware-intel]
+1 -1
View File
@@ -5,7 +5,7 @@ package_fixtures:
- name: arch-intel-audio-bt-fp
os_family: Archlinux
profile: { cpu: intel, gpus: [intel], wireless: [intel], audio: [intel], fingerprint: true, bluetooth: true, camera: { uvc: true, ipu6: false } }
expect_contains: [intel-ucode, linux-firmware-other, mesa, vulkan-icd-loader, vulkan-intel, sof-firmware, alsa-ucm-conf, bluez, bluez-utils, fprintd, libfprint, v4l-utils]
expect_contains: [intel-ucode, linux-firmware-intel, mesa, vulkan-icd-loader, vulkan-intel, sof-firmware, alsa-ucm-conf, bluez, bluez-utils, fprintd, libfprint, v4l-utils]
expect_excludes: [nvidia-open-dkms, evdi]
- name: debian-amd-audio-no-bt-no-fp
+22
View File
@@ -0,0 +1,22 @@
---
- name: "Validate the fixture ({{ inf.name }})"
block:
- name: "Run the validator ({{ inf.name }})"
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_spec.yml"
vars:
fixture: "{{ inf.value }}"
register: inputs_result
rescue:
- name: "Keep the failed result ({{ inf.name }})"
ansible.builtin.set_fact:
inputs_result: "{{ ansible_failed_result }}"
- name: "Assert the reported violations ({{ inf.name }})"
ansible.builtin.assert:
that:
- (inputs_result.errors | default([])) | sort == inf.expect | sort
- "'topsecret-value' not in (inputs_result | to_json)"
fail_msg: "[inputs {{ inf.name }}] expected {{ inf.expect }}, got {{ inputs_result.errors | default([]) }}"
success_msg: "[inputs {{ inf.name }}] OK"
+47
View File
@@ -0,0 +1,47 @@
---
argument_specs:
inputs:
options:
fixture:
type: dict
options:
name:
type: str
required: true
enabled:
type: bool
count:
type: int
servers:
type: list
elements: str
kind:
type: str
choices: [a, b]
secret:
type: str
no_log: true
gate:
type: raw
options:
port:
type: int
disks:
type: list
elements: dict
options:
size:
type: int
required: true
users:
type: dict
options:
"<user>":
type: dict
options:
password:
type: str
no_log: true
keys:
type: list
elements: str
+18
View File
@@ -0,0 +1,18 @@
---
# Vaulted with a discarded password: the validator must handle a value it cannot decrypt.
fixture:
name: web01
secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
62363835313830363035333361613236373662303039376639396136336438616331336565393631
6637376163346430386663393661386561316537306662350a663563326164376164396230616361
33646564346532643735396239626133333162353839666135343030643836313938363466383439
3130353636666365310a353064373139323836373163336563353861356333316534386238333664
3663
servers: !vault |
$ANSIBLE_VAULT;1.1;AES256
65626131373433633432363261356534613135386461626535393539656631323531346661643233
3961366535643630386462303038636231373239343130310a343565336461633532316136643139
30306461616232663763633630353661376130323536346637633964373239343664303533396236
6165623262633131330a303965643439626335663130376237393362363231346537643334633163
3835
+122
View File
@@ -0,0 +1,122 @@
---
inputs_fixtures:
- name: valid
value:
name: web01
enabled: true
count: "3"
servers: [10.0.0.1, 10.0.0.2]
kind: a
disks:
- size: 30
users:
alice:
password: pw
keys: [ssh-ed25519 AAAA alice]
retired: ~
expect: []
- name: empty-int-is-unset
value:
name: web01
count: ""
expect: []
- name: list-for-dict
value:
name: web01
users:
- name: alice
expect: ["fixture.users: expected dict keyed by name, got list"]
- name: comma-string-for-list
value:
name: web01
servers: 10.0.0.1,10.0.0.2
expect: ["fixture.servers: expected list, got str"]
- name: yes-string-for-bool
value:
name: web01
enabled: "yes"
expect: ["fixture.enabled: expected bool, got str"]
- name: typo-key
value:
name: web01
userz: {}
expect: ["fixture.userz: unsupported key, fixture accepts count, disks, enabled, gate, kind, name, secret, servers, users"]
- name: wrong-element-type
value:
name: web01
disks:
- size: 30
- size: big
expect: ["fixture.disks[1].size: expected int, got str"]
- name: missing-required
value:
enabled: false
expect: ["fixture.name: required key is missing"]
- name: choice-mismatch
value:
name: web01
kind: c
expect: ["fixture.kind: must be one of a, b, got 'c'"]
- name: secrets-never-echoed
value:
name: web01
secret: [topsecret-value]
users:
alice:
password: [topsecret-value]
expect:
- "fixture.secret: expected str, got list"
- "fixture.users.alice.password: expected str, got list"
- name: keyed-entry-not-a-dict
value:
name: web01
users:
alice: pw
expect: ["fixture.users.alice: expected dict, got str"]
- name: two-violations-at-once
value:
name: web01
enabled: "true"
userz: {}
expect:
- "fixture.userz: unsupported key, fixture accepts count, disks, enabled, gate, kind, name, secret, servers, users"
- "fixture.enabled: expected bool, got str"
- name: raw-gate-false
value:
name: web01
gate: false
expect: []
- name: raw-gate-dict-checked
value:
name: web01
gate:
port: http
portz: 1
expect:
- "fixture.gate.portz: unsupported key, fixture.gate accepts port"
- "fixture.gate.port: expected int, got str"
- name: raw-gate-true
value:
name: web01
gate: true
expect: []
- name: raw-gate-string-rejected
value:
name: web01
gate: "yes"
expect: ["fixture.gate: expected bool or dict, got str"]
+108
View File
@@ -0,0 +1,108 @@
---
# Run: ansible-playbook tests/inputs/test_inputs.yml
- name: Strict input validator fixture tests
hosts: localhost
gather_facts: false
connection: local
vars_files:
- inputs_fixtures.yml
tasks:
- name: Run each input fixture
ansible.builtin.include_tasks: _assert_inputs.yml
loop: "{{ inputs_fixtures }}"
loop_control:
loop_var: inf
label: "{{ inf.name }}"
- name: Expand a spec pattern
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_*.yml"
vars:
fixture:
name: web01
servers: [10.0.0.1]
register: inputs_pattern_result
- name: Assert the pattern matched the fixture spec
ansible.builtin.assert:
that:
- inputs_pattern_result is succeeded
success_msg: "[inputs pattern] OK"
- name: Refuse a pattern that matches nothing
block:
- name: Run the validator with a pattern that matches no file
validate_inputs:
specs:
- "{{ playbook_dir }}/nothing_*.yml"
register: inputs_nomatch_result
rescue:
- name: Keep the no-match failure
ansible.builtin.set_fact:
inputs_nomatch_result: "{{ ansible_failed_result }}"
- name: Assert the empty pattern was refused
ansible.builtin.assert:
that:
- inputs_nomatch_result.msg is search('no spec file matches')
fail_msg: "[inputs no-match] got {{ inputs_nomatch_result.msg | default('') }}"
success_msg: "[inputs no-match] OK"
- name: Refuse a spec list given as a string
block:
- name: Run the validator with specs as a string
validate_inputs:
specs: "{{ playbook_dir }}/fixture_spec.yml"
register: inputs_string_result
rescue:
- name: Keep the string-specs failure
ansible.builtin.set_fact:
inputs_string_result: "{{ ansible_failed_result }}"
- name: Assert the string was refused
ansible.builtin.assert:
that:
- inputs_string_result.msg is search('specs must be a list')
fail_msg: "[inputs string] got {{ inputs_string_result.msg | default('') }}"
success_msg: "[inputs string] OK"
- name: Refuse a variable declared by two specs
block:
- name: Run the validator with the fixture spec twice
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_spec.yml"
- "{{ playbook_dir }}/fixture_spec.yml"
register: inputs_duplicate_result
rescue:
- name: Keep the duplicate-declaration failure
ansible.builtin.set_fact:
inputs_duplicate_result: "{{ ansible_failed_result }}"
- name: Assert the duplicate declaration was refused
ansible.builtin.assert:
that:
- inputs_duplicate_result.msg is search('fixture is declared by both')
fail_msg: "[inputs duplicate] got {{ inputs_duplicate_result.msg | default('') }}"
success_msg: "[inputs duplicate] OK"
- name: Validate a host var holding values that cannot be decrypted
block:
- name: Run the validator on the vaulted host var
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_spec.yml"
register: inputs_vault_result
rescue:
- name: Keep the failed vault result
ansible.builtin.set_fact:
inputs_vault_result: "{{ ansible_failed_result }}"
- name: Assert a vaulted string passes and a vaulted non-list is reported
ansible.builtin.assert:
that:
- >-
inputs_vault_result.errors | default([]) == ['fixture.servers: expected list, got str']
fail_msg: "[inputs vault] got {{ inputs_vault_result.errors | default([]) }}"
success_msg: "[inputs vault] OK"
+62
View File
@@ -0,0 +1,62 @@
---
# Run: ansible-playbook tests/users/test_users.yml
# One host per fixture: set_fact state cannot be cleared between loop iterations.
- name: Register one host per users fixture
hosts: localhost
gather_facts: false
connection: local
vars_files:
- users_fixtures.yml
tasks:
- name: Add the fixture host
ansible.builtin.add_host:
name: "{{ uf.name }}"
groups: users_fixtures
ansible_connection: local
ansible_python_interpreter: "{{ ansible_playbook_python }}"
expect: "{{ uf.expect }}"
system: "{{ _system }}"
system_cfg: "{{ _system if uf.precomputed | default(false) else omit }}"
vars:
_system: "{{ users_fixture_system | combine(uf.system, {'name': uf.name}, recursive=True) }}"
loop: "{{ users_fixtures }}"
loop_control:
loop_var: uf
label: "{{ uf.name }}"
- name: Run global_defaults against each users fixture
hosts: users_fixtures
gather_facts: false
vars:
hypervisor:
type: libvirt
url: qemu:///system
storage: default
boot_iso: /var/lib/libvirt/images/fixture.iso
tasks:
- name: Normalize and validate
block:
- name: Load global defaults
ansible.builtin.include_role:
name: global_defaults
- name: Record the pass
ansible.builtin.set_fact:
users_outcome: pass
rescue:
- name: Record the failure
ansible.builtin.set_fact:
users_outcome: >-
{{ ([ansible_failed_result.msg | default('')]
+ (ansible_failed_result.results | default([]) | map(attribute='msg', default='') | list))
| join(' ') }}
- name: Assert the expected outcome
ansible.builtin.assert:
that:
- >-
(users_outcome == 'pass' and bootstrap_primary_user.name == expect.primary)
if expect.primary is defined
else (expect.fail in users_outcome)
fail_msg: "[{{ inventory_hostname }}] expected {{ expect }}, got: {{ users_outcome }}"
success_msg: "[{{ inventory_hostname }}] OK"
+123
View File
@@ -0,0 +1,123 @@
---
users_fixture_system:
type: virtual
os: debian
version: "13"
cpus: 2
memory: 2048
disks:
- size: 30
network:
interfaces:
- bridge: br0
ip: 192.0.2.10
prefix: 24
gateway: 192.0.2.1
root:
password: rootpw
# precomputed: hand system_cfg over the way deploy_iac does, skipping the raw input checks.
users_fixtures:
- name: dict-first-password-wins
system:
users:
keyonly:
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture keyonly
blank:
password: ""
ops:
password: pw
sudo: true
later:
password: pw2
expect:
primary: ops
- name: dict-first-password-wins-precomputed
precomputed: true
system:
users:
keyonly:
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture keyonly
ops:
password: pw
expect:
primary: ops
- name: list-rejected
system:
users:
- name: ops
password: pw
expect:
fail: "system.users: expected dict keyed by name, got list"
- name: list-rejected-precomputed
precomputed: true
system:
users:
- name: ops
password: pw
expect:
fail: "system.users: expected dict keyed by name, got list"
- name: scalar-entry-rejected
system:
users:
ops: pw
expect:
fail: "system.users.ops: expected dict, got str"
- name: scalar-entry-rejected-precomputed
precomputed: true
system:
users:
ops: pw
expect:
fail: "system.users.ops: expected dict, got str"
- name: keys-string-rejected
system:
users:
ops:
password: pw
keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops
expect:
fail: "system.users.ops.keys: expected list, got str"
- name: no-password-rejected
system:
users:
ops:
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops
expect:
fail: needs a non-empty password
- name: autologin-user-accepted
system:
users:
ops:
password: pw
features:
desktop:
enabled: true
environment: gnome
autologin: ops
expect:
primary: ops
- name: autologin-unknown-user-rejected
system:
users:
ops:
password: pw
features:
desktop:
enabled: true
environment: gnome
autologin: ghost
expect:
fail: desktop.autologin must be false or a username