Files
kevinveenbirkenbachandClaude Opus 5 46110f4311 perf(swarm-test): rotate the round's credentials, not every block in the file
The matrix gate rotates credentials between the two deploy passes so PASS 2
has to carry fresh values. It rotated every application block in the host_vars
file, and on a matrix inventory that is not the round. Provision applies its
--include as a strict intersection (filters.py:57-62), so it generates
credentials for at most derive_includes, while mirror_overrides.py adds a block
per mirror artefact carrying only image and version. Rotating those costs one
credential subprocess each for values provision never wrote.

matrix.py now passes --include derive_includes(app_id, variants=round_variants),
the same call 02_provision_inventory.sh:11 feeds provision, from the same
round_variants the provision step got one call earlier. The rotation set is
therefore a superset of what provision generated: every database_password still
rotates, because utils/manager/inventory.py:236-242 writes it under an id from
that same filtered list. Measured for web-app-wordpress: 35 ids.

The scope stops at the credentials. reset_credentials takes credential_ids
beside application_ids because the two halves shared one list, and narrowing it
narrowed _drop_user_passwords too: that resolves through
required_user_policies(roles_dir, application_ids), so 60 of 66 declared users
would have stopped rotating, root, backup and every mcp-* bot among them. The
user half keeps the full document list and is byte-identical to before.

The test fixture declared the same users in both roles, which made the
regression test pass against the regression. web-app-keycloak now declares a
user web-app-a does not, so the assertion has something to bite on.

The CLI aborts when --include matches no block instead of rotating nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:32:02 +02:00

260 lines
8.8 KiB
Python

"""Rotate the credentials an inventory generated, so a redeploy has to carry them.
Provisioning fills a missing credential and leaves an existing one alone. That
keeps a redeploy stable, and it also means no deploy ever runs against a secret
that moved: every pass reads the value the first pass wrote. Dropping the
generated values and regenerating them between two deploy passes turns the
second pass into a test of the update path, where each role has to push its new
secret into the application and the database it already created.
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
from ruamel.yaml.comments import CommentedMap
from cli.administration.inventory.credentials.vault import is_ruamel_vault
from utils.manager.credential_key import CREDENTIALS_KEY, SECRETS_KEY
from utils.roles.mapping import ROLE_FILE_META_SECRETS, ROLE_FILE_VARS_MAIN
from .credentials_generator import generate_credentials_for_roles
from .ruamel_io import dump_document, load_document
from .users_generator import generate_user_passwords, required_user_policies
if TYPE_CHECKING:
from pathlib import Path
ROTATABLE_KEY = "rotatable"
def _pinned_paths(
schema: CommentedMap, prefix: tuple[str, ...]
) -> set[tuple[str, ...]]:
"""Return the credential paths ``schema`` marks ``rotatable: false``.
Args:
schema: a ``credentials`` branch of a role's ``meta/secrets.yml``.
prefix: the path walked so far.
Returns:
The pinned paths, each as a tuple of keys.
"""
pinned: set[tuple[str, ...]] = set()
for key, meta in schema.items():
if not isinstance(meta, CommentedMap):
continue
if meta.get(ROTATABLE_KEY) is False:
pinned.add((*prefix, key))
else:
pinned |= _pinned_paths(meta, (*prefix, key))
return pinned
def _pinned_credentials(roles_dir: Path) -> dict[str, set[tuple[str, ...]]]:
"""Map application id to the credentials its role refuses to have rotated.
Args:
roles_dir: directory the roles live in.
Returns:
Application id to the set of pinned credential paths.
"""
pinned: dict[str, set[tuple[str, ...]]] = {}
for role_dir in sorted(roles_dir.iterdir()):
schema = load_document(role_dir / ROLE_FILE_META_SECRETS).get(CREDENTIALS_KEY)
if not isinstance(schema, CommentedMap):
continue
paths = _pinned_paths(schema, ())
if not paths:
continue
application_id = load_document(role_dir / ROLE_FILE_VARS_MAIN).get(
"application_id"
)
if application_id:
pinned[str(application_id)] = paths
return pinned
def _drop_generated(
node: CommentedMap, pinned: set[tuple[str, ...]], prefix: tuple[str, ...]
) -> int:
"""Remove every vault-encrypted leaf below ``node``.
Args:
node: a ``credentials`` branch of the inventory.
pinned: paths the role marked ``rotatable: false``.
prefix: the path walked so far.
Returns:
How many values were removed.
A `plain` credential the operator supplies stays unencrypted in host_vars
(an empty string while it was never set), so restricting the drop to
vaulted leaves keeps operator input out of the rotation.
"""
dropped = 0
for key in list(node.keys()):
path = (*prefix, key)
if path in pinned:
print(
f"[WARN] not rotated, pinned as rotatable: false: {'.'.join(path)}",
file=sys.stderr,
)
continue
value = node.get(key)
if isinstance(value, CommentedMap):
dropped += _drop_generated(value, pinned, path)
elif is_ruamel_vault(value):
del node[key]
dropped += 1
return dropped
def _drop_app_credentials(
document: CommentedMap,
exclude: set[str],
pinned: dict[str, set[tuple[str, ...]]],
application_ids: list[str],
) -> int:
"""Remove the generated credentials of the applications being rotated.
Args:
document: the host_vars document.
exclude: application ids to leave untouched.
pinned: per-application credential paths that must survive a rotation.
application_ids: the ids the caller regenerates afterwards. Passing a
narrower set here than the caller regenerates would drop
credentials nothing writes back.
Returns:
How many values were removed.
"""
applications = document.get("applications")
if not isinstance(applications, CommentedMap):
return 0
rotated = set(application_ids)
dropped = 0
for application_id, application in applications.items():
if application_id in exclude or not isinstance(application, CommentedMap):
continue
if str(application_id) not in rotated:
continue
secrets = application.get(SECRETS_KEY)
if not isinstance(secrets, CommentedMap):
continue
credentials = secrets.get(CREDENTIALS_KEY)
if isinstance(credentials, CommentedMap):
dropped += _drop_generated(
credentials, pinned.get(str(application_id), set()), ()
)
return dropped
def _drop_user_passwords(
document: CommentedMap,
roles_dir: Path,
application_ids: list[str],
exclude: set[str],
) -> int:
"""Remove the pinned password of every user the resolved roles declare.
Args:
document: the host_vars document.
roles_dir: directory the roles live in.
application_ids: the roles resolved into this inventory.
exclude: user keys to leave untouched.
Returns:
How many passwords were removed.
Only declared users are dropped, because only those get a new password
pinned afterwards; a user that exists solely in the inventory would lose
its password with nothing to put it back.
"""
users = document.get("users")
if not isinstance(users, CommentedMap):
return 0
dropped = 0
for username in required_user_policies(roles_dir, application_ids):
if username in exclude:
continue
user = users.get(username)
if isinstance(user, CommentedMap) and user.pop("password", None) is not None:
dropped += 1
return dropped
def reset_credentials(
application_ids: list[str],
roles_dir: Path,
host_vars_file: Path,
vault_password_file: Path,
project_root: Path,
env: dict[str, str] | None,
schema: bool,
users: bool,
exclude: set[str],
workers: int = 4,
app_variants: dict[str, int] | None = None,
credential_ids: list[str] | None = None,
) -> int:
"""Replace every generated credential in ``host_vars_file`` with a fresh one.
Args:
application_ids: the roles resolved into this inventory.
roles_dir: directory the roles live in.
host_vars_file: inventory file the credentials are rewritten in.
vault_password_file: vault password used to encrypt each value.
project_root: repository root the role resolver works from.
env: environment the credential subprocesses inherit.
schema: rotate ``applications.<app>.credentials.*``.
users: rotate ``users.<name>.password``.
exclude: application ids and user keys to leave untouched.
workers: worker threads for credentials generation.
app_variants: variant index per app, as at provision time.
credential_ids: application ids whose credentials rotate; ``None``
rotates all of ``application_ids``. User passwords ignore it and
always resolve through the full ``application_ids``.
Returns:
How many values were rotated.
"""
if not schema and not users:
raise SystemExit("reset_credentials: pick at least one of schema, users")
rotated_ids = application_ids if credential_ids is None else credential_ids
document = load_document(host_vars_file)
dropped = 0
if schema:
dropped += _drop_app_credentials(
document, exclude, _pinned_credentials(roles_dir), rotated_ids
)
if users:
dropped += _drop_user_passwords(document, roles_dir, application_ids, exclude)
dump_document(host_vars_file, document)
if schema:
generate_credentials_for_roles(
application_ids=rotated_ids,
roles_dir=roles_dir,
host_vars_file=host_vars_file,
vault_password_file=vault_password_file,
project_root=project_root,
env=env,
workers=workers,
app_variants=app_variants,
)
if users:
generate_user_passwords(
roles_dir=roles_dir,
application_ids=application_ids,
host_vars_file=host_vars_file,
vault_password_file=vault_password_file,
)
return dropped