mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
WORDPRESS_OPTIONS now merges the role's own contributions (admin_email from the platform administrator, blogname from the design title while the design service is on, the freedomtranslate-wp config.options while that addon is on) under services.wordpress.options, so the inventory still wins and wordpress_options_checked guards the merged map. design.php no longer writes blogname, the addon task no longer writes its options, and WORDPRESS_OPTIONS_OWNED_ELSEWHERE drops the three. Every deploy now converges admin_email, and blogname while the design is on; a write WP-CLI reports as unchanged no longer counts as a change, and the FreedomTranslate readback runs after the options write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
483 lines
18 KiB
Python
483 lines
18 KiB
Python
"""``WORDPRESS_OPTIONS`` is the one map the role pins WordPress options from.
|
|
|
|
The map joins the role's contributions (``admin_email``, the design title as
|
|
``blogname``, the FreedomTranslate options) with ``services.wordpress.options``
|
|
as the strict config lookup resolves it, so the role default and the
|
|
inventory both win over a contribution. Every option another path writes is
|
|
refused by name, and the tasks in ``tasks/07_manager_ops.yml`` read each
|
|
option, write only the ones that drift and flush the rewrite rules after a
|
|
write that changed the stored value.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
from ansible.errors import AnsibleError
|
|
from ansible.parsing.dataloader import DataLoader
|
|
from ansible.plugins.loader import filter_loader, lookup_loader
|
|
from ansible.plugins.lookup import LookupBase
|
|
from ansible.template import Templar
|
|
|
|
from plugins.lookup.config import LookupModule
|
|
from utils.cache import _reset_cache_for_tests
|
|
from utils.cache import base as cache_base
|
|
from utils.cache.files import iter_project_files_with_content, read_text
|
|
from utils.cache.yaml import load_yaml_any
|
|
from utils.roles.applications.config import AppConfigKeyError
|
|
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_VARS_MAIN
|
|
from utils.templating.ansible import _trust_as_template
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
ROLE = PROJECT_ROOT / "roles/web-app-wordpress"
|
|
PROBE_APP = "web-app-options-probe"
|
|
PINNED_STRUCTURE = "/blog/%year%/%monthnum%/%day%/%postname%/"
|
|
OPTIONS_LOOKUP = re.compile(r"lookup\(([^()]*'\.options')\)")
|
|
LITERAL_OPTION_WRITE = re.compile(
|
|
r"wp option (?:update|add) ([a-z_][a-z0-9_]*)\b"
|
|
r"|update_option\(\s*'([a-z_][a-z0-9_]*)'"
|
|
)
|
|
ADMIN_EMAIL = "admin@example.test"
|
|
DESIGN_TITLE = "Blog by Example & Partner"
|
|
DISCOURSE_OPTION = "discourse_connect"
|
|
TRANSLATE_ADDON = ROLE / "meta/addons/freedomtranslate-wp.yml"
|
|
TRANSLATE_TASKS = ROLE / "tasks/addons/freedomtranslate-wp-stack-host.yml"
|
|
|
|
filter_loader.add_directory(str(ROLE / "filter_plugins"))
|
|
|
|
|
|
def _vars() -> dict:
|
|
return load_yaml_any(str(ROLE / ROLE_FILE_VARS_MAIN), default_if_missing={})
|
|
|
|
|
|
def _manager_tasks() -> list[dict]:
|
|
return load_yaml_any(str(ROLE / "tasks/07_manager_ops.yml"), default_if_missing=[])
|
|
|
|
|
|
def _task(fragment: str) -> dict:
|
|
return next(t for t in _manager_tasks() if fragment in t["name"])
|
|
|
|
|
|
def _position(fragment: str) -> int:
|
|
return next(i for i, t in enumerate(_manager_tasks()) if fragment in t["name"])
|
|
|
|
|
|
def _render(expression: str, **variables):
|
|
templar = Templar(loader=DataLoader(), variables=variables)
|
|
return templar.template(_trust_as_template(expression))
|
|
|
|
|
|
def _translate_options() -> list[dict]:
|
|
addon = load_yaml_any(str(TRANSLATE_ADDON), default_if_missing={})
|
|
return addon["config"]["options"]
|
|
|
|
|
|
class _Stub(LookupBase):
|
|
def __init__(self, answer):
|
|
super().__init__()
|
|
self._answer = answer
|
|
|
|
def run(self, terms, variables=None, **kwargs):
|
|
return [self._answer(*terms)]
|
|
|
|
|
|
ALL_ON = {
|
|
"configured": {"permalink_structure": PINNED_STRUCTURE},
|
|
"design": True,
|
|
"title": DESIGN_TITLE,
|
|
"translate": True,
|
|
"translate_options": _translate_options(),
|
|
}
|
|
|
|
|
|
def _resolve(name: str, scenario: dict):
|
|
"""Resolve one variable of ``vars/main.yml`` through Ansible's templar.
|
|
|
|
Args:
|
|
name: the variable to resolve.
|
|
scenario: ``configured`` is what ``services.wordpress.options``
|
|
resolves to (role default and inventory merged), ``design`` is
|
|
``services.design.enabled``, ``title`` is the design lookup's
|
|
``title``, ``translate`` is ``addons.freedomtranslate-wp.enabled``
|
|
and ``translate_options`` is that addon's ``config.options``.
|
|
|
|
Returns:
|
|
The resolved value, with the role's own filter plugins loaded.
|
|
"""
|
|
config = {
|
|
"services.wordpress.options": scenario["configured"],
|
|
"users.platform_administrator.email": ADMIN_EMAIL,
|
|
"services.design.enabled": scenario["design"],
|
|
"addons.freedomtranslate-wp.enabled": scenario["translate"],
|
|
"addons.freedomtranslate-wp.config.options": scenario["translate_options"],
|
|
"addons.wp-discourse.config.discourse_settings": [{"name": DISCOURSE_OPTION}],
|
|
}
|
|
stubs = {
|
|
"config": lambda _app, path: config[path],
|
|
"design": lambda _app: {"title": scenario["title"]},
|
|
}
|
|
real_get = lookup_loader.get
|
|
|
|
def get(lookup_name, *args, **kwargs):
|
|
if lookup_name in stubs:
|
|
return _Stub(stubs[lookup_name])
|
|
return real_get(lookup_name, *args, **kwargs)
|
|
|
|
declared = DataLoader().load_from_file(
|
|
str(ROLE / ROLE_FILE_VARS_MAIN), trusted_as_template=True
|
|
)
|
|
templar = Templar(
|
|
loader=DataLoader(), variables={**declared, "entity_name": "wordpress"}
|
|
)
|
|
with mock.patch.object(lookup_loader, "get", side_effect=get):
|
|
return templar.template(declared[name])
|
|
|
|
|
|
def _messages(error: BaseException) -> str:
|
|
parts = []
|
|
while error is not None:
|
|
parts.append(str(error))
|
|
error = error.__cause__ or error.__context__
|
|
return "\n".join(parts)
|
|
|
|
|
|
class TestPinnedStructure(unittest.TestCase):
|
|
def test_services_pins_the_structure_of_the_published_urls(self):
|
|
services = load_yaml_any(
|
|
str(ROLE / ROLE_FILE_META_SERVICES), default_if_missing={}
|
|
)
|
|
self.assertEqual(
|
|
services["wordpress"]["options"]["permalink_structure"], PINNED_STRUCTURE
|
|
)
|
|
|
|
def test_the_hard_coded_structure_is_gone(self):
|
|
self.assertNotIn("WORDPRESS_PERMALINK_STRUCTURE", _vars())
|
|
self.assertNotIn(
|
|
"WORDPRESS_PERMALINK_STRUCTURE",
|
|
read_text(str(ROLE / "tasks/07_manager_ops.yml")),
|
|
)
|
|
|
|
|
|
class TestStrictLookup(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_reset_cache_for_tests()
|
|
cache_base._reset()
|
|
self._cwd = str(Path.cwd())
|
|
self._tmpdir = tempfile.TemporaryDirectory()
|
|
os.chdir(self._tmpdir.name)
|
|
|
|
def tearDown(self) -> None:
|
|
os.chdir(self._cwd)
|
|
self._tmpdir.cleanup()
|
|
_reset_cache_for_tests()
|
|
|
|
def _terms(self) -> list[str]:
|
|
match = OPTIONS_LOOKUP.search(_vars()["WORDPRESS_OPTIONS"])
|
|
self.assertIsNotNone(
|
|
match, "WORDPRESS_OPTIONS must read services.<entity>.options"
|
|
)
|
|
return [term.strip() for term in match.group(1).split(",")]
|
|
|
|
def test_the_lookup_carries_no_default(self):
|
|
self.assertEqual(len(self._terms()), 3, "lookup('config', app, path) only")
|
|
|
|
def test_the_vars_pass_the_map_through_the_validating_filter(self):
|
|
self.assertRegex(
|
|
_vars()["WORDPRESS_OPTIONS"],
|
|
r"\| wordpress_options_checked\(WORDPRESS_OPTIONS_OWNED_ELSEWHERE\)",
|
|
)
|
|
|
|
def _path(self) -> str:
|
|
return _render("{{ " + self._terms()[2] + " }}", entity_name="wordpress")
|
|
|
|
def test_a_missing_options_key_fails_loudly(self):
|
|
applications = {PROBE_APP: {"services": {"wordpress": {"enabled": True}}}}
|
|
with self.assertRaises(AppConfigKeyError) as caught:
|
|
LookupModule().run(
|
|
[PROBE_APP, self._path()], variables={"applications": applications}
|
|
)
|
|
self.assertIn("options", str(caught.exception))
|
|
|
|
def test_a_present_options_key_resolves(self):
|
|
options = {"permalink_structure": PINNED_STRUCTURE}
|
|
applications = {PROBE_APP: {"services": {"wordpress": {"options": options}}}}
|
|
out = LookupModule().run(
|
|
[PROBE_APP, self._path()], variables={"applications": applications}
|
|
)
|
|
self.assertEqual(out, [options])
|
|
|
|
def test_the_role_default_resolves_without_an_inventory_entry(self):
|
|
out = LookupModule().run(
|
|
["web-app-wordpress", self._path()],
|
|
variables={"applications": {"web-app-wordpress": {}}},
|
|
)
|
|
self.assertEqual(out[0]["permalink_structure"], PINNED_STRUCTURE)
|
|
|
|
|
|
class TestEffectiveMap(unittest.TestCase):
|
|
def _options(self, scenario: dict) -> dict:
|
|
return _resolve("WORDPRESS_OPTIONS", scenario)
|
|
|
|
def test_the_role_default_stays_in_the_map(self):
|
|
self.assertEqual(self._options(ALL_ON)["permalink_structure"], PINNED_STRUCTURE)
|
|
|
|
def test_admin_email_is_the_platform_administrators(self):
|
|
self.assertEqual(self._options(ALL_ON)["admin_email"], ADMIN_EMAIL)
|
|
|
|
def test_the_design_title_is_the_blogname_while_the_design_is_on(self):
|
|
self.assertEqual(self._options(ALL_ON)["blogname"], DESIGN_TITLE)
|
|
|
|
def test_no_blogname_while_the_design_is_off(self):
|
|
self.assertNotIn("blogname", self._options(ALL_ON | {"design": False}))
|
|
|
|
def test_no_blogname_when_the_design_replaces_no_title(self):
|
|
for title in (False, ""):
|
|
with self.subTest(title=title):
|
|
self.assertNotIn("blogname", self._options(ALL_ON | {"title": title}))
|
|
|
|
def test_the_translate_options_join_while_the_addon_is_on(self):
|
|
options = self._options(ALL_ON)
|
|
declared = _translate_options()
|
|
self.assertTrue(declared, "the addon must declare its options")
|
|
for entry in declared:
|
|
with self.subTest(option=entry["name"]):
|
|
self.assertEqual(options[entry["name"]], entry["value"])
|
|
|
|
def test_no_translate_option_while_the_addon_is_off(self):
|
|
options = self._options(ALL_ON | {"translate": False})
|
|
for entry in _translate_options():
|
|
with self.subTest(option=entry["name"]):
|
|
self.assertNotIn(entry["name"], options)
|
|
|
|
def test_an_inventory_entry_wins_over_every_contribution(self):
|
|
configured = {
|
|
"permalink_structure": PINNED_STRUCTURE,
|
|
"admin_email": "ops@example.test",
|
|
"blogname": "Own name",
|
|
"freedomtranslate_api_url": "https://translate.example.test/translate",
|
|
}
|
|
options = self._options(ALL_ON | {"configured": configured})
|
|
for name, value in configured.items():
|
|
with self.subTest(option=name):
|
|
self.assertEqual(options[name], value)
|
|
|
|
def test_the_filter_guards_the_merged_map(self):
|
|
configured = {"permalink_structure": PINNED_STRUCTURE, "home": "https://x"}
|
|
with self.assertRaises(AnsibleError) as caught:
|
|
self._options(ALL_ON | {"configured": configured})
|
|
self.assertIn("services.wordpress.options.home", _messages(caught.exception))
|
|
|
|
def test_a_secret_named_addon_option_is_refused(self):
|
|
leaked = [
|
|
*_translate_options(),
|
|
{"name": "freedomtranslate_api_key", "value": "x"},
|
|
]
|
|
with self.assertRaises(AnsibleError) as caught:
|
|
self._options(ALL_ON | {"translate_options": leaked})
|
|
self.assertIn("freedomtranslate_api_key", _messages(caught.exception))
|
|
|
|
def test_services_declares_no_option_a_contribution_sets(self):
|
|
services = load_yaml_any(
|
|
str(ROLE / ROLE_FILE_META_SERVICES), default_if_missing={}
|
|
)
|
|
contributed = _resolve("WORDPRESS_OPTIONS_CONTRIBUTED", ALL_ON)
|
|
self.assertTrue(contributed)
|
|
self.assertFalse(set(services["wordpress"]["options"]) & set(contributed))
|
|
|
|
|
|
class TestOwnedElsewhere(unittest.TestCase):
|
|
def test_every_option_the_role_writes_by_name_is_refused_by_the_map(self):
|
|
owned = _vars()["WORDPRESS_OPTIONS_OWNED_ELSEWHERE"]
|
|
written: set[str] = set()
|
|
for _path, content in iter_project_files_with_content(
|
|
extensions=(".yml", ".php"), exclude_tests=True
|
|
):
|
|
if not str(_path).startswith(str(ROLE)):
|
|
continue
|
|
for match in LITERAL_OPTION_WRITE.finditer(content):
|
|
written.add(match.group(1) or match.group(2))
|
|
self.assertTrue(written, "the scan must find the role's own option writes")
|
|
for name in sorted(written):
|
|
with self.subTest(option=name):
|
|
self.assertIn(f"'{name}'", owned)
|
|
|
|
def test_computed_and_wordpress_managed_options_stay_owned(self):
|
|
owned = _resolve("WORDPRESS_OPTIONS_OWNED_ELSEWHERE", ALL_ON)
|
|
for name in (
|
|
"home",
|
|
"siteurl",
|
|
"active_plugins",
|
|
"rewrite_rules",
|
|
"openid_connect_generic_settings",
|
|
"mwai_options",
|
|
"site_icon",
|
|
"site_logo",
|
|
DISCOURSE_OPTION,
|
|
):
|
|
with self.subTest(option=name):
|
|
self.assertIn(name, owned)
|
|
|
|
def test_options_the_map_now_carries_left_the_owned_list(self):
|
|
owned = _resolve("WORDPRESS_OPTIONS_OWNED_ELSEWHERE", ALL_ON)
|
|
moved = ["admin_email", "blogname"] + [o["name"] for o in _translate_options()]
|
|
for name in moved:
|
|
with self.subTest(option=name):
|
|
self.assertNotIn(name, owned)
|
|
|
|
def test_no_contribution_is_owned_elsewhere(self):
|
|
owned = _resolve("WORDPRESS_OPTIONS_OWNED_ELSEWHERE", ALL_ON)
|
|
contributed = _resolve("WORDPRESS_OPTIONS_CONTRIBUTED", ALL_ON)
|
|
self.assertFalse(set(owned) & set(contributed))
|
|
|
|
|
|
class TestOrder(unittest.TestCase):
|
|
def test_options_are_read_after_the_plugins_are_installed(self):
|
|
self.assertLess(
|
|
_position("Iterate through WordPress plugins"),
|
|
_position("Read the WordPress options"),
|
|
)
|
|
|
|
def test_the_translate_readback_follows_the_options_write(self):
|
|
self.assertLess(
|
|
_position("Write each pinned WordPress option"),
|
|
_position("Verify FreedomTranslate"),
|
|
)
|
|
|
|
|
|
class TestTranslateReadback(unittest.TestCase):
|
|
def test_the_addon_task_installs_the_plugin_and_writes_no_option(self):
|
|
tasks = load_yaml_any(str(TRANSLATE_TASKS), default_if_missing=[])
|
|
self.assertEqual(
|
|
[t["vars"]["plugin_name"] for t in tasks], ["freedomtranslate-wp"]
|
|
)
|
|
self.assertNotIn("wp option", read_text(str(TRANSLATE_TASKS)))
|
|
|
|
def test_the_readback_runs_only_with_the_addon(self):
|
|
self.assertEqual(
|
|
_task("Verify FreedomTranslate")["when"],
|
|
"WORDPRESS_TRANSLATE_ENABLED | bool",
|
|
)
|
|
|
|
def test_the_expected_endpoint_follows_the_effective_map(self):
|
|
declared = {o["name"]: o["value"] for o in _translate_options()}
|
|
self.assertEqual(
|
|
_resolve("WORDPRESS_TRANSLATE_ENDPOINT", ALL_ON),
|
|
declared["freedomtranslate_api_url"],
|
|
)
|
|
own = "https://translate.example.test/translate"
|
|
configured = {
|
|
"permalink_structure": PINNED_STRUCTURE,
|
|
"freedomtranslate_api_url": own,
|
|
}
|
|
self.assertEqual(
|
|
_resolve(
|
|
"WORDPRESS_TRANSLATE_ENDPOINT", ALL_ON | {"configured": configured}
|
|
),
|
|
own,
|
|
)
|
|
|
|
|
|
class TestReadTask(unittest.TestCase):
|
|
def _fails(self, rc: int, stderr: str) -> bool:
|
|
failed_when = _task("Read the WordPress options")["failed_when"]
|
|
return bool(
|
|
_render(
|
|
"{{ (" + failed_when + ") | bool }}",
|
|
wp_options_stored={"rc": rc, "stderr": stderr},
|
|
)
|
|
)
|
|
|
|
def test_it_loops_over_the_validated_map(self):
|
|
task = _task("Read the WordPress options")
|
|
self.assertEqual(task["loop"], "{{ WORDPRESS_OPTIONS | dict2items }}")
|
|
self.assertIn("--format=json", task["shell"])
|
|
self.assertIs(task["changed_when"], False)
|
|
|
|
def test_a_missing_option_is_no_failure(self):
|
|
self.assertFalse(
|
|
self._fails(
|
|
1, "Error: Could not get 'blogdescription' option. Does it exist?"
|
|
)
|
|
)
|
|
|
|
def test_any_other_error_fails_the_deploy(self):
|
|
self.assertTrue(
|
|
self._fails(1, "Error: Error establishing a database connection.")
|
|
)
|
|
|
|
def test_a_present_option_is_no_failure(self):
|
|
self.assertFalse(self._fails(0, ""))
|
|
|
|
|
|
class TestWriteTask(unittest.TestCase):
|
|
def _argv(self, key: str, value) -> list[str]:
|
|
rendered = _render(
|
|
_task("Write each pinned WordPress option")["shell"],
|
|
item={"item": {"key": key, "value": value}},
|
|
WORDPRESS_USER="www-data",
|
|
WORDPRESS_CONTAINER_ADDRESS="wordpress",
|
|
WORDPRESS_DOCKER_HTML_PATH="/var/www/html",
|
|
)
|
|
return shlex.split(rendered)
|
|
|
|
def _changed(self, stdout: str) -> bool:
|
|
changed_when = _task("Write each pinned WordPress option")["changed_when"]
|
|
return bool(
|
|
_render(
|
|
"{{ (" + changed_when + ") | bool }}",
|
|
wp_options_written={"stdout": stdout},
|
|
)
|
|
)
|
|
|
|
def test_the_structure_reaches_wp_cli_as_one_json_argument(self):
|
|
argv = self._argv("permalink_structure", PINNED_STRUCTURE)
|
|
position = argv.index("update")
|
|
self.assertEqual(argv[position + 1], "permalink_structure")
|
|
self.assertEqual(json.loads(argv[position + 2]), PINNED_STRUCTURE)
|
|
self.assertIn("--format=json", argv)
|
|
|
|
def test_a_structure_with_quotes_and_spaces_survives_the_shell(self):
|
|
value = {"title": 'It\'s "quoted" text', "items": [1, "two words"]}
|
|
argv = self._argv("plugin_settings", value)
|
|
position = argv.index("update")
|
|
self.assertEqual(json.loads(argv[position + 2]), value)
|
|
|
|
def test_only_drifted_options_are_written(self):
|
|
task = _task("Write each pinned WordPress option")
|
|
self.assertEqual(task["loop"], "{{ wp_options_stored.results }}")
|
|
self.assertEqual(task["when"], "item | wordpress_option_drifted")
|
|
self.assertEqual(task["register"], "wp_options_written")
|
|
|
|
def test_an_updated_option_is_a_change(self):
|
|
self.assertTrue(self._changed("Success: Updated 'blogname' option."))
|
|
|
|
def test_a_value_wordpress_already_stores_sanitized_is_no_change(self):
|
|
self.assertFalse(
|
|
self._changed("Success: Value passed for 'blogname' option is unchanged.")
|
|
)
|
|
|
|
|
|
class TestFlushTask(unittest.TestCase):
|
|
def _flushes(self, written: dict) -> bool:
|
|
when = _task("Flush the rewrite rules")["when"]
|
|
return bool(_render("{{ (" + when + ") | bool }}", wp_options_written=written))
|
|
|
|
def test_a_write_flushes_the_rewrite_rules(self):
|
|
self.assertIn("wp rewrite flush", _task("Flush the rewrite rules")["shell"])
|
|
self.assertTrue(self._flushes({"changed": True, "results": []}))
|
|
|
|
def test_no_write_leaves_the_rewrite_rules_alone(self):
|
|
self.assertFalse(self._flushes({"changed": False, "skipped": True}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|