Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

* **plugin:nextcloud_occ_app_config, plugin:nextcloud_occ_system_config, plugin:uptimerobot_monitor, plugin:uptimerobot_psp**: Fixed their documentation so `ansible-doc` renders them again. A unit-test guard now catches this class of error for every in-house plugin.
* **plugin:bitwarden_item**: Fixed the lookup's documentation so `ansible-doc` renders it again.
* **plugin:combine_lod**: The `combine_lod` filter now reports an error when an item is missing part of a composite `unique_key` (a list of keys), instead of silently grouping such items together. Inventories with incomplete composite keys that previously merged by accident now fail loudly and must be corrected. Also fixed its documentation so `ansible-doc` renders it again.
* **role:kernel_settings**: The `systemd_cpu_affinity` setting is now actually applied. The value was computed and shown in the debug output but never passed to the underlying system role, so a configured CPU affinity had no effect.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,7 @@ In-house plugins live under `plugins/` following the standard Ansible collection
```

* Use single quotes and f-strings consistently (vendored plugins keep their upstream style, see below).
* Every plugin carries `DOCUMENTATION` (and `RETURN` / `EXAMPLES` where applicable). Keep it valid YAML: in a `description` list, a bullet containing a colon followed by a space is parsed as a mapping and makes `ansible-doc` fail, so rephrase or quote such bullets. Verify with `ansible-doc -t <filter|lookup|module> linuxfabrik.lfops.<name>`.
* Every plugin carries `DOCUMENTATION` (and `RETURN` / `EXAMPLES` where applicable). Keep it valid YAML: in a `description` list, a bullet containing a colon followed by a space is parsed as a mapping and makes `ansible-doc` fail, so rephrase or quote such bullets. Verify with `ansible-doc -t <filter|lookup|module> linuxfabrik.lfops.<name>`; `tests/unit/test_plugin_docs.py` guards against this class of error for all in-house plugins.
* Set `version_added` to the LFOps release the plugin first shipped in, and never change it afterwards.
* `module_utils` holds code shared between plugins. Do not import the external Linuxfabrik Python Libraries (`lib`) into a plugin; copy what you need and note the origin in a comment.

Expand Down
2 changes: 1 addition & 1 deletion plugins/modules/nextcloud_occ_app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
- Drives C(occ config:app:set) and C(config:app:delete) to bring a single app config key into the desired state.
- The current value and type are read from C(occ config:app:get --details --output=json) (or from a pre-fetched C(occ config:list --output=json --private) listing passed via I(installed_config_json)). C(occ config:app:set) is only called when the stored value or type does not already match.
- When I(name) contains spaces, each whitespace-separated token is passed as a separate argument to C(occ), matching how Nextcloud addresses nested keys (e.g. C(name="endpoint enabled")).
- Booleans are normalized for Nextcloud's storage: I(value) values C(true)/C(1)/C(on)/C(yes) (case-insensitive) become C(1) in the database; everything else becomes C(0). When reading via I(installed_config_json), the type is inferred from the JSON value type (Python C(bool)/C(int)/C(float)/C(list)/C(str)), since C(occ config:list) returns values already cast by C(convertTypedValue()).
- Booleans are normalized for Nextcloud's storage. I(value) values C(true)/C(1)/C(on)/C(yes) (case-insensitive) become C(1) in the database; everything else becomes C(0). When reading via I(installed_config_json), the type is inferred from the JSON value type (Python C(bool)/C(int)/C(float)/C(list)/C(str)), since C(occ config:list) returns values already cast by C(convertTypedValue()).

requirements:
- A working Nextcloud installation with the C(occ) command available.
Expand Down
2 changes: 1 addition & 1 deletion plugins/modules/nextcloud_occ_system_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
- Drives C(occ config:system:set) and C(config:system:delete) to bring a single system config key into the desired state.
- The current value is read from C(occ config:system:get) (or from a pre-fetched C(occ config:list --output=json --private) listing passed via I(installed_config_json)). C(occ config:system:set) is only called when the stored value does not already match I(value).
- When I(name) contains spaces, each whitespace-separated token is passed as a separate argument to C(occ), matching how Nextcloud addresses nested keys (e.g. C(name="trusted_domains 0"), C(name="forbidden_filename_characters 0")).
- Booleans are normalized for C(occ): I(value) values C(true)/C(1)/C(on)/C(yes) (case-insensitive) become the literal string C(true); everything else becomes C(false). This matches what Nextcloud's CastHelper accepts on C(config:system:set). Note that this differs from C(nextcloud_occ_app_config), which stores booleans as C(1)/C(0).
- Booleans are normalized for C(occ). I(value) values C(true)/C(1)/C(on)/C(yes) (case-insensitive) become the literal string C(true); everything else becomes C(false). This matches what Nextcloud's CastHelper accepts on C(config:system:set). Note that this differs from C(nextcloud_occ_app_config), which stores booleans as C(1)/C(0).

requirements:
- A working Nextcloud installation with the C(occ) command available.
Expand Down
4 changes: 2 additions & 2 deletions plugins/modules/uptimerobot_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
alert_contacts:
description:
- Alert contacts to attach to the monitor. Each item references an existing alert contact, plus the per-monitor I(threshold) and I(recurrence). The list is replaced on every run; pass an empty list to clear all attached contacts.
- Resolution: when an item has I(id), it is used directly. Otherwise I(friendly_name) is resolved against C(getAlertContacts); an unknown name fails the play.
- When an item has I(id), it is used directly. Otherwise I(friendly_name) is resolved against C(getAlertContacts); an unknown name fails the play.
type: list
elements: dict
required: false
Expand All @@ -189,7 +189,7 @@
mwindows:
description:
- Maintenance windows to attach to the monitor. Each item references an existing maintenance window. The list is replaced on every run; pass an empty list to detach all windows.
- Resolution: when an item has I(id), it is used directly. Otherwise I(friendly_name) is resolved against C(getMWindows); an unknown name fails the play.
- When an item has I(id), it is used directly. Otherwise I(friendly_name) is resolved against C(getMWindows); an unknown name fails the play.
type: list
elements: dict
required: false
Expand Down
2 changes: 1 addition & 1 deletion plugins/modules/uptimerobot_psp.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
monitors:
description:
- Monitors to display on the status page. Each item references an existing monitor.
- Resolution: when an item has I(id), it is used directly. Otherwise I(friendly_name) is resolved against C(getMonitors); an unknown name fails the play.
- When an item has I(id), it is used directly. Otherwise I(friendly_name) is resolved against C(getMonitors); an unknown name fails the play.
type: list
elements: dict
suboptions:
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/test_plugin_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author: Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
# https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

"""Guard against the DOCUMENTATION YAML bug that breaks `ansible-doc`.

In a `description` block (a YAML list), a bullet that contains a colon
followed by a space is parsed as a mapping instead of a string, e.g.

description:
- Useful for X: it does Y. # parsed as {"Useful for X": "it does Y."}

`ansible-doc` then aborts with "expected str instance, AnsibleMapping
found". This test parses every in-house plugin's DOCUMENTATION (and
RETURN) and asserts that every `description` is a string or a list of
strings, catching the bug at unit-test time instead of at render time.

Vendored plugins keep their upstream docs and are out of scope.
"""

from __future__ import absolute_import, division, print_function

__metaclass__ = type

import ast
import glob
import os
import unittest

import yaml

_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_PLUGIN_GLOBS = [
'plugins/filter/*.py',
'plugins/lookup/*.py',
'plugins/modules/*.py',
]
# Vendored plugins are kept in lockstep with upstream and not restyled here.
_VENDORED_PREFIXES = ('ipa',)
_VENDORED_NAMES = {'lvm_pv.py'}


def _in_house_plugin_files():
files = []
for pattern in _PLUGIN_GLOBS:
for path in glob.glob(os.path.join(_REPO_ROOT, pattern)):
name = os.path.basename(path)
if name.startswith(_VENDORED_PREFIXES) or name in _VENDORED_NAMES:
continue
files.append(path)
return sorted(files)


def _extract_doc_constants(source):
"""Return {const_name: yaml_obj} for DOCUMENTATION/RETURN string assignments."""
tree = ast.parse(source)
docs = {}
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
for wanted in ('DOCUMENTATION', 'RETURN'):
if wanted in names and isinstance(node.value, ast.Constant) \
and isinstance(node.value.value, str):
docs[wanted] = yaml.safe_load(node.value.value)
return docs


def _iter_description_problems(obj, path=''):
"""Yield human-readable paths where a `description` is not str / list[str]."""
if isinstance(obj, dict):
for key, value in obj.items():
if key == 'description':
if isinstance(value, str):
pass
elif isinstance(value, list):
for i, item in enumerate(value):
if not isinstance(item, str):
yield f'{path}.description[{i}] is {type(item).__name__}, expected str'
else:
yield f'{path}.description is {type(value).__name__}, expected str or list[str]'
yield from _iter_description_problems(value, f'{path}.{key}')
elif isinstance(obj, list):
for i, item in enumerate(obj):
yield from _iter_description_problems(item, f'{path}[{i}]')


class TestPluginDocs(unittest.TestCase):

def test_in_house_plugins_have_renderable_descriptions(self):
files = _in_house_plugin_files()
self.assertTrue(files, 'no in-house plugin files found')
for path in files:
with self.subTest(plugin=os.path.relpath(path, _REPO_ROOT)):
with open(path, 'r') as f:
source = f.read()
docs = _extract_doc_constants(source)
problems = []
for const_name, doc in docs.items():
problems += [f'{const_name}{p}' for p in _iter_description_problems(doc)]
self.assertEqual(
problems, [],
'description fields must be str or list[str] '
'(a colon + space in a bullet makes ansible-doc fail):\n '
+ '\n '.join(problems),
)


if __name__ == '__main__':
unittest.main()