mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(cache): keep the applications carrier valid across token store writes
Since778c18fb23the carrier key holds the tokens file's mtime and size. sys-token-store changed that stat on every inclusion (00_core.yml touched the file) and on every persisted token, and nothing re-parked a carrier afterwards. Every later task's fresh worker then rendered the applications SPOT cold, 30 to 80 s each. On mainfea6f31d9f(run 38812964308) seven heavy jobs logged 194 to 287 such tasks and ran out of the 20400 s budget; the same jobs before778c18fb23logged 65 to 83, all of them real waits. - 00_core.yml touches the store with modification_time and access_time preserved, so it only creates a missing file. In swarm the controller sees the store over NFS with attribute caching, and the touch alone made the second pass go cold without any write. - write.yml re-parks the carrier when the persist or the controller mirror changed the store. Both run once, so every host that ran the include gets the same verdict. - tasks/utils/setup/role.yml re-parks after every role on every host, so hosts that did not run the writing role recover too. Where the key still matches this serves the carried payload; measured 0.1 s per role in a local compose deploy. - The tokens file enters the key as the SHA-256 of its content, memoised per inode, mtime and size, so a touch no longer changes the key. A stat that still matches serves the memo (1.67 us against 1.41 us for the old stat signature), and a stale stat keeps key and YAML cache on the same old content instead of pairing new content with an old key. - applications_carrier opens the tokens file before it computes the key, so on the NFS-mounted controller the parked key reflects the revalidated attributes rather than a cached stat that changes a minute later. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
af18df9fed
commit
67f6c8d7e8
10 files changed
+161
-25
No files matched your search
@@ -18,7 +18,7 @@ The `applications` lookup is the ONLY supported runtime entry point for merged a
|
||||
| `lookup('applications')` | Full merged mapping keyed by `application_id`. |
|
||||
| `lookup('applications', '<application_id>')` | Merged entry for that application. Raises when missing. |
|
||||
| `lookup('applications', '<application_id>', default_value)` | Merged entry, or `default_value` when the entry is missing. |
|
||||
| `lookup('applications', carrier=True)` | `{"key": [...], "applications": {...}}`: the full mapping plus the cache key it is valid under. Reserved for the constructor's carrier task (see below). |
|
||||
| `lookup('applications', carrier=True)` | `{"key": [...], "applications": {...}}`: the full mapping plus the cache key it is valid under. Reserved for the `applications_carrier` action (see below). |
|
||||
|
||||
## Data Sources 📚
|
||||
|
||||
@@ -33,9 +33,11 @@ No other source is consulted.
|
||||
|
||||
Ansible forks a fresh worker process for every task. The in-process cache inside `get_merged_applications` therefore never survives from one task to the next, and every task that touches the lookup would render the full tree again (about 16 to 23 seconds per task on a CI runner, measured on the workspace jobs).
|
||||
|
||||
The constructor stage renders once through the [applications_carrier](../../../../../../plugins/action/applications_carrier.py) action and parks the result together with its cache key in the host fact `_INFINITO_APPLICATIONS_RENDERED` (the action calls `lookup('applications', carrier=True)`). Every later worker inherits that fact at fork time; `get_merged_applications` serves it whenever the key still matches and renders only when it does not (a changed `applications`, `users`, `DOMAIN_PRIMARY` or `SYSTEM_EMAIL_DOMAIN`).
|
||||
The constructor stage renders once through the [applications_carrier](../../../../../../plugins/action/applications_carrier.py) action and parks the result together with its cache key in the host fact `_INFINITO_APPLICATIONS_RENDERED` (the action calls `lookup('applications', carrier=True)`). Every later worker inherits that fact at fork time; `get_merged_applications` serves it whenever the key still matches and renders only when it does not (a changed `applications`, `users`, `DOMAIN_PRIMARY`, `SYSTEM_EMAIL_DOMAIN` or `group_names`, or a changed path or content of the tokens file).
|
||||
|
||||
- The task sits in `tasks/stages/01_constructor.yml` after the `networks`, `design` and `service_provider` merges, after the `add_host` placements and after the token store, so the payload renders with the same `group_names`, `groups` and user records a role would see. Moving it earlier bakes stale values into the play.
|
||||
- The stage-wide task sits in `tasks/stages/01_constructor.yml` after the `networks`, `design` and `service_provider` merges, after the `add_host` placements and after the token store, so the payload renders with the same `group_names`, `groups` and user records a role would see. Moving it earlier bakes stale values into the play.
|
||||
- The carrier is parked again after every role in `tasks/utils/setup/role.yml` and after every persisted token in `roles/sys-token-store/tasks/write.yml`. A re-park whose key still matches serves the carried payload without rendering.
|
||||
- The tokens file enters the key as the SHA-256 of its content, memoised per inode, mtime and size, so a touch keeps the key. The carrier action opens the file before it computes the key, so an NFS client keys on revalidated attributes.
|
||||
- The action masks its own result: the fact is the whole rendered tree with every application's credentials, so it never reaches a log regardless of `MASK_CREDENTIALS_IN_LOGS`.
|
||||
- User records inside the payload are the token store's state at render time. Readers of tokens captured later in the play MUST go through `lookup('users', ...)` or `lookup('config', <app>, 'users.<key>...')`, both of which read the users cache fresh.
|
||||
- The carrier is private to the lookup. Consumers MUST NOT read `_INFINITO_APPLICATIONS_RENDERED`; they keep going through `lookup('applications')`, which is what makes the carrier transparent.
|
||||
|
||||
@@ -14,13 +14,17 @@ instead of rendering::
|
||||
for the merge block, which it drops again at the block's end, one after the
|
||||
``add_host`` tasks for the swarm bootstrap roles, parked only when
|
||||
``DEPLOYMENT_MODE`` is ``swarm``, and the stage-wide one after the token store. ``merged_applications_cache_key`` reads ``group_names`` and the
|
||||
tokens file's path, mtime and size, so a carrier whose groups or tokens no
|
||||
tokens file's path and content digest, so a carrier whose groups or tokens no
|
||||
longer match is rejected and the reader renders afresh::
|
||||
|
||||
- name: 🧊 Drop the merge-block carrier so later stages re-render
|
||||
applications_carrier:
|
||||
clear: true
|
||||
|
||||
``tasks/utils/setup/role.yml`` parks one after every role and
|
||||
``roles/sys-token-store/tasks/write.yml`` one after every persisted token;
|
||||
where the key still matches they serve the carried payload without rendering.
|
||||
|
||||
The result is masked by the action itself: the fact is the whole rendered
|
||||
tree, about 0.4 MB with every application's credentials, and printing it at
|
||||
``-v`` is neither credential debugging nor readable.
|
||||
@@ -39,7 +43,7 @@ from ansible.module_utils.parsing.convert_bool import boolean
|
||||
from ansible.plugins.action import ActionBase
|
||||
from ansible.plugins.loader import lookup_loader
|
||||
|
||||
from utils.cache.carrier import APPLICATIONS_RENDERED_FACT
|
||||
from utils.cache.carrier import APPLICATIONS_RENDERED_FACT, revalidate_tokens_file
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
@@ -64,6 +68,7 @@ class ActionModule(ActionBase):
|
||||
)
|
||||
return result
|
||||
|
||||
revalidate_tokens_file(task_vars)
|
||||
lookup = lookup_loader.get(
|
||||
"applications", loader=self._loader, templar=self._templar
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
ansible.builtin.file:
|
||||
path: "{{ FILE_TOKENS }}"
|
||||
state: touch
|
||||
modification_time: preserve
|
||||
access_time: preserve
|
||||
owner: "{{ SYS_TOKEN_STORE_OWNER }}"
|
||||
group: "{{ SYS_TOKEN_STORE_GROUP }}"
|
||||
mode: "{{ SYS_TOKEN_STORE_FILE_MODE }}"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
run_once: true
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
become: true
|
||||
register: sys_token_store_persisted
|
||||
|
||||
- name: "🪞 Mirror the store onto the controller, where every users lookup reads"
|
||||
ansible.builtin.copy:
|
||||
@@ -63,3 +64,8 @@
|
||||
delegate_to: localhost
|
||||
run_once: true
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
register: sys_token_store_mirrored
|
||||
|
||||
- name: 🧊 Re-render the applications SPOT the new token invalidated
|
||||
applications_carrier: {}
|
||||
when: sys_token_store_persisted is changed or sys_token_store_mirrored is changed
|
||||
@@ -6,3 +6,6 @@
|
||||
|
||||
- name: "🚿 flush handlers after {{ role_entry.app }}"
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: "🧊 Carry the applications SPOT past {{ role_entry.app }}"
|
||||
applications_carrier: {}
|
||||
@@ -56,6 +56,48 @@ class TestApplicationsCarrierActionPlugin(unittest.TestCase):
|
||||
self.assertEqual(variables, {"applications": {}})
|
||||
self.assertTrue(kwargs["carrier"])
|
||||
|
||||
def test_revalidates_the_tokens_file_before_rendering(self):
|
||||
order = []
|
||||
lookup = _FakeLookup({"key": ["roles", []], "applications": {}})
|
||||
lookup.run = lambda *args, **kwargs: order.append("render") or [{}]
|
||||
action = _make_action(_FakeTask())
|
||||
with (
|
||||
patch(
|
||||
"plugins.action.applications_carrier.ActionBase.run",
|
||||
autospec=True,
|
||||
return_value={},
|
||||
),
|
||||
patch(
|
||||
"plugins.action.applications_carrier.lookup_loader.get",
|
||||
return_value=lookup,
|
||||
),
|
||||
patch(
|
||||
"plugins.action.applications_carrier.revalidate_tokens_file",
|
||||
side_effect=lambda variables: order.append(("revalidate", variables)),
|
||||
),
|
||||
):
|
||||
action.run(task_vars={"FILE_TOKENS": "/x/tokens.yml"})
|
||||
|
||||
self.assertEqual(
|
||||
order, [("revalidate", {"FILE_TOKENS": "/x/tokens.yml"}), "render"]
|
||||
)
|
||||
|
||||
def test_clear_does_not_revalidate(self):
|
||||
action = _make_action(_FakeTask(args={"clear": True}))
|
||||
with (
|
||||
patch(
|
||||
"plugins.action.applications_carrier.ActionBase.run",
|
||||
autospec=True,
|
||||
return_value={},
|
||||
),
|
||||
patch(
|
||||
"plugins.action.applications_carrier.revalidate_tokens_file"
|
||||
) as revalidate,
|
||||
):
|
||||
action.run(task_vars={})
|
||||
|
||||
revalidate.assert_not_called()
|
||||
|
||||
def test_rejects_arguments(self):
|
||||
action = _make_action(_FakeTask(args={"foo": 1}))
|
||||
with (
|
||||
|
||||
+39
-4
@@ -10,9 +10,11 @@ files; this file pins the contract of base itself.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
from pathlib import Path
|
||||
|
||||
from utils.cache import _reset_cache_for_tests, base
|
||||
@@ -153,11 +155,13 @@ class TestStableVariablesSignature(unittest.TestCase):
|
||||
|
||||
|
||||
class TestTokensFileSignature(unittest.TestCase):
|
||||
def test_missing_file_returns_zeroed_signature(self):
|
||||
def setUp(self):
|
||||
base._reset()
|
||||
|
||||
def test_missing_file_returns_an_empty_digest(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sig = base._tokens_file_signature(Path(tmp) / "absent.yml")
|
||||
self.assertEqual(sig[1], 0)
|
||||
self.assertEqual(sig[2], 0)
|
||||
path = Path(tmp) / "absent.yml"
|
||||
self.assertEqual(base._tokens_file_signature(path), (str(path), ""))
|
||||
|
||||
def test_signature_changes_when_file_changes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -168,6 +172,37 @@ class TestTokensFileSignature(unittest.TestCase):
|
||||
sig_after = base._tokens_file_signature(path)
|
||||
self.assertNotEqual(sig_before, sig_after)
|
||||
|
||||
def test_a_touch_keeps_the_signature(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "tokens.yml"
|
||||
path.write_text("a: 1\n", encoding="utf-8")
|
||||
sig_before = base._tokens_file_signature(path)
|
||||
later = path.stat().st_mtime_ns + 5_000_000_000
|
||||
os.utime(path, ns=(later, later))
|
||||
self.assertEqual(base._tokens_file_signature(path), sig_before)
|
||||
|
||||
def test_an_unchanged_stat_serves_the_digest_without_reading(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "tokens.yml"
|
||||
path.write_text("a: 1\n", encoding="utf-8")
|
||||
sig_before = base._tokens_file_signature(path)
|
||||
with mock.patch.object(Path, "open", side_effect=AssertionError):
|
||||
self.assertEqual(base._tokens_file_signature(path), sig_before)
|
||||
|
||||
|
||||
class TestRevalidateTokensFile(unittest.TestCase):
|
||||
def test_opens_the_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "tokens.yml"
|
||||
path.write_text("a: 1\n", encoding="utf-8")
|
||||
with mock.patch.object(Path, "open", wraps=path.open) as opened:
|
||||
base._revalidate_tokens_file(path)
|
||||
opened.assert_called_once_with("rb")
|
||||
|
||||
def test_a_missing_file_is_ignored(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base._revalidate_tokens_file(Path(tmp) / "absent.yml")
|
||||
|
||||
|
||||
class TestRenderWithTemplar(unittest.TestCase):
|
||||
"""`_render_with_templar` is the single ansible-coupled symbol in
|
||||
|
||||
Vendored
+1
-1
@@ -26,7 +26,7 @@ The layers are orthogonal: `yaml.py` caches "this file's parsed root", the `appl
|
||||
|
||||
Both layers are process-wide in-process caches. They intentionally do NOT track on-disk file mtimes; CLI tools are short-lived and the assumption "the on-disk file does not change while my process runs" holds. The single exception is `utils.cache.yaml.dump_yaml`, which writes through and evicts the cached entry for the path it just wrote so a tool that edits a file mid-run sees the new content on the next read.
|
||||
|
||||
Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` starts empty in every worker. `get_merged_applications` therefore also accepts the play-scoped carrier fact `_INFINITO_APPLICATIONS_RENDERED` (payload plus cache key, parked once by the constructor stage) and serves it when the key matches. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) for the contract.
|
||||
Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` starts empty in every worker. `get_merged_applications` therefore also accepts the play-scoped carrier fact `_INFINITO_APPLICATIONS_RENDERED` (payload plus cache key, parked by the constructor stage and again after every role and every persisted token) and serves it when the key matches. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) for the contract.
|
||||
|
||||
## Test-Only Helpers 🧪
|
||||
|
||||
|
||||
Vendored
+43
-14
@@ -16,10 +16,12 @@ only when actually invoked.
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from plugins.filter.merge.with_defaults import (
|
||||
merge_with_defaults, # noqa: F401 re-exported
|
||||
@@ -28,8 +30,7 @@ from utils.paths import FILE_TOKENS
|
||||
|
||||
from . import PROJECT_ROOT, ROLES_DIR # noqa: F401
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import os
|
||||
_TOKENS_DIGESTS: dict[tuple, str] = {}
|
||||
|
||||
try:
|
||||
from ansible.parsing.vault import EncryptedString as _AnsibleEncryptedString
|
||||
@@ -125,18 +126,46 @@ def _stable_variables_signature(variables: Mapping[str, Any] | None) -> tuple:
|
||||
)
|
||||
|
||||
|
||||
def _tokens_file_signature(path: Path) -> tuple:
|
||||
"""Return a cheap stat-based signature for the tokens file.
|
||||
def _stat_identity(st: os.stat_result) -> tuple:
|
||||
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||
|
||||
The merged-users cache must invalidate whenever sys-token-store persists a
|
||||
new token — otherwise downstream `lookup('users', ...)` returns stale tokens
|
||||
within the same play. stat() is cheap and captures in-place writes.
|
||||
|
||||
def _tokens_file_signature(path: Path) -> tuple:
|
||||
"""Return ``(path, sha256 of the content)`` for the tokens file.
|
||||
|
||||
Args:
|
||||
path: the tokens file.
|
||||
|
||||
Returns:
|
||||
``(str(path), "")`` when the file cannot be read.
|
||||
"""
|
||||
try:
|
||||
st = path.stat()
|
||||
except (FileNotFoundError, OSError):
|
||||
return (str(path), 0, 0)
|
||||
return (str(path), st.st_mtime_ns, st.st_size)
|
||||
identity = (str(path), *_stat_identity(path.stat()))
|
||||
except OSError:
|
||||
return (str(path), "")
|
||||
digest = _TOKENS_DIGESTS.get(identity)
|
||||
if digest is None:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
identity = (str(path), *_stat_identity(os.fstat(handle.fileno())))
|
||||
digest = hashlib.sha256(handle.read()).hexdigest()
|
||||
except OSError:
|
||||
return (str(path), "")
|
||||
_TOKENS_DIGESTS[identity] = digest
|
||||
return (str(path), digest)
|
||||
|
||||
|
||||
def _revalidate_tokens_file(path: Path) -> None:
|
||||
"""Open and close the tokens file so an NFS client refreshes its attributes.
|
||||
|
||||
Args:
|
||||
path: the tokens file.
|
||||
"""
|
||||
try:
|
||||
with path.open("rb"):
|
||||
pass
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def _deep_merge(base: Any, override: Any) -> Any:
|
||||
@@ -287,5 +316,5 @@ def _render_with_templar(
|
||||
|
||||
|
||||
def _reset() -> None:
|
||||
"""No-op kept so the `data._reset_cache_for_tests` facade can call every
|
||||
domain module uniformly. This module owns no cache."""
|
||||
"""Clear the tokens-file digest memo."""
|
||||
_TOKENS_DIGESTS.clear()
|
||||
Vendored
+13
-1
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from .base import (
|
||||
_cache_key,
|
||||
_resolve_roles_dir,
|
||||
_revalidate_tokens_file,
|
||||
_stable_variables_signature,
|
||||
_tokens_file_signature,
|
||||
)
|
||||
@@ -42,7 +43,7 @@ def merged_applications_cache_key(
|
||||
|
||||
Returns:
|
||||
``(resolved roles dir, stable variables signature extended by the
|
||||
tokens file's path, mtime and size)``.
|
||||
tokens file's path and content digest)``.
|
||||
"""
|
||||
from .users import _resolve_tokens_file
|
||||
|
||||
@@ -53,6 +54,17 @@ def merged_applications_cache_key(
|
||||
)
|
||||
|
||||
|
||||
def revalidate_tokens_file(variables: Mapping[str, Any] | None) -> None:
|
||||
"""Refresh an NFS client's attributes of the tokens file before a carrier is keyed.
|
||||
|
||||
Args:
|
||||
variables: The play's variable scope the tokens file resolves from.
|
||||
"""
|
||||
from .users import _resolve_tokens_file
|
||||
|
||||
_revalidate_tokens_file(_resolve_tokens_file(variables))
|
||||
|
||||
|
||||
def _carried_applications(
|
||||
variables: Mapping[str, Any], cache_key: tuple[str, tuple]
|
||||
) -> dict[str, Any] | None:
|
||||
|
||||
Reference in new issue
Block a user