mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-13 06:06:38 +00:00
perf(web-app-openproject): apply settings and the administrator only when their digest changes
The admin step ran its Rails runner on every deploy. It now digests its sources, its inputs and the administrator and settings state OpenProject holds, compares that with the admin-provision.sha256 stamp in the instance directory, and runs only on a mismatch or when services.openproject.admin.force is set. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
008887c0f0
commit
dd0d9e964d
8 files changed
+503
-28
No files matched your search
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from ansible.parsing.dataloader import DataLoader
|
||||
from ansible.template import Templar
|
||||
|
||||
from utils.cache.files import PROJECT_ROOT, read_text
|
||||
from utils.cache.yaml import load_yaml_any
|
||||
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_VARS_MAIN
|
||||
from utils.templating.ansible import _trust_as_template
|
||||
|
||||
ROLE = PROJECT_ROOT / "roles" / "web-app-openproject"
|
||||
GATE = ROLE / "tasks" / "04_admin.yml"
|
||||
STATE = ROLE / "tasks" / "04_admin" / "state.yml"
|
||||
PROVISION = ROLE / "tasks" / "04_admin" / "provision.yml"
|
||||
VARS = ROLE / ROLE_FILE_VARS_MAIN
|
||||
SERVICES = ROLE / ROLE_FILE_META_SERVICES
|
||||
QUERY = ROLE / "files" / "sql" / "select-admin-state.sql"
|
||||
|
||||
FIRST_READ = "📖 Read the settings, administrator and LDAP link OpenProject holds"
|
||||
READ = "🔍 Read the settings, administrator and LDAP link OpenProject holds"
|
||||
DIGEST = "🧮 Digest the run's sources, inputs and the state OpenProject holds"
|
||||
DECIDE = "🧮 Decide whether the settings and administrator need a run"
|
||||
GATED = "🔧 Run the settings and administrator step and stamp what it left"
|
||||
RUN = "🔧 Apply settings, ensure the administrator and link it to LDAP"
|
||||
REREAD = "📖 Read the settings, administrator and LDAP link the run left"
|
||||
STAMP = "📝 Stamp the settings, administrator and LDAP link the run left"
|
||||
|
||||
SETTINGS = {
|
||||
"email_delivery_method": "smtp",
|
||||
"smtp_address": "mail.infinito.example",
|
||||
"smtp_domain": "infinito.example",
|
||||
"smtp_user_name": "no-reply@infinito.example",
|
||||
"smtp_password": "smtp-token",
|
||||
"smtp_ssl": False,
|
||||
}
|
||||
STORED_SETTINGS = "email_delivery_method=a1,smtp_address=b2,smtp_password=c3"
|
||||
STEADY = {
|
||||
"id": "7",
|
||||
"admin": "t",
|
||||
"status": "1",
|
||||
"force_password_change": "f",
|
||||
"ldap_auth_source_id": "3",
|
||||
"password_id": "41",
|
||||
"password_md5": "9f86d081",
|
||||
"ldap_source_id": "3",
|
||||
"settings": STORED_SETTINGS,
|
||||
}
|
||||
ABSENT = {
|
||||
"changed": False,
|
||||
"skipped": True,
|
||||
"false_condition": "openproject_admin_stamp_file.stat.exists",
|
||||
}
|
||||
|
||||
|
||||
def _row(**changes: str) -> list[str]:
|
||||
"""Return the row the state query yields, in its column order.
|
||||
|
||||
Args:
|
||||
changes: columns whose value differs from the steady state.
|
||||
"""
|
||||
return list({**STEADY, **changes}.values())
|
||||
|
||||
|
||||
def _flat(tasks: list[dict]) -> list[dict]:
|
||||
"""Return the tasks in run order, each block followed by its children.
|
||||
|
||||
Args:
|
||||
tasks: parsed task list of one task file.
|
||||
"""
|
||||
flat: list[dict] = []
|
||||
for task in tasks:
|
||||
flat.append(task)
|
||||
flat.extend(_flat(task.get("block") or []))
|
||||
return flat
|
||||
|
||||
|
||||
def _tasks(path) -> list[dict]:
|
||||
return _flat(load_yaml_any(str(path), default_if_missing=[]))
|
||||
|
||||
|
||||
def _task(path, name: str) -> dict:
|
||||
return next(task for task in _tasks(path) if task.get("name") == name)
|
||||
|
||||
|
||||
def _render(template, **variables):
|
||||
templar = Templar(loader=DataLoader(), variables=variables)
|
||||
return templar.template(_trust_as_template(template))
|
||||
|
||||
|
||||
def _sources() -> list[str]:
|
||||
raw = load_yaml_any(str(VARS))["OPENPROJECT_ADMIN_SOURCES"]
|
||||
return [_render(item, role_path=str(ROLE)) for item in raw]
|
||||
|
||||
|
||||
INPUTS = {
|
||||
"OPENPROJECT_ADMIN_SOURCES": _sources(),
|
||||
"OPENPROJECT_VERSION": "17",
|
||||
"OPENPROJECT_ADMINISTRATOR_USERNAME": "administrator",
|
||||
"OPENPROJECT_ADMINISTRATOR_EMAIL": "administrator@infinito.example",
|
||||
"OPENPROJECT_ADMINISTRATOR_PASSWORD": "Sw0rdf1sh!",
|
||||
"OPENPROJECT_RAILS_SETTINGS": SETTINGS,
|
||||
"OPENPROJECT_ADMIN_LDAP_NAME": "infinito.example",
|
||||
}
|
||||
|
||||
|
||||
def _digest(row: list[str], **inputs) -> str:
|
||||
"""Render the digest the state step records.
|
||||
|
||||
Args:
|
||||
row: the one row the state query returns.
|
||||
inputs: overrides of the role variables in INPUTS.
|
||||
"""
|
||||
expression = _task(STATE, DIGEST)["ansible.builtin.set_fact"][
|
||||
"openproject_admin_digest"
|
||||
]
|
||||
return _render(
|
||||
expression,
|
||||
**{**INPUTS, **inputs},
|
||||
openproject_admin_state={"rows": [row]},
|
||||
)
|
||||
|
||||
|
||||
def _stamp(digest: str) -> dict:
|
||||
"""Return the slurp result of a stamp holding ``digest``.
|
||||
|
||||
Args:
|
||||
digest: the digest the stamp file holds.
|
||||
"""
|
||||
content = base64.b64encode(f"{digest}\n".encode()).decode()
|
||||
return {"changed": False, "content": content, "encoding": "base64"}
|
||||
|
||||
|
||||
def _decision() -> str:
|
||||
return _task(GATE, DECIDE)["ansible.builtin.set_fact"]["openproject_admin_due"]
|
||||
|
||||
|
||||
def _due(stamp: dict, digest: str, force=False, decision: str = "") -> bool:
|
||||
"""Render whether the settings and administrator run is due.
|
||||
|
||||
Args:
|
||||
stamp: the registered slurp result of the stamp file.
|
||||
digest: the digest of the current sources, inputs and state.
|
||||
force: the value of services.openproject.admin.force.
|
||||
decision: the decision expression; the gate's own when empty.
|
||||
"""
|
||||
due = _render(
|
||||
decision or _decision(),
|
||||
OPENPROJECT_ADMIN_FORCE=force,
|
||||
openproject_admin_stamp=stamp,
|
||||
openproject_admin_digest=digest,
|
||||
)
|
||||
return _render("{{ due | bool }}", due=due)
|
||||
|
||||
|
||||
class TestDecision(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.digest = _digest(_row())
|
||||
|
||||
def test_a_missing_stamp_runs_the_step(self):
|
||||
self.assertTrue(_due(ABSENT, self.digest))
|
||||
|
||||
def test_a_stamp_equal_to_the_digest_skips_the_step(self):
|
||||
self.assertFalse(_due(_stamp(self.digest), self.digest))
|
||||
|
||||
def test_a_stamp_different_from_the_digest_runs_the_step(self):
|
||||
rotated = _digest(_row(), OPENPROJECT_ADMINISTRATOR_PASSWORD="rotated")
|
||||
self.assertTrue(_due(_stamp(rotated), self.digest))
|
||||
|
||||
def test_force_runs_the_step_on_an_equal_stamp(self):
|
||||
for force in (True, "true"):
|
||||
with self.subTest(force=force):
|
||||
self.assertTrue(_due(_stamp(self.digest), self.digest, force=force))
|
||||
|
||||
def test_negative_control_a_gate_blind_to_the_digest_misses_the_drift(self):
|
||||
blind = _decision().replace(
|
||||
"!= openproject_admin_digest",
|
||||
"!= (openproject_admin_stamp.content | b64decode | trim)",
|
||||
)
|
||||
self.assertNotEqual(blind, _decision())
|
||||
demoted = _stamp(_digest(_row(admin="f")))
|
||||
self.assertFalse(_due(demoted, self.digest, decision=blind))
|
||||
self.assertTrue(_due(demoted, self.digest))
|
||||
|
||||
|
||||
class TestDigest(unittest.TestCase):
|
||||
def test_the_same_sources_inputs_and_state_give_the_same_digest(self):
|
||||
self.assertEqual(_digest(_row()), _digest(_row()))
|
||||
self.assertRegex(_digest(_row()), r"^[0-9a-f]{64}$")
|
||||
|
||||
def test_drift_in_openproject_changes_the_digest(self):
|
||||
removed = dict.fromkeys(list(STEADY)[:7], "")
|
||||
drifts = {
|
||||
"administrator removed": _row(**removed),
|
||||
"administrator demoted": _row(admin="f"),
|
||||
"administrator locked": _row(status="3"),
|
||||
"password change forced": _row(force_password_change="t"),
|
||||
"LDAP link dropped": _row(ldap_auth_source_id=""),
|
||||
"password changed in the UI": _row(password_id="42", password_md5="e3b0"),
|
||||
"password rehashed in place": _row(password_md5="e3b0"),
|
||||
"LDAP source replaced": _row(ldap_source_id="4"),
|
||||
"setting edited in the UI": _row(
|
||||
settings=STORED_SETTINGS.replace("c3", "d4")
|
||||
),
|
||||
"setting deleted": _row(settings="email_delivery_method=a1"),
|
||||
}
|
||||
steady = _digest(_row())
|
||||
for drift, row in drifts.items():
|
||||
with self.subTest(drift=drift):
|
||||
self.assertEqual(len(row), len(STEADY))
|
||||
self.assertNotEqual(_digest(row), steady)
|
||||
|
||||
def test_changed_inputs_change_the_digest(self):
|
||||
changes = {
|
||||
"OPENPROJECT_ADMINISTRATOR_PASSWORD": "rotated",
|
||||
"OPENPROJECT_ADMINISTRATOR_USERNAME": "root",
|
||||
"OPENPROJECT_ADMINISTRATOR_EMAIL": "root@infinito.example",
|
||||
"OPENPROJECT_RAILS_SETTINGS": {**SETTINGS, "smtp_password": "rotated"},
|
||||
"OPENPROJECT_ADMIN_LDAP_NAME": "",
|
||||
"OPENPROJECT_VERSION": "18",
|
||||
"OPENPROJECT_ADMIN_SOURCES": [
|
||||
str(ROLE / "files" / "ruby" / "drain_jobs.rb"),
|
||||
*INPUTS["OPENPROJECT_ADMIN_SOURCES"][1:],
|
||||
],
|
||||
}
|
||||
steady = _digest(_row())
|
||||
for name, value in changes.items():
|
||||
with self.subTest(input=name):
|
||||
self.assertNotEqual(_digest(_row(), **{name: value}), steady)
|
||||
|
||||
def test_secrets_reach_the_digest_only_hashed(self):
|
||||
expression = _task(STATE, DIGEST)["ansible.builtin.set_fact"][
|
||||
"openproject_admin_digest"
|
||||
]
|
||||
for entry in (
|
||||
"'password': OPENPROJECT_ADMINISTRATOR_PASSWORD | hash('sha256')",
|
||||
(
|
||||
"'settings': OPENPROJECT_RAILS_SETTINGS | to_json(sort_keys=true)"
|
||||
" | hash('sha256')"
|
||||
),
|
||||
):
|
||||
with self.subTest(entry=entry):
|
||||
self.assertIn(entry, expression)
|
||||
|
||||
|
||||
class TestSources(unittest.TestCase):
|
||||
def test_every_file_the_run_reads_is_a_source(self):
|
||||
provision = read_text(str(PROVISION))
|
||||
read = {
|
||||
str(ROLE / "files" / name)
|
||||
for name in re.findall(r"lookup\('file', '([^']+)'\)", provision)
|
||||
}
|
||||
self.assertEqual(len(read), 3)
|
||||
self.assertLessEqual(read | {str(PROVISION)}, set(_sources()))
|
||||
|
||||
def test_every_source_exists(self):
|
||||
for source in _sources():
|
||||
with self.subTest(source=source):
|
||||
self.assertTrue(read_text(source))
|
||||
|
||||
|
||||
class TestQuery(unittest.TestCase):
|
||||
def test_the_query_reads_what_the_scripts_write(self):
|
||||
sql = read_text(str(QUERY))
|
||||
for fragment in (
|
||||
"account.admin",
|
||||
"account.status",
|
||||
"account.force_password_change",
|
||||
"account.ldap_auth_source_id",
|
||||
"FROM user_passwords",
|
||||
"JOIN ldap_auth_sources",
|
||||
"FROM settings",
|
||||
):
|
||||
with self.subTest(fragment=fragment):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
def test_the_query_returns_hashes_not_secrets(self):
|
||||
sql = read_text(str(QUERY))
|
||||
secrets = [
|
||||
match
|
||||
for match in re.finditer(r"(\w+)\.(hashed_password|value)\b", sql)
|
||||
if match.group(1) != "candidate"
|
||||
]
|
||||
self.assertEqual(len(secrets), 2)
|
||||
for match in secrets:
|
||||
with self.subTest(column=match.group(0)):
|
||||
self.assertEqual(sql[match.start() - 4 : match.start()], "md5(")
|
||||
|
||||
def test_the_query_reads_exactly_the_settings_the_run_applies(self):
|
||||
args = _task(STATE, READ)["database_query"]["named_args"]
|
||||
names = _render(args["settings"], OPENPROJECT_RAILS_SETTINGS=SETTINGS)
|
||||
self.assertEqual(json.loads(names), list(SETTINGS))
|
||||
self.assertEqual(args["ldap_name"], "{{ OPENPROJECT_ADMIN_LDAP_NAME }}")
|
||||
self.assertEqual(args["login"], "{{ OPENPROJECT_ADMINISTRATOR_USERNAME }}")
|
||||
|
||||
|
||||
class TestWiring(unittest.TestCase):
|
||||
def test_the_run_is_followed_by_a_reread_and_a_stamp(self):
|
||||
names = [task["name"] for task in _tasks(GATE)]
|
||||
order = [names.index(n) for n in (FIRST_READ, DECIDE, RUN, REREAD, STAMP)]
|
||||
self.assertEqual(order, sorted(order))
|
||||
|
||||
def test_run_reread_and_stamp_wait_for_the_decision(self):
|
||||
gated = _task(GATE, GATED)
|
||||
self.assertEqual(gated["when"], "openproject_admin_due | bool")
|
||||
self.assertEqual(
|
||||
[task["name"] for task in gated["block"]], [RUN, REREAD, STAMP]
|
||||
)
|
||||
|
||||
def test_both_state_reads_are_masked(self):
|
||||
for name in (FIRST_READ, REREAD):
|
||||
with self.subTest(task=name):
|
||||
include = _task(GATE, name)["include_tasks"]
|
||||
self.assertEqual(include["file"], "04_admin/state.yml")
|
||||
self.assertEqual(
|
||||
include["apply"]["no_log"],
|
||||
"{{ MASK_CREDENTIALS_IN_LOGS | bool }}",
|
||||
)
|
||||
|
||||
def test_the_stamp_holds_only_the_digest_and_only_for_its_owner(self):
|
||||
stamp = _task(GATE, STAMP)["stack_host_copy"]
|
||||
self.assertEqual(stamp["content"], "{{ openproject_admin_digest }}\n")
|
||||
self.assertEqual(stamp["mode"], "0600")
|
||||
self.assertEqual(stamp["dest"], "{{ OPENPROJECT_ADMIN_STAMP_FILE }}")
|
||||
self.assertIn(
|
||||
"directories.instance",
|
||||
load_yaml_any(str(VARS))["OPENPROJECT_ADMIN_STAMP_FILE"],
|
||||
)
|
||||
|
||||
def test_the_force_flag_is_declared_off(self):
|
||||
openproject = load_yaml_any(str(SERVICES))["openproject"]
|
||||
self.assertIs(openproject["admin"]["force"], False)
|
||||
self.assertIn(
|
||||
"'services.openproject.admin.force'",
|
||||
load_yaml_any(str(VARS))["OPENPROJECT_ADMIN_FORCE"],
|
||||
)
|
||||
|
||||
def test_the_link_uses_the_ldap_name_the_state_reads(self):
|
||||
provision = read_text(str(PROVISION))
|
||||
self.assertIn("OPENPROJECT_ADMIN_LDAP_NAME", provision)
|
||||
self.assertNotIn("OPENPROJECT_LDAP.name", provision)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user