Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7253f6af96 | ||
|
|
2d1a875577 | ||
|
|
f0b580cd3d |
No files matched your search
@@ -11,3 +11,4 @@ vars_proxmox.yml
|
|||||||
.sisyphus/
|
.sisyphus/
|
||||||
|
|
||||||
.ansible/
|
.ansible/
|
||||||
|
__pycache__/
|
||||||
@@ -238,6 +238,11 @@ system:
|
|||||||
| `password` | string | -- | User password (required for at least one user) |
|
| `password` | string | -- | User password (required for at least one user) |
|
||||||
| `keys` | list | `[]` | SSH public keys |
|
| `keys` | list | `[]` | SSH public keys |
|
||||||
| `sudo` | bool/string | -- | `true` for NOPASSWD ALL, or custom sudoers string |
|
| `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`.
|
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) |
|
| `size` | number | Disk size in GB (required for virtual) |
|
||||||
| `device` | string | Block device path (required for physical data disks) |
|
| `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) |
|
| `partition` | string | Derived from `device` during normalization (not user input) |
|
||||||
| `mount.path` | string | Mount point (additional disks only) |
|
| `mount.path` | string | Mount point (additional disks only) |
|
||||||
| `mount.fstype`| string | `btrfs`, `ext4`, or `xfs` |
|
| `mount.fstype`| string | `btrfs`, `ext4`, or `xfs` |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[defaults]
|
[defaults]
|
||||||
hash_behaviour = merge
|
hash_behaviour = merge
|
||||||
roles_path = roles
|
roles_path = roles
|
||||||
|
action_plugins = plugins/action
|
||||||
interpreter_python = auto_silent
|
interpreter_python = auto_silent
|
||||||
deprecation_warnings = False
|
deprecation_warnings = False
|
||||||
host_key_checking = False
|
host_key_checking = False
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""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 named ``<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. Messages name the key path
|
||||||
|
and the types involved but never a value, so secrets cannot leak into job output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from ansible.errors import AnsibleError
|
||||||
|
from ansible.parsing.vault import EncryptedString
|
||||||
|
from ansible.plugins.action import ActionBase
|
||||||
|
|
||||||
|
KEYED = "<name>"
|
||||||
|
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. 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.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 not TYPES[kind](value):
|
||||||
|
self.errors.append(f"{path}: expected {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}")
|
||||||
|
if kind == "dict" and "options" in spec:
|
||||||
|
self.check_options(spec["options"], value, path)
|
||||||
|
elif kind == "list" and "elements" in spec:
|
||||||
|
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 set(options) == {KEYED}:
|
||||||
|
for name, entry in value.items():
|
||||||
|
if entry is not None:
|
||||||
|
self.check(options[KEYED], entry, f"{path}.{name}")
|
||||||
|
return
|
||||||
|
for key in value:
|
||||||
|
if key not in options:
|
||||||
|
self.errors.append(f"{path}.{key}: unsupported key, {path} accepts {', '.join(sorted(options))}")
|
||||||
|
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)
|
||||||
|
for path in self._task.args["specs"]:
|
||||||
|
options = self._loader.load_from_file(path)["argument_specs"]["inputs"]["options"]
|
||||||
|
_check_spec(options, path)
|
||||||
|
for name, spec in options.items():
|
||||||
|
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,248 @@
|
|||||||
|
---
|
||||||
|
# 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}
|
||||||
|
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: str}
|
||||||
|
cpus: {type: int}
|
||||||
|
memory: {type: int}
|
||||||
|
balloon: {type: int}
|
||||||
|
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: str}
|
||||||
|
ip: {type: str}
|
||||||
|
prefix: {type: str}
|
||||||
|
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: str}
|
||||||
|
ip: {type: str}
|
||||||
|
prefix: {type: str}
|
||||||
|
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: float}
|
||||||
|
device: {type: str}
|
||||||
|
storage: {type: str}
|
||||||
|
format: {type: str}
|
||||||
|
bus: {type: str}
|
||||||
|
slot: {type: str}
|
||||||
|
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
|
- name: Build normalized system configuration
|
||||||
vars:
|
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_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_os_input: "{{ system_raw.os | default('') | string | lower }}"
|
||||||
system_name: >-
|
system_name: >-
|
||||||
|
|||||||
@@ -1,43 +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.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) 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 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
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
---
|
---
|
||||||
# Normalizes all input dicts into system_cfg/hypervisor_cfg/etc. here, so downstream
|
# 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).
|
# 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
|
- name: Global defaults loaded
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: Global defaults loaded.
|
msg: Global defaults loaded.
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
- name: Normalize system and disk configuration
|
- name: Normalize system and disk configuration
|
||||||
when: system_cfg is not defined
|
when: system_cfg is not defined
|
||||||
block:
|
block:
|
||||||
- name: Validate raw system input types
|
|
||||||
ansible.builtin.include_tasks: _validate_input.yml
|
|
||||||
|
|
||||||
- name: Normalize system configuration
|
- name: Normalize system configuration
|
||||||
ansible.builtin.include_tasks: _normalize_system.yml
|
ansible.builtin.include_tasks: _normalize_system.yml
|
||||||
|
|
||||||
|
|||||||
@@ -26,111 +26,6 @@
|
|||||||
fail_msg: "hypervisor.type must be one of: libvirt, proxmox, vmware, xen when system.type=virtual."
|
fail_msg: "hypervisor.type must be one of: libvirt, proxmox, vmware, xen when system.type=virtual."
|
||||||
quiet: true
|
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.users is a dict keyed by username
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- system_cfg.users is mapping
|
|
||||||
fail_msg: >-
|
|
||||||
system.users must be a dict keyed by username, e.g. users: {alice: {password: ...,
|
|
||||||
keys: [...], sudo: true}}. A list of {name: ...} entries is not accepted.
|
|
||||||
quiet: true
|
|
||||||
|
|
||||||
- name: Validate system.users entries
|
|
||||||
loop: "{{ system_cfg.users | dict2items }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.key }}"
|
|
||||||
ansible.builtin.assert:
|
|
||||||
that:
|
|
||||||
- item.key | string | length > 0
|
|
||||||
- item.value is mapping
|
|
||||||
- ('keys' not in item.value) or (item.value['keys'] is iterable and item.value['keys'] is not string)
|
|
||||||
fail_msg: "system.users.{{ item.key }} must be a dict, and its keys a list of SSH public keys."
|
|
||||||
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
|
- name: Validate OS and version inputs
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
@@ -383,18 +278,6 @@
|
|||||||
as the first such user after the reboot.
|
as the first such user after the reboot.
|
||||||
quiet: true
|
quiet: 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."
|
|
||||||
quiet: true
|
|
||||||
|
|
||||||
- name: Validate all virtual disks have a positive size
|
- name: Validate all virtual disks have a positive size
|
||||||
when: system_cfg.type == "virtual"
|
when: system_cfg.type == "virtual"
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
@@ -486,13 +369,10 @@
|
|||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item | to_json }}"
|
label: "{{ item | to_json }}"
|
||||||
|
|
||||||
# A string here would iterate into one address per character in every network renderer.
|
|
||||||
- name: Validate network interfaces extra_addresses
|
- name: Validate network interfaces extra_addresses
|
||||||
when: item.extra_addresses is defined
|
when: item.extra_addresses is defined
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
- item.extra_addresses is not string
|
|
||||||
- item.extra_addresses is iterable
|
|
||||||
- item.ip | default('') | string | length > 0
|
- item.ip | default('') | string | length > 0
|
||||||
- item.extra_addresses | reject('match', '^(\\d{1,3}\\.){3}\\d{1,3}/\\d{1,2}$') | list | 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'."
|
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'."
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
disks:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
options:
|
||||||
|
size:
|
||||||
|
type: int
|
||||||
|
required: true
|
||||||
|
users:
|
||||||
|
type: dict
|
||||||
|
options:
|
||||||
|
"<name>":
|
||||||
|
type: dict
|
||||||
|
options:
|
||||||
|
password:
|
||||||
|
type: str
|
||||||
|
no_log: true
|
||||||
|
keys:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
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: list-for-dict
|
||||||
|
value:
|
||||||
|
name: web01
|
||||||
|
users:
|
||||||
|
- name: alice
|
||||||
|
expect: ["fixture.users: expected dict, 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, 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, kind, name, secret, servers, users"
|
||||||
|
- "fixture.enabled: expected bool, got str"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
# 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: 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"
|
||||||
@@ -52,7 +52,7 @@ users_fixtures:
|
|||||||
- name: ops
|
- name: ops
|
||||||
password: pw
|
password: pw
|
||||||
expect:
|
expect:
|
||||||
fail: system.users must be a dict keyed by username
|
fail: "system.users: expected dict, got list"
|
||||||
|
|
||||||
- name: list-rejected-precomputed
|
- name: list-rejected-precomputed
|
||||||
precomputed: true
|
precomputed: true
|
||||||
@@ -61,14 +61,14 @@ users_fixtures:
|
|||||||
- name: ops
|
- name: ops
|
||||||
password: pw
|
password: pw
|
||||||
expect:
|
expect:
|
||||||
fail: system.users must be a dict keyed by username
|
fail: "system.users: expected dict, got list"
|
||||||
|
|
||||||
- name: scalar-entry-rejected
|
- name: scalar-entry-rejected
|
||||||
system:
|
system:
|
||||||
users:
|
users:
|
||||||
ops: pw
|
ops: pw
|
||||||
expect:
|
expect:
|
||||||
fail: system.users.ops must be a dict
|
fail: "system.users.ops: expected dict, got str"
|
||||||
|
|
||||||
- name: scalar-entry-rejected-precomputed
|
- name: scalar-entry-rejected-precomputed
|
||||||
precomputed: true
|
precomputed: true
|
||||||
@@ -76,7 +76,7 @@ users_fixtures:
|
|||||||
users:
|
users:
|
||||||
ops: pw
|
ops: pw
|
||||||
expect:
|
expect:
|
||||||
fail: system.users.ops must be a dict
|
fail: "system.users.ops: expected dict, got str"
|
||||||
|
|
||||||
- name: keys-string-rejected
|
- name: keys-string-rejected
|
||||||
system:
|
system:
|
||||||
@@ -85,7 +85,7 @@ users_fixtures:
|
|||||||
password: pw
|
password: pw
|
||||||
keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops
|
keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops
|
||||||
expect:
|
expect:
|
||||||
fail: its keys a list of SSH public keys
|
fail: "system.users.ops.keys: expected list, got str"
|
||||||
|
|
||||||
- name: no-password-rejected
|
- name: no-password-rejected
|
||||||
system:
|
system:
|
||||||
|
|||||||
Reference in new issue
Block a user