diff --git a/docs/contributing/artefact/files/plugins/lookup/applications.md b/docs/contributing/artefact/files/plugins/lookup/applications.md index 93b468d4dc..f00cd885c1 100644 --- a/docs/contributing/artefact/files/plugins/lookup/applications.md +++ b/docs/contributing/artefact/files/plugins/lookup/applications.md @@ -33,14 +33,14 @@ 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`, `SYSTEM_EMAIL_DOMAIN` or `group_names`, or changed tokens in the store the users are hydrated from). +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`). - 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`, after every nested application load in `tasks/utils/load/app.yml`, and after every store write in `roles/sys-token-store/tasks/write_many.yml`, which `write.yml` runs for a single token. The first two run on every host of the include, also on hosts the loaded role skips. A re-park whose key still matches serves the carried payload without rendering. - The action renders with `application_id` removed from its variables, so the carried tree holds the play-scope result of every consumer-aligned `lookup('tls', ...)`, whichever role parks it. -- The token store enters the key as the SHA-256 of exactly the tokens the users are hydrated from: the controller's tokens file until `sys-token-store` loads, the `sys_token_store_users` fact after. Both sources key alike when they hold the same tokens, so loading an unchanged store keeps the carrier, and 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 token store is not part of the key: a store write keeps the applications carrier and re-renders only the users carrier (see [users.md](users.md)). - 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', , 'users....')`, both of which key the users on the current token store. +- User records inside the payload are the token store's state at render time, which can predate every store write of the play. Readers of tokens MUST go through `lookup('users', ...)` or `lookup('config', , 'users....')`, both of which key the users on the current token store. Application config MUST NOT derive a value from a token. - The action parks the users carrier `_INFINITO_USERS_RENDERED` at the same points, after the applications render; `clear: true` drops both. See [users.md](users.md). - 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. - The carrier is the only materialised merged view a play MAY hold. The rule against a top-level `applications` fact above is unchanged. diff --git a/docs/contributing/artefact/files/plugins/lookup/users.md b/docs/contributing/artefact/files/plugins/lookup/users.md index 22fdab26b2..43f866b0ce 100644 --- a/docs/contributing/artefact/files/plugins/lookup/users.md +++ b/docs/contributing/artefact/files/plugins/lookup/users.md @@ -24,7 +24,8 @@ The `users` lookup is the ONLY supported runtime entry point for merged user con The [applications_carrier](../../../../../../plugins/action/applications_carrier.py) action parks the rendered users next to the applications in the host fact `_INFINITO_USERS_RENDERED`, at every point listed in [applications.md](applications.md), and `clear: true` drops both facts. `get_merged_users` serves the fact to a templated read when its key matches and renders otherwise. -- The key is the applications carrier key plus `inventory_hostname`: a changed `applications`, `users`, `DOMAIN_PRIMARY`, `SYSTEM_EMAIL_DOMAIN`, `group_names`, token store or host rejects the carrier. +- The key is the applications carrier key plus the token store and `inventory_hostname`: a changed `applications`, `users`, `DOMAIN_PRIMARY`, `SYSTEM_EMAIL_DOMAIN`, `group_names`, token store or host rejects the carrier. +- The token store enters the key as the SHA-256 of exactly the tokens the users are hydrated from: the controller's tokens file until `sys-token-store` loads, the `sys_token_store_users` fact after. Both sources key alike when they hold the same tokens, so loading an unchanged store keeps the carrier, and a touch keeps the key. The carrier action opens the file before it computes the key, so an NFS client keys on revalidated attributes. - A read without a templar (the raw users inside an applications render) never takes the carrier. - The carrier is private to the lookup. Consumers MUST NOT read `_INFINITO_USERS_RENDERED`; they keep going through `lookup('users')` or `lookup('config', , 'users....')`. diff --git a/plugins/action/applications_carrier.py b/plugins/action/applications_carrier.py index 5ad3fbf84c..39cae6ed84 100644 --- a/plugins/action/applications_carrier.py +++ b/plugins/action/applications_carrier.py @@ -7,7 +7,7 @@ renders in the worker of one constructor task and returns both payloads as host facts; every later worker inherits them at fork time and ``get_merged_applications`` / ``get_merged_users`` serve them instead of rendering. The users carrier is keyed like the applications carrier plus -``inventory_hostname``:: +the token store digest and ``inventory_hostname``:: - name: 🧊 Render the applications SPOT once and carry it into every task's worker applications_carrier: {} @@ -15,11 +15,12 @@ rendering. The users carrier is keyed like the applications carrier plus ``tasks/stages/01_constructor.yml`` parks three of them: a block-scoped one 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 a -digest of the tokens the users are hydrated from (the tokens file, or the -``sys_token_store_users`` fact once the token store loaded; both key alike for -the same tokens), so a carrier whose groups or tokens no longer match is -rejected and the reader renders afresh:: +``DEPLOYMENT_MODE`` is ``swarm``, and the stage-wide one after the token store. ``merged_applications_cache_key`` reads ``group_names``, so a +carrier whose groups no longer match is rejected and the reader renders +afresh. ``merged_users_cache_key`` adds a digest of the tokens the users are +hydrated from (the tokens file, or the ``sys_token_store_users`` fact once the +token store loaded; both key alike for the same tokens), so changed tokens +reject the users carrier only:: - name: 🧊 Drop the merge-block carrier so later stages re-render applications_carrier: @@ -30,7 +31,7 @@ rejected and the reader renders afresh:: ``roles/sys-token-store/tasks/write_many.yml`` one after every store write; where the key still matches they serve the carried payload without rendering, and a users carrier that still matches is left in place instead of being -returned again. +returned again. A store write therefore re-renders the users alone. The result is masked by the action itself: the facts are the whole rendered trees, about 0.4 MB with every application's and user's credentials, and diff --git a/roles/sys-token-store/README.md b/roles/sys-token-store/README.md index 8a83ec3aba..b8d2695cc8 100644 --- a/roles/sys-token-store/README.md +++ b/roles/sys-token-store/README.md @@ -103,7 +103,7 @@ Empty tokens are rejected explicitly. `write.yml` hands its token to `write_many ### `write_many.yml` -Persists several tokens with one store write and at most one re-render of the applications SPOT. +Persists several tokens with one store write and at most one re-render of the users SPOT. **Input** @@ -112,7 +112,7 @@ Persists several tokens with one store write and at most one re-render of the ap **Effects** - Merges every entry into the store in list order, so a later entry for the same user and app wins -- Writes the store file and re-renders the applications SPOT once, and only when the merged store differs from the loaded one +- Writes the store file and re-renders the users SPOT once, and only when the merged store differs from the loaded one --- diff --git a/roles/sys-token-store/tasks/write_many.yml b/roles/sys-token-store/tasks/write_many.yml index 458e08225c..4ff2e901c2 100644 --- a/roles/sys-token-store/tasks/write_many.yml +++ b/roles/sys-token-store/tasks/write_many.yml @@ -61,6 +61,6 @@ when: sys_token_store_users_merged != sys_token_store_users no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" -- name: 🧊 Re-render the applications SPOT the new tokens invalidated +- name: 🧊 Re-render the users SPOT the new tokens invalidated applications_carrier: {} when: sys_token_store_persisted is changed or sys_token_store_mirrored is changed diff --git a/tests/unit/python/roles/sys-token-store/test_write_many.py b/tests/unit/python/roles/sys-token-store/test_write_many.py index 728eabea4d..a02668d216 100644 --- a/tests/unit/python/roles/sys-token-store/test_write_many.py +++ b/tests/unit/python/roles/sys-token-store/test_write_many.py @@ -15,7 +15,7 @@ EMPTY = "🛑 Abort if a token is empty (do not persist empties)" PERSIST = "💾 Persist store (idempotent, single-writer via STACK_HOST)" MIRROR = "🪞 Mirror the store onto the controller, where every users lookup reads" HAND_OVER = "🔄 Hand the written store to the lookups of the rest of the play" -RERENDER = "🧊 Re-render the applications SPOT the new tokens invalidated" +RERENDER = "🧊 Re-render the users SPOT the new tokens invalidated" USERS = { "alice": {"tokens": {"web-app-a": "old-a"}, "note": "kept"}, diff --git a/tests/unit/python/utils/cache/test_applications.py b/tests/unit/python/utils/cache/test_applications.py index ba829a0a67..071e47d5fe 100644 --- a/tests/unit/python/utils/cache/test_applications.py +++ b/tests/unit/python/utils/cache/test_applications.py @@ -390,7 +390,7 @@ class TestGetMergedApplicationsCarrierFact(unittest.TestCase): merged["web-app-foo"]["services"]["foo"]["image"], "override" ) - def test_a_changed_tokens_file_rejects_the_carrier(self): + def test_a_changed_tokens_file_keeps_the_carrier(self): with tempfile.TemporaryDirectory() as tmp: roles = _seed_minimal_roles(Path(tmp)) tokens = Path(tmp) / "tokens.yml" @@ -398,9 +398,10 @@ class TestGetMergedApplicationsCarrierFact(unittest.TestCase): variables = self._variables() variables["FILE_TOKENS"] = str(tokens) key = merged_applications_cache_key(variables, roles_dir=roles) + carried = {"web-app-foo": {"carried": True}} variables[APPLICATIONS_RENDERED_FACT] = { "key": [key[0], list(key[1])], - "applications": {"web-app-foo": {"carried": True}}, + "applications": carried, } tokens.write_text( "users: {no-reply: {tokens: {web-app-stalwart: minted}}}\n", @@ -409,7 +410,7 @@ class TestGetMergedApplicationsCarrierFact(unittest.TestCase): merged = cache_apps.get_merged_applications( variables=variables, roles_dir=roles, templar=None ) - self.assertNotIn("carried", merged["web-app-foo"]) + self.assertIs(merged, carried) def _park_from_the_tokens_file(self, tmp: Path, token: str) -> tuple: roles = _seed_minimal_roles(tmp) @@ -441,28 +442,31 @@ class TestGetMergedApplicationsCarrierFact(unittest.TestCase): ) self.assertIs(merged, carried) - def test_the_store_fact_with_other_tokens_than_the_file_rejects_the_carrier( + def test_the_store_fact_with_other_tokens_than_the_file_keeps_the_carrier( self, ): with tempfile.TemporaryDirectory() as tmp: - roles, variables, _ = self._park_from_the_tokens_file(Path(tmp), "minted") + roles, variables, carried = self._park_from_the_tokens_file( + Path(tmp), "minted" + ) variables["sys_token_store_users"] = { "no-reply": {"tokens": {"web-app-stalwart": "rotated"}} } merged = cache_apps.get_merged_applications( variables=variables, roles_dir=roles, templar=None ) - self.assertNotIn("carried", merged["web-app-foo"]) + self.assertIs(merged, carried) - def test_a_changed_store_fact_rejects_the_carrier(self): + def test_a_changed_store_fact_keeps_the_carrier(self): with tempfile.TemporaryDirectory() as tmp: roles = _seed_minimal_roles(Path(tmp)) variables = self._variables() variables["sys_token_store_users"] = {} key = merged_applications_cache_key(variables, roles_dir=roles) + carried = {"web-app-foo": {"carried": True}} variables[APPLICATIONS_RENDERED_FACT] = { "key": [key[0], list(key[1])], - "applications": {"web-app-foo": {"carried": True}}, + "applications": carried, } variables["sys_token_store_users"] = { "no-reply": {"tokens": {"web-app-stalwart": "minted"}} @@ -470,7 +474,7 @@ class TestGetMergedApplicationsCarrierFact(unittest.TestCase): merged = cache_apps.get_merged_applications( variables=variables, roles_dir=roles, templar=None ) - self.assertNotIn("carried", merged["web-app-foo"]) + self.assertIs(merged, carried) def test_malformed_carrier_is_ignored(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/unit/python/utils/cache/users/test_module.py b/tests/unit/python/utils/cache/users/test_module.py index ee1ce655d2..a73dce394e 100644 --- a/tests/unit/python/utils/cache/users/test_module.py +++ b/tests/unit/python/utils/cache/users/test_module.py @@ -519,6 +519,46 @@ class TestGetMergedUsersCarrierFact(unittest.TestCase): merged["rendered"]["foo"]["tokens"]["web-app-foo"], "rotated" ) + def _park_from_the_tokens_file(self, tmp: Path, token: str) -> tuple: + roles = _seed_minimal_user_role(tmp) + tokens = tmp / "tokens.yml" + tokens.write_text( + f"users: {{foo: {{tokens: {{web-app-foo: {token}}}}}}}\n", + encoding="utf-8", + ) + variables = self._variables() + del variables[cache_users_store.STORE_FACT] + variables["FILE_TOKENS"] = str(tokens) + carried = self._park(roles, variables, {"foo": {"carried": True}}) + return roles, variables, tokens, carried + + def test_a_changed_tokens_file_rejects_the_carrier(self): + with tempfile.TemporaryDirectory() as tmp: + roles, variables, tokens, _ = self._park_from_the_tokens_file( + Path(tmp), "minted" + ) + tokens.write_text( + "users: {foo: {tokens: {web-app-foo: rotated-token}}}\n", + encoding="utf-8", + ) + merged, renders = self._merged(roles, variables) + self.assertEqual(renders, 1) + self.assertEqual( + merged["rendered"]["foo"]["tokens"]["web-app-foo"], "rotated-token" + ) + + def test_the_store_fact_with_the_file_tokens_keeps_the_carrier(self): + with tempfile.TemporaryDirectory() as tmp: + roles, variables, _, carried = self._park_from_the_tokens_file( + Path(tmp), "minted" + ) + variables[cache_users_store.STORE_FACT] = { + "foo": {"tokens": {"web-app-foo": "minted"}} + } + merged, renders = self._merged(roles, variables) + self.assertIs(merged, carried) + self.assertEqual(renders, 0) + def test_a_rotated_inventory_password_rejects_the_carrier(self): with tempfile.TemporaryDirectory() as tmp: roles = _seed_minimal_user_role(Path(tmp)) diff --git a/utils/cache/README.md b/utils/cache/README.md index 2ecbb1ccb5..6e81076853 100644 --- a/utils/cache/README.md +++ b/utils/cache/README.md @@ -11,7 +11,7 @@ This package contains the in-process cache layers Infinito.Nexus uses to keep CL | [base.py](base.py) | Cross-cutting helpers: filesystem constants (`PROJECT_ROOT`, `ROLES_DIR`, `DEFAULT_TOKENS_FILE`), `_deep_merge`, cache-key + content-fingerprint signatures, the cross-domain re-entry guard, and the templar-render machinery. The only ansible-coupled symbol is `_render_with_templar`, which lazy-imports its dependency. | | [applications.py](applications.py) | Per-app variants + defaults + `get_merged_applications`. **Strictly ansible-free at import time** so the GitHub Actions runner-host CLI path (`cli.administration.deploy.development.init` → `plan_dev_inventory_matrix` → `get_variants`) keeps working without ansible installed. | | [carrier.py](carrier.py) | The play-scoped carriers of the rendered applications and users payloads: the fact names `_INFINITO_APPLICATIONS_RENDERED` and `_INFINITO_USERS_RENDERED`, `merged_applications_cache_key`, `merged_users_cache_key`, and the readers `get_merged_applications` and `get_merged_users` consult before they render. | -| [users/](users/) | User definitions, token store hydration, alias materialization, `get_user_defaults`, `get_merged_users` (package; `users/placeholders.py` substitutes `DOMAIN_PRIMARY` / `ORGANIZATION` / `SOFTWARE_NAME` ahead of the templar render pass; `users/store.py` reads the `sys_token_store_users` fact and digests the tokens the users are hydrated from, the digest the users and carrier keys share). | +| [users/](users/) | User definitions, token store hydration, alias materialization, `get_user_defaults`, `get_merged_users` (package; `users/placeholders.py` substitutes `DOMAIN_PRIMARY` / `ORGANIZATION` / `SOFTWARE_NAME` ahead of the templar render pass; `users/store.py` reads the `sys_token_store_users` fact and digests the tokens the users are hydrated from, the digest the users cache and users carrier keys share). | | [domains.py](domains.py) | Canonical-domains map derived from the merged applications view: `get_merged_domains`. A read issued while the applications render is in progress (the re-entry guard is set) sees the unrendered view, so it is cached under its own key and never answers a later read. | | [`__init__.py`](__init__.py) | Owns the package-level `_reset_cache_for_tests()` orchestrator that clears every cache plus the shared fingerprint memo in one call. | @@ -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` and `_MERGED_USERS_CACHE` start 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, every nested application load and every persisted token) and serves it when the key matches. The same action parks `_INFINITO_USERS_RENDERED`, which `get_merged_users` serves to templated reads under the applications key plus `inventory_hostname`. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) and [users.md](../../docs/contributing/artefact/files/plugins/lookup/users.md) for the contract. +Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` and `_MERGED_USERS_CACHE` start 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, every nested application load and every persisted token) and serves it when the key matches. The same action parks `_INFINITO_USERS_RENDERED`, which `get_merged_users` serves to templated reads under the applications key plus the token store digest and `inventory_hostname`. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) and [users.md](../../docs/contributing/artefact/files/plugins/lookup/users.md) for the contract. While one render runs, the re-entry guard also holds that render's unrendered view and its cache key. A nested `get_merged_applications` call from a lookup inside a role template (`config`, `tls`, `domain`, ...) with the same key returns that view instead of copying and merging the role defaults again. The view is shared by every nested call of the render, so callers MUST NOT mutate it. The guard drops it when the render ends. @@ -84,7 +84,7 @@ flowchart TD proc -.->|"TRIP-WIRE: read BEFORE the
host's own fact, so an inherited
entry outranks it"| fact render -.->|"TRIP-WIRE: main process renders
task names for one host; forked
workers inherit that entry"| proc - key -.->|"TRIP-WIRE: carrier.py:118 stringifies
only one side, so a non-scalar key
part silently never matches again"| fact + key -.->|"TRIP-WIRE: carrier.py:121 stringifies
only one side, so a non-scalar key
part silently never matches again"| fact ``` The key covers `group_names`. `domains.py` derives from the applications view and shares that dependency; `users` reads `group_names` nowhere. diff --git a/utils/cache/carrier.py b/utils/cache/carrier.py index 23f7972e70..b8fdf5e1d8 100644 --- a/utils/cache/carrier.py +++ b/utils/cache/carrier.py @@ -37,21 +37,17 @@ def merged_applications_cache_key( Args: variables: The play's variable scope. Only the subset that - `_stable_variables_signature` reads, and the token store the users - are hydrated from, influence the key. + `_stable_variables_signature` reads influences the key; the token + store keys the users carrier instead (`merged_users_cache_key`). roles_dir: Roles tree the payload is built from; defaults to the repository's `roles/`. Returns: - ``(resolved roles dir, stable variables signature extended by the - token store signature)``. + ``(resolved roles dir, stable variables signature)``. """ - from .users import _resolve_tokens_file, _store_signature - - tokens = _store_signature(variables, _resolve_tokens_file(variables)) return ( _cache_key(_resolve_roles_dir(roles_dir=roles_dir)), - (*_stable_variables_signature(variables), *(str(part) for part in tokens)), + _stable_variables_signature(variables), ) @@ -63,18 +59,25 @@ def merged_users_cache_key( """Key under which one rendered users payload stays valid. Args: - variables: The play's variable scope. The applications key's inputs - plus ``inventory_hostname`` influence the key. + variables: The play's variable scope. The applications key's inputs, + the token store the users are hydrated from and + ``inventory_hostname`` influence the key. roles_dir: Roles tree the payload is built from; defaults to the repository's `roles/`. Returns: ``(resolved roles dir, applications key signature extended by the - host the payload was rendered for)``. + token store signature and the host the payload was rendered for)``. """ + from .users import _resolve_tokens_file, _store_signature + roles_key, signature = merged_applications_cache_key(variables, roles_dir=roles_dir) + tokens = _store_signature(variables, _resolve_tokens_file(variables)) host = (variables or {}).get("inventory_hostname") - return (roles_key, (*signature, str(host or ""))) + return ( + roles_key, + (*signature, *(str(part) for part in tokens), str(host or "")), + ) def revalidate_tokens_file(variables: Mapping[str, Any] | None) -> None: