Files
computer-playbook/utils/tests/swarm/write/extras.py
T
evangelostsak 93052c4dec fix(swarm-test): escalate with the password the account was given
user-administrator creates the account from users.administrator.password,
which the development inventory states outright. The value ansible sends for
become is generated per host and never reaches the account, so every sudo
over the mesh was refused. Nothing noticed before because the container
connection escalates to root without a password.

The extras already carry that inventory's users, so the same value now rides
the deploy-facing twin as ansible_become_password.
2026-09-27 23:55:58 +02:00

223 lines
8.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Runtime-only extras YAML for the swarm-NFS test pipeline.
Companion to the static ``inventories/development/swarm.yml``; both
are loaded by the deploy step. Generates an ed25519 keypair at
``KEY_PATH`` if missing so the public half can land in
``users.administrator.authorized_keys``.
Inputs (env): ``NFS_IP``, ``MGR_IP``, ``MGR``, ``OUT_PATH`` (default
``/tmp/swarm-nfs-extras.yml``), ``KEY_PATH`` (default
``/tmp/swarm-nfs-admin.key``). A second ed25519 keypair is generated at
``INFINITO_SWARM_BACKUP_KEY`` (SPOT: default.env) and its public half lands
in ``users.backup.authorized_keys`` so the DR drill's backup host can pull
over the ``user-backup`` ssh-wrapper. The ``applications`` block written into
the extras file carries the ``local-2-device`` mount/target/source device
paths, which is all the DR drill reads back from it. The deploy-facing
overrides, including ``remote-2-local.backup_providers`` (derived per round
from ``utils.tests.swarm.backup_repos``), are baked by the matrix
orchestrator into the provisioner's host_vars merge (INFINITO_VARS_PAYLOAD),
NOT into the extras file: extra-vars replace the whole inventory
``applications`` dict and would strip every generated credential. ``users``
carries the same hazard for the same reason, and its authorized_keys travel
the same merge channel. The deploy-facing twin ``<OUT_PATH stem>.deploy.yml``
therefore carries everything except ``applications`` and ``users``.
``ansible_become_password`` rides that twin so the passes that connect over
ssh escalate with the password the administrator account was actually given.
The account is created from ``users.administrator.password``, which this
inventory states outright, while the value ansible would otherwise send is
generated per host and never reaches the account.
"""
from __future__ import annotations
import copy
import os
import subprocess
import sys
from pathlib import Path
from cli.meta.runtime import detect_runtime
from utils import PROJECT_ROOT
from utils.cache.yaml import dump_yaml, load_yaml
from utils.env.parser import parse_static_env
from utils.paths import DIR_BACKUPS
_DEFAULT_INVENTORY = PROJECT_ROOT / "inventories" / "development" / "default.yml"
def device_applications_overrides() -> dict:
"""Application overrides for svc-bkp-local-2-device.
Returns:
dict with the ``applications`` subtree the DR drill reads back from
the extras file (mount/target) and the backup host deploys.
"""
return {
"svc-bkp-local-2-device": {
"services": {
"local-2-device": {
"mount": "/mnt/backup-to-device",
"target": "/infinito",
"source": str(DIR_BACKUPS),
},
},
},
}
def backup_applications_overrides(providers: list[str]) -> dict:
"""Application overrides for the backup-host roles the DR drill triggers.
Args:
providers: node IPs svc-bkp-remote-2-local pulls from, as derived by
``utils.tests.swarm.backup_repos.backup_provider_ips``.
Returns:
dict with the ``applications`` subtree for svc-bkp-remote-2-local
(backup_providers) and svc-bkp-local-2-device (device paths).
"""
return {
"svc-bkp-remote-2-local": {
"services": {
"remote-2-local": {
"backup_providers": list(providers),
},
},
},
**device_applications_overrides(),
}
def _ensure_keypair(key_path: Path) -> str:
if not key_path.is_file():
subprocess.run(
[
"ssh-keygen",
"-t",
"ed25519",
"-N",
"",
"-f",
str(key_path),
"-C",
"swarm-test",
"-q",
],
check=True,
)
pub = Path(f"{key_path}.pub").read_text(encoding="utf-8") # nocheck: cache-read
return pub.strip()
def ensure_swarm_keypairs() -> dict[str, str]:
"""Create the swarm-test keypairs if they are absent and return the public keys.
Returns:
Public key per user name, for ``administrator`` and ``backup``.
The matrix orchestrator needs these before it provisions, so that the
authorized_keys reach host_vars through the merging ``--vars`` channel
rather than through the replacing extra-vars file.
"""
static_env = parse_static_env(PROJECT_ROOT / "default.env")
admin_key = Path(os.environ.get("KEY_PATH", "/tmp/swarm-nfs-admin.key")) # noqa: S108 - ephemeral swarm-test path, overridable via KEY_PATH
backup_key = Path(
os.environ.get("INFINITO_SWARM_BACKUP_KEY")
or static_env["INFINITO_SWARM_BACKUP_KEY"]
)
return {
"administrator": _ensure_keypair(admin_key),
"backup": _ensure_keypair(backup_key),
}
def _mesh_address(host_vars_dir: Path, host: str, mesh: str, fallback: str) -> str:
"""The host's address in ``mesh``, or ``fallback`` when it has none.
Read from the host_vars the mesh writer produced earlier in the round
rather than recomputed, so the allocation stays in one place. Falling back
to the underlay keeps a round without a mesh working unchanged.
"""
path = host_vars_dir / f"{host}.yml"
if not path.exists():
return fallback
document = load_yaml(str(path))
address = (
document.get("applications", {})
.get("svc-net-wireguard", {})
.get("meshes", {})
.get(mesh, {})
.get("address")
)
return address or fallback
def main() -> int:
nfs_ip = os.environ["NFS_IP"]
mgr_ip = os.environ["MGR_IP"]
mgr = os.environ["MGR"]
out_path = Path(os.environ.get("OUT_PATH", "/tmp/swarm-nfs-extras.yml")) # noqa: S108 - ephemeral swarm-test path, overridable via OUT_PATH
# Exception: swarm and NFS must be addressed on the mesh, not the underlay.
# Pinning them to the lab addresses leaves every tunnel up and carrying
# nothing, because both sides keep talking over 192.168.244.0/24.
host_vars_dir = out_path.parent / "host_vars"
mgr_addr = _mesh_address(host_vars_dir, mgr, "swarm", mgr_ip)
nfs_addr = _mesh_address(
host_vars_dir, os.environ.get("NFS_SERVER", ""), "data", nfs_ip
)
pubkeys = ensure_swarm_keypairs()
static_env = parse_static_env(PROJECT_ROOT / "default.env")
default_users = copy.deepcopy(load_yaml(str(_DEFAULT_INVENTORY)).get("users", {}))
admin = dict(default_users.get("administrator", {}))
admin["authorized_keys"] = [pubkeys["administrator"]]
default_users["administrator"] = admin
backup = dict(default_users.get("backup", {"accounts": ["host"]}))
backup["authorized_keys"] = [pubkeys["backup"]]
default_users["backup"] = backup
extras = {
"RUNTIME": detect_runtime(),
"DOMAIN_PRIMARY": os.environ.get("INFINITO_DOMAIN")
or static_env["INFINITO_DOMAIN"],
"storage": {
"backend": "nfs",
"nfs": {
"server": nfs_addr,
# Exception: the controller is not a mesh member, so it reaches
# the export on the lab address. Pointing it at the mesh one
# times out the delegated controller mount.
"controller_server": nfs_ip,
},
},
"swarm": {
"manager": {"advertise_addr": mgr_addr},
"registry": {"host": mgr, "port": 5000},
"network": {"encryption": True},
},
"nfs_server_ip": nfs_ip,
"users": default_users,
"applications": device_applications_overrides(),
}
if admin.get("password"):
extras["ansible_become_password"] = admin["password"]
dump_yaml(str(out_path), extras)
deploy_extras = {
k: v for k, v in extras.items() if k not in ("applications", "users")
}
deploy_path = out_path.with_suffix(".deploy.yml")
dump_yaml(str(deploy_path), deploy_extras)
print(out_path.read_text()) # nocheck: cache-read — re-reads the file just written
return 0
if __name__ == "__main__":
sys.exit(main())