fix(inventory): drop disable= toggles once their key leaves the list

Provisioning merges into host_vars and never deletes, so the false
enabled/shared toggles that apply_services_disabled writes for a key stayed
in the round inventory after a later run dropped that key from disable=.
That covered the provider's own entry too: with seaweedfs once disabled,
web-app-seaweedfs kept shared: false, the service registry offered no
seaweedfs provider, container_networks attached no consumer, and BookWyrm
ran USE_S3=true against http://seaweedfs:8333 without a route.

reconcile_services_disabled_from_env records the keys of each disable run
in the host_vars key _INFINITO_SERVICES_DISABLED. On the next run it deletes,
in every application block, the enabled/shared toggles that are still
exactly false for a recorded key the current disable no longer lists, drops
a block left empty, and rewrites or removes the record. It runs right after
ensure_host_vars_file, before credentials and the variant bake, so a variant
that wants a toggle off writes it again. With an unchanged or empty disable
it does nothing. An inventory written before this change has no record, so
its older toggles are only reverted after one run with the old list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-10 11:07:21 +02:00
1 parent 1fb98b5b76
commit 0327114c44
3 files changed
+175 -1

No files matched your search

@@ -19,7 +19,10 @@ from .inventory_generator import generate_dynamic_inventory
from .mirror_overrides import apply_mirror_overrides
from .passwords import generate_random_password
from .project import build_env_with_project_root, detect_project_root
from .services_disabler import apply_services_disabled_from_env
from .services_disabler import (
apply_services_disabled_from_env,
reconcile_services_disabled_from_env,
)
from .tor_node import apply_tor_node_onion
from .users_generator import generate_user_passwords
from .yaml_io import dump_yaml, load_yaml
@@ -230,6 +233,9 @@ def main(argv: list[str] | None = None) -> int:
host_vars_file=host_vars_file,
host=args.host,
)
reconcile_services_disabled_from_env(
host_vars_file=host_vars_file, roles_dir=roles_dir
)
print(f"[INFO] Ensuring ansible_become_password for host '{args.host}'")
ensure_become_password(
@@ -15,9 +15,13 @@ from utils.roles.applications.services.registry import (
)
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from .ruamel_io import dump_document, load_document
if TYPE_CHECKING:
from pathlib import Path
DISABLED_SERVICES_RECORD = "_INFINITO_SERVICES_DISABLED"
def _load_yaml_mapping_tolerant(path: Path) -> dict:
"""Read a YAML file as a mapping; non-mapping or unparseable content
@@ -262,6 +266,64 @@ def apply_services_disabled_from_env(
)
def _revert_disabled_toggles(applications: CommentedMap, services: list[str]) -> None:
for app_id, app_data in applications.items():
if not isinstance(app_data, CommentedMap):
continue
svc_map = app_data.get("services")
if not isinstance(svc_map, CommentedMap):
continue
for svc_name in services:
svc = svc_map.get(svc_name)
if not isinstance(svc, CommentedMap):
continue
dropped = [flag for flag in ("enabled", "shared") if svc.get(flag) is False]
if not dropped:
continue
for flag in dropped:
del svc[flag]
if not svc:
del svc_map[svc_name]
print(
f"[INFO] disable: {app_id}.services.{svc_name} → "
f"{', '.join(dropped)} back to the role default"
)
def reconcile_services_disabled_from_env(host_vars_file: Path, roles_dir: Path) -> None:
"""Drop the false toggles of every key the previous `disable` run recorded
and this run's `disable` no longer lists, then record this run's keys.
Runs before the variant bake, so a variant that wants a toggle off writes it
again right after.
Args:
host_vars_file: host_vars file of the inventory.
roles_dir: roles tree the `disable` tokens resolve against.
"""
if not host_vars_file.exists():
return
current = expand_service_tokens(
parse_services_disabled(os.environ.get("disable", "")), roles_dir
)
doc = load_document(host_vars_file)
raw_record = doc.get(DISABLED_SERVICES_RECORD)
recorded = [str(key) for key in raw_record] if isinstance(raw_record, list) else []
if recorded == current:
return
stale = [key for key in recorded if key not in current]
applications = doc.get("applications")
if stale and isinstance(applications, CommentedMap):
_revert_disabled_toggles(applications, stale)
if current:
doc[DISABLED_SERVICES_RECORD] = current
else:
del doc[DISABLED_SERVICES_RECORD]
dump_document(host_vars_file, doc)
def _inventory_application_ids(inventory_file: Path) -> set[str]:
doc = _load_yaml_mapping_tolerant(inventory_file)
all_section = doc.get("all") or {}
@@ -9,15 +9,18 @@ from tempfile import TemporaryDirectory
from ruamel.yaml import YAML
from cli.administration.inventory.provision.services_disabler import (
DISABLED_SERVICES_RECORD,
apply_services_disabled,
apply_services_disabled_from_env,
assert_services_disabled_inventory_consistency_from_env,
find_provider_roles,
find_services_disabled_conflicts,
parse_services_disabled,
reconcile_services_disabled_from_env,
remove_roles_from_inventory,
)
from utils.cache.yaml import dump_yaml
from utils.roles.applications.services.registry import discover_role_services
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_VARS_MAIN
@@ -544,5 +547,108 @@ class TestServicesDisabledConflicts(unittest.TestCase):
)
class TestReconcileServicesDisabledFromEnv(unittest.TestCase):
def setUp(self):
self.tmp = TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.yaml_rt = YAML(typ="rt")
self.yaml_rt.preserve_quotes = True
self.host_vars = self.root / "host_vars.yml"
self.roles_dir = self.root / "roles"
self.roles_dir.mkdir()
self.provider_entry = {"image": "seaweedfs", "enabled": True, "shared": True}
self._make_role("web-app-seaweedfs", {"seaweedfs": self.provider_entry})
self._make_role(
"web-app-bookwyrm",
{
"seaweedfs": {
"enabled": "{{ 'web-app-seaweedfs' in group_names }}",
"shared": "{{ 'web-app-seaweedfs' in group_names }}",
},
"minio": {"enabled": False, "shared": False},
},
)
self._make_role("web-app-minio", {"minio": {"enabled": True, "shared": True}})
self._write({"applications": {}})
def _make_role(self, role_name: str, services: dict) -> None:
role_dir = self.roles_dir / role_name
(role_dir / "meta").mkdir(parents=True)
(role_dir / "vars").mkdir(parents=True)
dump_yaml(role_dir / ROLE_FILE_META_SERVICES, services)
dump_yaml(role_dir / ROLE_FILE_VARS_MAIN, {"application_id": role_name})
def _write(self, data: dict) -> None:
with self.host_vars.open("w") as f:
self.yaml_rt.dump(data, f)
def _read(self) -> dict:
with self.host_vars.open("r") as f:
return self.yaml_rt.load(f)
def _provision(self, disable: str) -> None:
with unittest.mock.patch.dict(os.environ, {"disable": disable}):
reconcile_services_disabled_from_env(self.host_vars, self.roles_dir)
apply_services_disabled_from_env(self.host_vars, roles_dir=self.roles_dir)
def _services(self, app_id: str) -> dict:
return self._read()["applications"][app_id].get("services", {})
def test_a_provider_dropped_from_disable_is_a_registry_provider_again(self):
self._provision("minio,seaweedfs")
self._provision("minio")
override = self._services("web-app-seaweedfs").get("seaweedfs", {})
self.assertNotIn("enabled", override)
self.assertNotIn("shared", override)
config = {"services": {"seaweedfs": {**self.provider_entry, **override}}}
self.assertIn("seaweedfs", discover_role_services("web-app-seaweedfs", config))
self.assertNotIn("seaweedfs", self._services("web-app-bookwyrm"))
def test_a_key_still_listed_stays_disabled(self):
self._provision("minio,seaweedfs")
self._provision("minio")
self.assertEqual(
{"enabled": False, "shared": False},
dict(self._services("web-app-minio")["minio"]),
)
self.assertEqual(["minio"], list(self._read()[DISABLED_SERVICES_RECORD]))
def test_a_toggle_written_true_after_the_disable_survives(self):
self._provision("seaweedfs")
hv = self._read()
consumer = hv["applications"]["web-app-bookwyrm"]["services"]["seaweedfs"]
consumer["enabled"] = True
self._write(hv)
self._provision("")
self.assertEqual(
{"enabled": True},
dict(self._services("web-app-bookwyrm")["seaweedfs"]),
)
self.assertNotIn(DISABLED_SERVICES_RECORD, self._read())
def test_a_false_toggle_no_disable_run_recorded_is_left_alone(self):
self._write(
{
"applications": {
"web-app-bookwyrm": {
"services": {"seaweedfs": {"enabled": False, "shared": False}}
}
}
}
)
self._provision("")
self.assertEqual(
{"enabled": False, "shared": False},
dict(self._services("web-app-bookwyrm")["seaweedfs"]),
)
if __name__ == "__main__":
unittest.main()