Files
computer-playbook/roles/sys-service-loader/tasks/main.yml
kevinveenbirkenbachandClaude Opus 5 9d7fc70900 perf(sys-service-loader): scope the preload to the whitelist closure
A whitelist-scoped update preloaded and fully executed every provider role
the host is a member of, because the gate only asked whether the role was in
group_names. On this inventory that meant a single-app update built compose
contexts, rendered configs and vhosts for 17 unrelated applications.

reachable_roles() is lifted out of applications_if_group_and_all_deps() so the
same traversal can start from an arbitrary seed set instead of group_names.
The old function now calls it and filters as before, so every existing caller
keeps its result. A new application_closure lookup exposes it to Ansible, and
sys-service-loader gates each preload on the whitelist's closure. An empty
whitelist yields an empty closure, which the gate reads as the full inventory
scope, so a no-apps deploy is unchanged.

Measured on mode=update apps=web-app-snipe-it: 42:49 before, 17:18 after, with
ok tasks dropping from 4903 to 2030. Nothing outside the closure ran, and
snipe-it's real dependencies (mariadb, redis, openldap, postgres, keycloak,
mailu, prometheus, seaweedfs, cdn, logout) all still did.

The closure also gates the required branch, deliberately. `required` reads as
a global property but plugins/lookup/service.py derives it from group_names,
so it means "some provisioned app needs this" and admits nearly every service.
Exempting it was measured at 42:39 against 17:18 for the same deploy, which is
the entire saving. A service whose provider lies outside the closure is by
definition required by an application outside the deploy scope.

Out of scope: seeding applications_current_play from the closure. That shrinks
CURRENT_PLAY_DOMAINS_ALL, which cert SANs, openresty vhosts, DNS records and
the webserver health check all consume at full inventory scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:31:12 +02:00

64 lines
2.2 KiB
YAML

---
# nocheck: run-once — main.yml is the orchestrator entry point and intentionally
# re-runnable; per-service idempotency is enforced by each loaded role's own
# run_once_<role> flag inside load_service.yml.
#
# SPOT: global loader for all shared services.
#
# Called from tasks/stages/01_constructor.yml before the normal server app pass.
#
# Two-phase contract:
# 1. Preload pass — iterate `_ordered_services` in dependency order and
# load each service body once (`service_loader_preloading: true`).
# 2. Post-load pass — flush the `service_loader_post_run` queue that
# roles registered during preload via this role's `list_or_shoot.yml`.
# Tasks listed there run with `service_loader_preloading: false`,
# so they see the fully preloaded service graph.
- name: "🔍 Discover ordered shared services"
ansible.builtin.set_fact:
_ordered_services: >-
{{
query(
'service_registry',
'ordered',
roles_dir=(lookup('path_absolute', 'roles'))
) | first
}}
- name: "🔍 Precompute required shared services (single applications load)"
ansible.builtin.set_fact:
_required_service_ids: >-
{{
query(
'service',
(_ordered_services | map(attribute='id') | list),
roles_dir=(lookup('path_absolute', 'roles'))
)
| selectattr('required')
| map(attribute='id')
| list
}}
- name: "🔍 Scope the preload to the whitelist's dependency closure"
ansible.builtin.set_fact:
_preload_closure: "{{ lookup('application_closure', lookup('deployment').whitelist) }}"
- name: "🔌 Load shared service"
include_tasks: load_service.yml
vars:
service_key: "{{ service_item.id }}"
service_role: "{{ service_item.role }}"
service_run_once_var: "{{ 'run_once_' ~ (service_item.role | replace('-', '_')) }}"
loop: "{{ _ordered_services }}"
loop_control:
loop_var: service_item
label: "{{ service_item.id }}"
- name: "▶️ Run service-loader post-load roles"
ansible.builtin.include_role:
name: "{{ post_run_role }}"
loop: "{{ service_loader_post_run | unique }}"
loop_control:
loop_var: post_run_role