From dd0d9e964dc7010d97f7c2b4d86e877bbeeff71b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 29 Oct 2026 00:28:15 +0100 Subject: [PATCH] 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) --- roles/web-app-openproject/README.md | 2 + .../files/sql/select-admin-state.sql | 42 +++ roles/web-app-openproject/meta/services.yml | 2 + roles/web-app-openproject/tasks/04_admin.yml | 68 ++-- .../tasks/04_admin/provision.yml | 30 ++ .../tasks/04_admin/state.yml | 30 ++ roles/web-app-openproject/vars/main.yml | 8 + .../web-app-openproject/test_admin_stamp.py | 349 ++++++++++++++++++ 8 files changed, 503 insertions(+), 28 deletions(-) create mode 100644 roles/web-app-openproject/files/sql/select-admin-state.sql create mode 100644 roles/web-app-openproject/tasks/04_admin/provision.yml create mode 100644 roles/web-app-openproject/tasks/04_admin/state.yml create mode 100644 tests/unit/python/roles/web-app-openproject/test_admin_stamp.py diff --git a/roles/web-app-openproject/README.md b/roles/web-app-openproject/README.md index 436ec673d9..7dfa14f300 100644 --- a/roles/web-app-openproject/README.md +++ b/roles/web-app-openproject/README.md @@ -143,6 +143,8 @@ docker run --rm -it \ To inspect and modify live settings inside the container, open a shell and use the Rails console for full LDAP and SMTP configuration. +[tasks/04_admin.yml](./tasks/04_admin.yml) applies the settings, the administrator and its LDAP link only when the digest of its sources, its inputs and the state OpenProject holds differs from the stamp `admin-provision.sha256` in the instance directory. Set `services.openproject.admin.force` to `true` to run it regardless. + ## Database migrations A deploy runs `bin/rails db:migrate` in the web container only while the comment on `schema_migrations` names another image than the one the web container runs, and writes the running image id there once the run succeeds. `COMMENT ON TABLE schema_migrations IS NULL` makes the next deploy migrate again. diff --git a/roles/web-app-openproject/files/sql/select-admin-state.sql b/roles/web-app-openproject/files/sql/select-admin-state.sql new file mode 100644 index 0000000000..835fbbcd2a --- /dev/null +++ b/roles/web-app-openproject/files/sql/select-admin-state.sql @@ -0,0 +1,42 @@ +SELECT + account.id AS account_id, + account.admin AS account_admin, + account.status AS account_status, + account.force_password_change AS account_force_password_change, + account.ldap_auth_source_id AS account_ldap_auth_source_id, + latest_password.id AS password_id, + md5(latest_password.hashed_password) AS password_digest, + ldap_source.id AS ldap_source_id, + stored_settings.state AS settings_state +FROM ( + SELECT + %(login)s AS wanted_login, + %(ldap_name)s AS wanted_ldap_name +) AS wanted +LEFT JOIN users AS account + ON + account.type = 'User' + AND wanted.wanted_login = account.login +LEFT JOIN LATERAL ( + SELECT + candidate.id, + candidate.hashed_password + FROM user_passwords AS candidate + WHERE candidate.user_id = account.id + ORDER BY candidate.id DESC + LIMIT 1 +) AS latest_password ON TRUE +LEFT JOIN ldap_auth_sources AS ldap_source + ON wanted.wanted_ldap_name = ldap_source.name +CROSS JOIN LATERAL ( + SELECT + string_agg( + concat(setting.name, '=', md5(setting.value)), + ',' ORDER BY setting.name, setting.id + ) AS state + FROM settings AS setting + WHERE setting.name IN ( + SELECT json_array_elements_text(%(settings)s::json) + ) +) AS stored_settings +ORDER BY account_id, ldap_source_id; diff --git a/roles/web-app-openproject/meta/services.yml b/roles/web-app-openproject/meta/services.yml index f4af64c5b9..d035d9ad2f 100644 --- a/roles/web-app-openproject/meta/services.yml +++ b/roles/web-app-openproject/meta/services.yml @@ -138,6 +138,8 @@ openproject: filters: administrators: false users: false + admin: + force: false ports: local: sso: 16483 diff --git a/roles/web-app-openproject/tasks/04_admin.yml b/roles/web-app-openproject/tasks/04_admin.yml index 7d6849ab29..799dc2b9a0 100644 --- a/roles/web-app-openproject/tasks/04_admin.yml +++ b/roles/web-app-openproject/tasks/04_admin.yml @@ -1,30 +1,42 @@ --- -- name: "🔍 Resolve openproject web hosting node + container id" - ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" - vars: - resolve_service_key: web - resolve_output_prefix: OPENPROJECT_WEB - resolve_compose_cid: "{{ OPENPROJECT_WEB_CONTAINER_ADDRESS }}" +- name: 📖 Read the settings, administrator and LDAP link OpenProject holds + include_tasks: + file: 04_admin/state.yml + apply: + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" -- name: 🔧 Apply settings, ensure the administrator and link it to LDAP - ansible.builtin.shell: - cmd: >- # nocheck: container-exec-resolver CID resolved at delegate_to runtime; container_address targets a manager-only subshell - container exec -i - -e {{ ('OPENPROJECT_ADMIN_LOGIN=' ~ OPENPROJECT_ADMINISTRATOR_USERNAME) | quote }} - -e {{ ('OPENPROJECT_ADMIN_EMAIL=' ~ OPENPROJECT_ADMINISTRATOR_EMAIL) | quote }} - {% if OPENPROJECT_HEADER_SSO_ENABLED | bool %}-e {{ ('OPENPROJECT_LDAP_NAME=' ~ OPENPROJECT_LDAP.name) | quote }}{% endif %} - {{ OPENPROJECT_WEB_LOCAL_CID | quote }} - sh -c 'IFS= read -r OPENPROJECT_ADMIN_PASSWORD; IFS= read -r OPENPROJECT_RAILS_SETTINGS; export OPENPROJECT_ADMIN_PASSWORD OPENPROJECT_RAILS_SETTINGS; exec - "$@"' sh bash -lc 'cd /app && bundle exec rails runner -' - stdin: |- - {{ OPENPROJECT_ADMINISTRATOR_PASSWORD }} - {{ OPENPROJECT_RAILS_SETTINGS | to_json }} - {{ lookup('file', 'ruby/apply_settings.rb') }} - {{ lookup('file', 'ruby/ensure_admin_user.rb') }} - {% if OPENPROJECT_HEADER_SSO_ENABLED | bool %}{{ lookup('file', 'ruby/link_admin_to_ldap.rb') }}{% endif %} - delegate_to: "{{ OPENPROJECT_WEB_HOST_NODE }}" - register: openproject_rails_runner - retries: 30 - delay: 5 - until: openproject_rails_runner.rc == 0 - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" +- name: 🔍 Find the stamp of the last settings and administrator run + ansible.builtin.stat: + path: "{{ OPENPROJECT_ADMIN_STAMP_FILE }}" + register: openproject_admin_stamp_file + +- name: 📖 Read the stamp of the last settings and administrator run + ansible.builtin.slurp: + src: "{{ OPENPROJECT_ADMIN_STAMP_FILE }}" + register: openproject_admin_stamp + when: openproject_admin_stamp_file.stat.exists + +- name: 🧮 Decide whether the settings and administrator need a run + ansible.builtin.set_fact: + openproject_admin_due: >- + {{ (OPENPROJECT_ADMIN_FORCE | bool) + or openproject_admin_stamp is skipped + or (openproject_admin_stamp.content | b64decode | trim) != openproject_admin_digest }} + +- name: 🔧 Run the settings and administrator step and stamp what it left + when: openproject_admin_due | bool + block: + - name: 🔧 Apply settings, ensure the administrator and link it to LDAP + include_tasks: 04_admin/provision.yml + + - name: 📖 Read the settings, administrator and LDAP link the run left + include_tasks: + file: 04_admin/state.yml + apply: + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + + - name: 📝 Stamp the settings, administrator and LDAP link the run left + stack_host_copy: + content: "{{ openproject_admin_digest }}\n" + dest: "{{ OPENPROJECT_ADMIN_STAMP_FILE }}" + mode: "0600" diff --git a/roles/web-app-openproject/tasks/04_admin/provision.yml b/roles/web-app-openproject/tasks/04_admin/provision.yml new file mode 100644 index 0000000000..154c2b82f8 --- /dev/null +++ b/roles/web-app-openproject/tasks/04_admin/provision.yml @@ -0,0 +1,30 @@ +--- +- name: "🔍 Resolve openproject web hosting node + container id" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" + vars: + resolve_service_key: web + resolve_output_prefix: OPENPROJECT_WEB + resolve_compose_cid: "{{ OPENPROJECT_WEB_CONTAINER_ADDRESS }}" + +- name: 🔧 Apply settings, ensure the administrator and link it to LDAP + ansible.builtin.shell: + cmd: >- # nocheck: container-exec-resolver CID resolved at delegate_to runtime; container_address targets a manager-only subshell + container exec -i + -e {{ ('OPENPROJECT_ADMIN_LOGIN=' ~ OPENPROJECT_ADMINISTRATOR_USERNAME) | quote }} + -e {{ ('OPENPROJECT_ADMIN_EMAIL=' ~ OPENPROJECT_ADMINISTRATOR_EMAIL) | quote }} + {% if OPENPROJECT_HEADER_SSO_ENABLED | bool %}-e {{ ('OPENPROJECT_LDAP_NAME=' ~ OPENPROJECT_ADMIN_LDAP_NAME) | quote }}{% endif %} + {{ OPENPROJECT_WEB_LOCAL_CID | quote }} + sh -c 'IFS= read -r OPENPROJECT_ADMIN_PASSWORD; IFS= read -r OPENPROJECT_RAILS_SETTINGS; export OPENPROJECT_ADMIN_PASSWORD OPENPROJECT_RAILS_SETTINGS; exec + "$@"' sh bash -lc 'cd /app && bundle exec rails runner -' + stdin: |- + {{ OPENPROJECT_ADMINISTRATOR_PASSWORD }} + {{ OPENPROJECT_RAILS_SETTINGS | to_json }} + {{ lookup('file', 'ruby/apply_settings.rb') }} + {{ lookup('file', 'ruby/ensure_admin_user.rb') }} + {% if OPENPROJECT_HEADER_SSO_ENABLED | bool %}{{ lookup('file', 'ruby/link_admin_to_ldap.rb') }}{% endif %} + delegate_to: "{{ OPENPROJECT_WEB_HOST_NODE }}" + register: openproject_rails_runner + retries: 30 + delay: 5 + until: openproject_rails_runner.rc == 0 + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" diff --git a/roles/web-app-openproject/tasks/04_admin/state.yml b/roles/web-app-openproject/tasks/04_admin/state.yml new file mode 100644 index 0000000000..34525745a3 --- /dev/null +++ b/roles/web-app-openproject/tasks/04_admin/state.yml @@ -0,0 +1,30 @@ +--- +- name: 🔍 Read the settings, administrator and LDAP link OpenProject holds + database_query: + config: "{{ lookup('database', application_id) }}" + query_file: "{{ [role_path, 'files/sql/select-admin-state.sql'] | path_join }}" + named_args: + login: "{{ OPENPROJECT_ADMINISTRATOR_USERNAME }}" + ldap_name: "{{ OPENPROJECT_ADMIN_LDAP_NAME }}" + settings: "{{ OPENPROJECT_RAILS_SETTINGS | list | to_json }}" + expect_rows: true + mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + register: openproject_admin_state + until: openproject_admin_state is succeeded + retries: 6 + delay: 5 + changed_when: false + +- name: 🧮 Digest the run's sources, inputs and the state OpenProject holds + ansible.builtin.set_fact: + openproject_admin_digest: >- + {{ { + 'sources': query('file', *OPENPROJECT_ADMIN_SOURCES) | map('hash', 'sha256') | list, + 'version': OPENPROJECT_VERSION, + 'login': OPENPROJECT_ADMINISTRATOR_USERNAME, + 'email': OPENPROJECT_ADMINISTRATOR_EMAIL, + 'password': OPENPROJECT_ADMINISTRATOR_PASSWORD | hash('sha256'), + 'settings': OPENPROJECT_RAILS_SETTINGS | to_json(sort_keys=true) | hash('sha256'), + 'ldap_name': OPENPROJECT_ADMIN_LDAP_NAME, + 'state': openproject_admin_state.rows + } | to_json(sort_keys=true) | hash('sha256') }} diff --git a/roles/web-app-openproject/vars/main.yml b/roles/web-app-openproject/vars/main.yml index def842c467..f7c305f855 100644 --- a/roles/web-app-openproject/vars/main.yml +++ b/roles/web-app-openproject/vars/main.yml @@ -46,6 +46,14 @@ OPENPROJECT_UPGRADE_RUN: OPENPROJECT_ADMINISTRATOR_USERNAME: "{{ lookup('users', 'administrator').username }}" OPENPROJECT_ADMINISTRATOR_PASSWORD: "{{ lookup('users', 'administrator').password }}" OPENPROJECT_ADMINISTRATOR_EMAIL: "{{ lookup('users', 'administrator').email }}" +OPENPROJECT_ADMIN_FORCE: "{{ lookup('config', application_id, 'services.openproject.admin.force') }}" +OPENPROJECT_ADMIN_LDAP_NAME: "{{ OPENPROJECT_LDAP.name if (OPENPROJECT_HEADER_SSO_ENABLED | bool) else '' }}" +OPENPROJECT_ADMIN_STAMP_FILE: "{{ [lookup('container', application_id, 'directories.instance'), 'admin-provision.sha256'] | path_join }}" +OPENPROJECT_ADMIN_SOURCES: + - "{{ [role_path, 'tasks/04_admin/provision.yml'] | path_join }}" + - "{{ [role_path, 'files/ruby/apply_settings.rb'] | path_join }}" + - "{{ [role_path, 'files/ruby/ensure_admin_user.rb'] | path_join }}" + - "{{ [role_path, 'files/ruby/link_admin_to_ldap.rb'] | path_join }}" OPENPROJECT_CACHE_CONTAINER: "{{ lookup('config', application_id, 'services.cache.name') }}" diff --git a/tests/unit/python/roles/web-app-openproject/test_admin_stamp.py b/tests/unit/python/roles/web-app-openproject/test_admin_stamp.py new file mode 100644 index 0000000000..a7846ce4e0 --- /dev/null +++ b/tests/unit/python/roles/web-app-openproject/test_admin_stamp.py @@ -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()