mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
feat(lifecycle): close the EOL contract and retire four roles
web-app-confluence, web-app-jira, web-app-minio and web-app-phpldapadmin are lifecycle: eol. Their primary services now carry a literal 'enabled: false' and their READMEs an end-of-life banner, so nothing re-enables them from a variant and a reader sees the state before the Quick Setup block. Three consequences, none of them cosmetic. minio drops its 'bond: 1' because test_disabled_service_bond.py refuses a non-zero bond beside a disabled service. jira and confluence gain 'shared: true' and with it provider status in the service registry, which moves them into the sys-service-loader preload phase and makes 'disable=' reach them; that is why both grow a tasks/00_core.yml whose first task is the run-once flag, with a thin tasks/main.yml, as test_service_core_first_task_run_once.py requires. The three inventory bundles drop the web-app-minio group. default.env carried INFINITO_LIFECYCLES="... maintained", a token matching no stage, so the envelope silently admitted nothing by that name. It reads 'maintenance' now. No role sits at either token, so the value changes no round today. Three lints keep it closed: an EOL role may not appear as an enabled dependency of a non-EOL consumer unless that consumer is itself EOL, every EOL role must carry the banner, and the envelope may only name stages that exist and are tested. Not theoretical: quay.io no longer serves quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z to an anonymous puller, so a round that lists web-app-minio dies at compose pull after ten retries. The upstream this role depends on is gone. Residuals, both latent and both unclosed. utils/annotations/suppress.py reads the reason from the leftmost marker while the gate scans all of them, so a second '# nocheck:' appended to a line that already carries a reasoned marker lends its reason to the bare EOL marker - and all four consumer flag lines already carry one. And the envelope lint guards only one direction: it rejects unknown and untested tokens but never asserts the envelope contains the tested stages, so dropping 'beta' would drain 214 roles from every round and stay green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c83c00da7d
commit
0a3da88912
23 files changed
+1299
-33
No files matched your search
+1
-1
@@ -242,7 +242,7 @@ INFINITO_IS_WSL2=false
|
||||
INFINITO_KEEP_SWARM_NODES=false
|
||||
|
||||
# Lifecycle envelope the CI test-deploy discovery exercises (scripts/meta/resolve/apps.sh --lifecycles); comma/space-separated.
|
||||
INFINITO_LIFECYCLES="alpha beta rc stable maintained"
|
||||
INFINITO_LIFECYCLES="alpha beta rc stable maintenance"
|
||||
|
||||
# Ansible limit pattern for local deploys.
|
||||
INFINITO_LIMIT_HOST=localhost
|
||||
|
||||
@@ -109,11 +109,18 @@ The role has reached end of life: it still ships in `roles/` for now but the pro
|
||||
|
||||
A role tagged `eol`:
|
||||
|
||||
- MUST clearly state in its `README.md` that the project does not maintain or test it and that operators use it at their own risk.
|
||||
- SHOULD link to the upstream project / vendor for support.
|
||||
- MUST clearly state in its `README.md` that the project does not maintain or test it and that operators use it at their own risk. The banner is a single blockquote line, the first non-blank line under the H1, opening with `> **End of life.**` and carrying both the `neither maintains nor tests` and the `at your own risk` wording. `test_eol_readme_banner.py` enforces exactly those three things: the position, the opening, and both clauses.
|
||||
- SHOULD link to the upstream project / vendor for support. The link is not mechanical and `test_eol_readme_banner.py` does not check it.
|
||||
- MUST store `enabled: false` as a literal on its own primary entity in `meta/services.yml`. A `"{{ '<role>' in group_names }}"` flag turns true the moment the role joins an inventory. `shared` stays at the value the role needs, normally `true`: it is what registers the entity in the service registry, and that registration is what makes `disable=<role-id>` resolve to the service key at all and what keeps the role in the `sys-service-loader` preload order. The literal forces two collateral suppressions, and a demotion that skips them hits two unrelated red lints: the own primary's key needs `# nocheck: playwright-service-flag` on the line above it, because a service stored off carries no `<NAME>_SERVICE_ENABLED=` line in `templates/playwright.env.j2`, and every consumer's literal flag needs `# nocheck: dynamic-flag` on its own line (or one marker in the comment block above the key, which covers both flags at once), because a literal where the dynamic form is the house shape is precisely what `dynamic-flag` reports.
|
||||
- MUST NOT be pinned truthy by any variant in any `meta/variants.yml`, its own included, neither under `services:` nor under `addons:`. A variant pin re-enables the role for that round alone, which is the one place where neither the banner nor the stored flag is in view.
|
||||
- MUST NOT be enabled as a dependency of a role that is not itself EOL: no `meta/services.yml` block, no variant pin, and no `meta/addons/*.yml` whose `bridges:` list names it. One EOL role MAY name another, because both leave together. The `eol-dependency` rule enforces all three, and `INFINITO_LIFECYCLES` is why: the envelope drops an EOL role from every CI round under the default envelope, so the dependency binds a partner no test deploys and first surfaces on a production host as an unreachable integration. An explicit `lifecycles: eol` workflow dispatch deploys the role standalone, and the literal flags keep every partner unbound even in that round.
|
||||
- MUST NOT block any release. CI MAY skip its deploy matrix entry entirely.
|
||||
- MAY be removed without a deprecation cycle.
|
||||
|
||||
An addon that stays in the tree while its partner is EOL gates on exactly `{{ lookup('config', '<own-role>', 'services.<eol-key>.enabled') | bool }}`, which the stored literal holds at `false`; the rule matches that expression anchored over the whole value, accepting either quote character and whitespace after `lookup(` and around its commas, because a gate that merely mentions the lookup can still render true. An addon reaching an external SaaS provider through `lookup('api_enabled', …)` declares no `bridges:` and is not a dependency on the in-repo role at all.
|
||||
|
||||
The generated integration matrix (`roles/web-app-docs/files/python/infinito_docs/generators/integrations.py`) reads this `lifecycle` value and marks every edge whose target is `eol` with `(end of life)` on all three of its pages, so a declared integration with a dead partner does not read as a live one.
|
||||
|
||||
## The tested envelope 🧪
|
||||
|
||||
Stages `alpha`, `beta`, `rc`, `stable`, and `maintenance` form the **tested envelope**.
|
||||
@@ -124,6 +131,11 @@ Stages outside the envelope (`planned`, `pre-alpha`, `deprecated`, `eol`) MAY sk
|
||||
CI MAY still exercise them on a best-effort basis but failures MUST NOT block unrelated work.
|
||||
|
||||
Promotion from `eol` back into the tested envelope is allowed but requires meeting the `alpha` criteria from scratch.
|
||||
Promoting also reverses the storage steps, in this order: change `lifecycle`, set the primary entity's `enabled` and `shared` to the values the role needs, restore the `bond` the primary carried before (`test_disabled_service_bond` is what required dropping it, because the resource model never reads a key on an entry stored off, so the number described a cost nobody paid), restore the consumer flags from their literal `false` to the dynamic form and drop the `# nocheck: dynamic-flag` markers that literal needed, re-add the variant pins the EOL ban removed, then re-run the matrix-deploy.
|
||||
The consumer blocks themselves were never removed, only pinned off, so there is nothing to re-create; the step is the flag value and its marker.
|
||||
Taking the `lifecycle` value off `eol` without the flags leaves the role in the envelope and off on every host, and the deploy matrix picks up a role no inventory enables.
|
||||
Demoting to `eol` runs the same list the other way round: set the primary's `enabled` to a literal `false` and add `# nocheck: playwright-service-flag` above its key, drop the primary's `bond` in the same edit because `test_disabled_service_bond` rejects a weight on an entry stored off, pin every consumer flag to a literal `false` with `# nocheck: dynamic-flag`, remove the variant pins, and add the README banner.
|
||||
The storage delta on a demoted primary is therefore not the flag alone, and it differs per role: `web-app-minio` went from `enabled: true` to `enabled: false` and lost its `bond: 1` (`test_disabled_service_bond`), `web-app-jira` and `web-app-confluence` gained both a stored `enabled: false` and `shared: true` where their primary declared neither before, and `web-app-phpldapadmin` gained the stored `enabled: false` on its own.
|
||||
Examples of `eol` roles at the time of writing include `web-app-confluence`, `web-app-jira`, `web-app-minio`, `web-app-phpldapadmin`.
|
||||
|
||||
## Setting the value ✏️
|
||||
|
||||
@@ -13,7 +13,6 @@ Privacy-conscious users, developers, journalists, and independent professionals.
|
||||
This bundle activates the following Infinito.Nexus roles:
|
||||
|
||||
- web-app-nextcloud
|
||||
- web-app-minio
|
||||
- web-app-keycloak
|
||||
- web-app-matrix
|
||||
- web-svc-libretranslate
|
||||
|
||||
@@ -16,9 +16,6 @@ all:
|
||||
web-app-nextcloud:
|
||||
hosts:
|
||||
server:
|
||||
web-app-minio:
|
||||
hosts:
|
||||
server:
|
||||
web-app-keycloak:
|
||||
hosts:
|
||||
server:
|
||||
|
||||
@@ -10,12 +10,11 @@ Enterprises, research teams, and regulated organizations that need AI capabiliti
|
||||
|
||||
## Included Components
|
||||
|
||||
- web-app-minio
|
||||
- web-svc-libretranslate
|
||||
- web-app-keycloak
|
||||
- svc-prx-openresty
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The stack provides on-prem data storage, a local AI-adjacent service, centralized identity,
|
||||
and secure exposure through a reverse proxy. This bundle is a skeleton activation map.
|
||||
The stack provides a local AI-adjacent service, centralized identity, and secure exposure
|
||||
through a reverse proxy. This bundle is a skeleton activation map.
|
||||
@@ -22,9 +22,6 @@ all:
|
||||
hosts:
|
||||
server:
|
||||
children:
|
||||
web-app-minio:
|
||||
hosts:
|
||||
server:
|
||||
web-svc-libretranslate:
|
||||
hosts:
|
||||
server:
|
||||
|
||||
@@ -23,7 +23,6 @@ and enterprises seeking a compliant cloud alternative.
|
||||
- web-app-nextcloud
|
||||
- web-svc-onlyoffice
|
||||
- web-app-keycloak
|
||||
- web-app-minio
|
||||
- web-app-matrix
|
||||
- svc-prx-openresty
|
||||
|
||||
|
||||
@@ -30,9 +30,6 @@ all:
|
||||
web-app-keycloak:
|
||||
hosts:
|
||||
server:
|
||||
web-app-minio:
|
||||
hosts:
|
||||
server:
|
||||
web-app-matrix:
|
||||
hosts:
|
||||
server:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Confluence
|
||||
|
||||
> **End of life.** Infinito.Nexus neither maintains nor tests this role, and the default `INFINITO_LIFECYCLES` envelope keeps it out of every CI round. Deploy it at your own risk and take support from [Atlassian Confluence](https://www.atlassian.com/software/confluence).
|
||||
|
||||
## Description
|
||||
|
||||
Confluence is Atlassian’s enterprise wiki and collaboration platform. This role deploys Confluence via Docker Compose, wires it to PostgreSQL, and integrates proxy awareness, optional OIDC SSO, health checks, and production-friendly defaults for Infinito.Nexus.
|
||||
|
||||
@@ -27,7 +27,10 @@ postgres:
|
||||
bond: 1
|
||||
enabled: true # nocheck: dynamic-flag
|
||||
shared: "{{ 'svc-db-postgres' in group_names }}"
|
||||
# nocheck: playwright-service-flag self-provider
|
||||
confluence:
|
||||
enabled: false
|
||||
shared: true
|
||||
healthcheck:
|
||||
flavor: curl
|
||||
hostname: localhost
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
- include_tasks: utils/once/flag.yml
|
||||
|
||||
- name: "🧩 load docker, db and proxy for {{ application_id }}"
|
||||
include_role:
|
||||
name: sys-stk-full
|
||||
vars:
|
||||
compose_handlers_flush: true
|
||||
@@ -1,9 +1,3 @@
|
||||
---
|
||||
- block:
|
||||
- name: "🧩 load docker, db and proxy for {{ application_id }}"
|
||||
include_role:
|
||||
name: sys-stk-full
|
||||
vars:
|
||||
compose_handlers_flush: true
|
||||
- include_tasks: utils/once/flag.yml
|
||||
- include_tasks: 00_core.yml
|
||||
when: run_once_web_app_confluence is not defined
|
||||
@@ -1,5 +1,7 @@
|
||||
# Jira
|
||||
|
||||
> **End of life.** Infinito.Nexus neither maintains nor tests this role, and the default `INFINITO_LIFECYCLES` envelope keeps it out of every CI round. Deploy it at your own risk and take support from [Atlassian](https://www.atlassian.com/).
|
||||
|
||||
## Description
|
||||
|
||||
[Jira](https://www.atlassian.com/) is Atlassian’s issue and project-tracking platform. This role deploys Jira via Docker Compose, connects it to PostgreSQL, and adds proxy awareness, optional OIDC SSO, health checks, and production-oriented defaults for Infinito.Nexus.
|
||||
|
||||
@@ -27,7 +27,10 @@ postgres:
|
||||
bond: 1
|
||||
enabled: true # nocheck: dynamic-flag
|
||||
shared: "{{ 'svc-db-postgres' in group_names }}"
|
||||
# nocheck: playwright-service-flag self-provider
|
||||
jira:
|
||||
enabled: false
|
||||
shared: true
|
||||
healthcheck:
|
||||
flavor: curl
|
||||
hostname: localhost
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
- include_tasks: utils/once/flag.yml
|
||||
|
||||
- name: "🧩 load docker, db and proxy for {{ application_id }}"
|
||||
include_role:
|
||||
name: sys-stk-full
|
||||
vars:
|
||||
compose_handlers_flush: true
|
||||
@@ -1,9 +1,3 @@
|
||||
---
|
||||
- block:
|
||||
- name: "🧩 load docker, db and proxy for {{ application_id }}"
|
||||
include_role:
|
||||
name: sys-stk-full
|
||||
vars:
|
||||
compose_handlers_flush: true
|
||||
- include_tasks: utils/once/flag.yml
|
||||
- include_tasks: 00_core.yml
|
||||
when: run_once_web_app_jira is not defined
|
||||
@@ -1,5 +1,7 @@
|
||||
# MinIO
|
||||
|
||||
> **End of life.** Infinito.Nexus neither maintains nor tests this role, and the default `INFINITO_LIFECYCLES` envelope keeps it out of every CI round. Deploy it at your own risk and take support from [www.min.io](https://www.min.io).
|
||||
|
||||
## Description
|
||||
|
||||
**MinIO** is an S3-compatible object storage service for files, media, backups, and AI artifacts, self-hosted for performance and control.
|
||||
|
||||
@@ -30,8 +30,7 @@ minio:
|
||||
enabled: true
|
||||
# nocheck: single-replica
|
||||
replicas: 1
|
||||
bond: 1
|
||||
enabled: true
|
||||
enabled: false
|
||||
shared: true
|
||||
backup:
|
||||
no_stop_required: false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# phpldapadmin
|
||||
|
||||
> **End of life.** Infinito.Nexus neither maintains nor tests this role, and the default `INFINITO_LIFECYCLES` envelope keeps it out of every CI round. Deploy it at your own risk and take support from [phpLDAPadmin](https://github.com/leenooks/phpLDAPadmin).
|
||||
|
||||
## Description
|
||||
|
||||
phpLDAPadmin is a web‑based LDAP client that provides an intuitive interface for managing LDAP directories. This containerized deployment leverages Docker Compose and Ansible automation to offer a secure, configurable environment for administering and exploring your LDAP configurations.
|
||||
|
||||
@@ -15,7 +15,9 @@ matomo:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-matomo' in group_names }}"
|
||||
shared: "{{ 'web-app-matomo' in group_names }}"
|
||||
# nocheck: playwright-service-flag self-provider
|
||||
phpldapadmin:
|
||||
enabled: false
|
||||
healthcheck:
|
||||
flavor: curl
|
||||
modes:
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
"""Lint guard: an end-of-life service stays off, and only an EOL role binds it.
|
||||
|
||||
A role whose primary entity carries ``lifecycle: eol`` receives no upstream
|
||||
fixes. Its implementation stays in the tree so an existing deployment keeps its
|
||||
migration path, but nothing may switch it back on: the lifecycle envelope
|
||||
``INFINITO_LIFECYCLES`` (``default.env:245``) drops it from every CI round under
|
||||
the default envelope, so a consumer that enables it binds a partner no test ever
|
||||
deploys, and the dependency surfaces first on a production host as an
|
||||
unreachable integration. An explicit ``lifecycles: eol`` workflow dispatch
|
||||
(``.github/workflows/entry-manual-steer.yml``) deploys the role standalone,
|
||||
which is the one round it is exercised in; the stored literals keep every
|
||||
partner unbound even there. The policy this file enforces lives at
|
||||
``docs/contributing/design/role/services/lifecycle.md``.
|
||||
|
||||
Five assertions, one per re-entry point plus two over the exemption ledger:
|
||||
|
||||
* ``meta/services.yml``: the EOL role's OWN primary block MUST store
|
||||
``enabled`` as a literal ``false``, and every other role's block for an EOL
|
||||
key MUST keep ``enabled`` literally ``false``. A dynamic
|
||||
``"{{ 'web-app-<x>' in group_names }}"`` flag is exactly a re-entry point: it
|
||||
turns true the moment the EOL role joins an inventory.
|
||||
* ``meta/variants.yml``: no variant of any role may pin ``enabled`` truthy,
|
||||
including a variant of the EOL role itself, under ``services:`` or under
|
||||
``addons:`` - the latter reaches the same partner through the pinned addon's
|
||||
``bridges:`` list, and ``roles/web-app-matrix/meta/variants.yml`` carries a
|
||||
live ``addons:`` block.
|
||||
* ``meta/addons/*.yml``: an addon whose ``bridges:`` list names an EOL key MUST
|
||||
gate on a literal ``false`` or on the exact reference
|
||||
``{{ lookup('config', '<own-role>', 'services.<eol-key>.enabled') | bool }}``,
|
||||
matched anchored over the whole expression, with either quote character and
|
||||
optional whitespace after ``lookup(`` and around its commas. Only an exact
|
||||
match contains the gate: a substring test passes ``{{ not lookup(…) }}`` and
|
||||
``{{ lookup(…) or true }}``, both of which render TRUE. An addon with no
|
||||
``bridges:`` integrates an external SaaS provider through
|
||||
``lookup('api_enabled', …)`` (credentials from
|
||||
``group_vars/all/18_api.yml``), not the in-repo role, and is out of scope.
|
||||
``test_addons_schema`` is what holds ``bridges`` to a non-empty list of
|
||||
strings, and this walk deliberately does not depend on it: that lint is
|
||||
suppressible from an addon's head with ``# nocheck: addon-schema``, so a
|
||||
scalar ``bridges: jira`` behind one marker would leave this rule with nothing
|
||||
bound while the loader still resolves the addon as enabled.
|
||||
:func:`_addon_bridges` therefore fails closed - a ``bridges`` key that is
|
||||
present but off-shape binds every string the value holds at any depth.
|
||||
* the exemption ledger: ``exempt_eol_roles`` MUST stay inside
|
||||
``WHOLE_RULE_EXEMPTIONS``, because that marker drops every consumer of a
|
||||
service in one move, and no whole-rule marker may be bare.
|
||||
|
||||
An addon's enable state is resolved the way the loader resolves it
|
||||
(``utils.cache.applications._normalize_addons``): a missing ``enabled`` falls
|
||||
back to ``required``, which itself defaults to ``false``. An optional addon
|
||||
that declares no gate is therefore already contained and is not an offence.
|
||||
|
||||
The key set is derived the way the registry derives it: the primary entity name,
|
||||
every ``provides`` entry (string or list) and every ``canonical`` alias that
|
||||
normalises to the entity name (``registry.py:132-150``). Twelve roles declare
|
||||
``provides``, so deriving from the entity name alone would let a renamed
|
||||
consumer-facing key escape. The entity name is kept unconditionally rather than
|
||||
read back out of the registry: ``is_provider`` needs ``shared``/``provides``/an
|
||||
alias, so ``web-app-phpldapadmin`` - whose primary carries no ``shared`` key -
|
||||
is absent from the registry entirely, and a registry-derived set would cover it
|
||||
vacuously.
|
||||
|
||||
A role that is itself EOL is exempt for FOREIGN keys only: one dead integration
|
||||
may name another, and both leave together. Its own key is never exempt, or the
|
||||
app re-enables itself.
|
||||
|
||||
``test_clearnet_role_forbids_variant_tor`` is the structural sibling: same
|
||||
two-sided walk, a different marker on the bound role.
|
||||
|
||||
Why ``shared`` is NOT checked
|
||||
=============================
|
||||
|
||||
``shared: true`` on the EOL role's own primary is deliberately retained, and
|
||||
this lint is what makes that safe. ``shared`` is the key that registers the
|
||||
entity as a provider (``registry.py:142``), and the registry is what
|
||||
``expand_service_tokens`` uses to accept both spellings of an operator's
|
||||
service reference. Measured on a throwaway tree materialised from HEAD and from
|
||||
the working tree: with ``shared: false`` on ``web-app-minio``'s primary,
|
||||
``expand_service_tokens(['web-app-minio'])`` returns ``['web-app-minio']``
|
||||
verbatim instead of ``['minio']``, so ``disable=web-app-minio`` silently
|
||||
matches nothing, the ``disable``-vs-inventory guard in
|
||||
``cli/administration/inventory/provision/services_disabler.py`` stops reporting
|
||||
that a disabled service's provider is still in the inventory, and the role drops
|
||||
out of the sys-service-loader preload order into the ordinary app pass.
|
||||
Withdrawing the provider registration therefore costs the ``disable=`` surface
|
||||
and the deploy order without buying containment: ``--include`` does not route
|
||||
through ``expand_service_tokens`` at all. The literal ``enabled: false`` plus
|
||||
these four assertions are the control that keeps a consumer from binding the
|
||||
dead app.
|
||||
|
||||
Only ``minio`` regressed, measured the same way: ``web-app-jira`` and
|
||||
``web-app-confluence`` carried no ``shared`` on their primary at all before
|
||||
this change, so ``expand_service_tokens(['web-app-jira'])`` already returned
|
||||
the token verbatim and ``disable=web-app-jira`` was already a no-op. Their
|
||||
``shared: true`` is new registration, not a restoration.
|
||||
``web-app-phpldapadmin`` still declares none and stays out of the registry,
|
||||
which is why the key set below keeps the entity name unconditionally.
|
||||
|
||||
Why no variant pin backs the literal
|
||||
====================================
|
||||
|
||||
The variant pins that used to hold these keys ``false`` were removed; the single
|
||||
literal plus these assertions are the compensating control. The pins were not
|
||||
cost-free. Measured with ``cli.meta.roles.applications.ressources`` on a
|
||||
throwaway tree: regressing ``web-app-mattermost``'s base ``jira`` flag to the
|
||||
``in group_names`` form makes variant 2 resolve the EOL app again, at +4.0 GB
|
||||
mem_reservation, +6.0 GB mem_limit, +1.027 GB min_storage and +2048 pids over
|
||||
the live 42.12 GB / 83.89 GB / 55.18 GB / 33024 - a dependency the variant-2 pin
|
||||
had previously contained. The literal is what closes it now, which is why the
|
||||
first assertion treats a missing or non-literal flag as an offence rather than a
|
||||
style nit.
|
||||
|
||||
Measured reduction of the removal itself (same tool, ``mem_reservation``, staged
|
||||
index to working tree) - a later variant-budget change has to be able to tell
|
||||
this drop apart from a regression:
|
||||
|
||||
* ``web-app-gitlab`` variant 0 and variant 2: -4.0 GB each (59.80 -> 55.80 GB)
|
||||
* ``web-app-gitlab`` variant 1: -10.0 GB (65.30 -> 55.30 GB)
|
||||
* ``web-app-matrix`` variant 1: -3.0 GB (45.98 -> 42.98 GB)
|
||||
* ``web-app-mattermost`` variants 0 and 1: -10.0 GB each (68.08 -> 58.08 GB)
|
||||
* all four EOL roles: unchanged
|
||||
|
||||
``web-app-gitlab`` variant 0 and variant 2 close a depth-4 transitive path
|
||||
(gitlab to mattermost to jira) that only became reachable statically because
|
||||
mattermost's base flag was dynamic.
|
||||
|
||||
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
|
||||
|
||||
* ``# nocheck: eol-dependency`` on the offending ``enabled:`` line, or on the
|
||||
nearest non-blank line above it when that is a COMMENT line, in a consumer's
|
||||
``meta/services.yml``, ``meta/variants.yml`` or ``meta/addons/<id>.yml``, for
|
||||
a case that is sound only there. The line above is accepted only when it is a
|
||||
comment or when its
|
||||
indent is strictly smaller than the judged line's: a marker carrying a reason
|
||||
on a nested ``enabled:`` inside a ``modes:`` sub-block, or on the sibling
|
||||
``shared:`` that happens to be the nearest non-blank line above, would
|
||||
otherwise silence the top-level flag it sits beside. Inside a
|
||||
``meta/variants.yml`` the marker must also resolve INSIDE the span of the
|
||||
variant being judged, so a marker parked in the neighbouring variant cannot
|
||||
reach this one.
|
||||
* ``# nocheck: eol-dependency-provider`` in the HEAD of the EOL role's own
|
||||
``meta/services.yml`` (the first 30 lines, the same placement ``file-size``
|
||||
uses) drops that role from the EOL set, which exempts every consumer in the
|
||||
repository at once: an EOL entity that still backs a live surface is a
|
||||
provider the rule has no claim on. The scope has its own rule key precisely
|
||||
because it cannot be read off a position - a trailing
|
||||
``# nocheck: eol-dependency`` on a flag that happens to sit in the first 30
|
||||
lines would otherwise claim the repository-wide scope by an accident of line
|
||||
numbering - and :meth:`test_no_eol_role_exempts_every_consumer` keeps the
|
||||
exemption red until it is recorded in ``WHOLE_RULE_EXEMPTIONS``.
|
||||
|
||||
Every marker of either key MUST carry a reason naming why the dependency
|
||||
survives the upstream's end of life; a bare marker is rejected, the way
|
||||
``onion-flag`` rejects one. A bare whole-rule marker grants nothing and is
|
||||
reported by :meth:`test_no_whole_rule_marker_is_bare`, so the strongest marker
|
||||
is held to the same bar as the weakest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
from utils.annotations.suppress import line_has_rule
|
||||
from utils.cache.files import read_text
|
||||
from utils.cache.yaml import load_yaml_str
|
||||
from utils.roles.entity.name import get_entity_name
|
||||
from utils.roles.mapping import (
|
||||
ROLE_DIR_META_ADDONS,
|
||||
ROLE_FILE_META_SERVICES,
|
||||
ROLE_FILE_META_VARIANTS,
|
||||
)
|
||||
from utils.roles.meta_lookup import get_role_lifecycle
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
ROLES_DIR = PROJECT_ROOT / "roles"
|
||||
_RULE = "eol-dependency"
|
||||
_PROVIDER_RULE = "eol-dependency-provider"
|
||||
_EOL = "eol"
|
||||
_FLAG = "enabled"
|
||||
_MIN_REASON_CHARS = 10
|
||||
_HEAD_LINES = 30
|
||||
|
||||
WHOLE_RULE_EXEMPTIONS: frozenset[str] = frozenset()
|
||||
"""EOL roles whose repository-wide exemption has been reviewed and recorded."""
|
||||
|
||||
_MARKER_RE = re.compile(
|
||||
r"(?:noqa|nocheck)\s*:\s*"
|
||||
r"(?:[a-z0-9][a-z0-9\-]*)(?:\s*,\s*[a-z0-9][a-z0-9\-]*)*(.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMMENT_LINE_RE = re.compile(r"^\s*#")
|
||||
_LIST_ENTRY_RE = re.compile(r"^-(?:\s|$)")
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> Any:
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
text = read_text(str(path))
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
if not text.strip():
|
||||
return None
|
||||
try:
|
||||
return load_yaml_str(text)
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
|
||||
|
||||
def _load_mapping(path: Path) -> dict[str, Any]:
|
||||
data = _load_yaml(path)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _lines(path: Path) -> list[str]:
|
||||
return read_text(str(path)).splitlines() if path.is_file() else []
|
||||
|
||||
|
||||
def _role_dirs() -> list[Path]:
|
||||
return sorted(p for p in ROLES_DIR.iterdir() if p.is_dir())
|
||||
|
||||
|
||||
def _eol_role_dirs() -> list[Path]:
|
||||
return [
|
||||
role_dir for role_dir in _role_dirs() if get_role_lifecycle(role_dir) == _EOL
|
||||
]
|
||||
|
||||
|
||||
def _marker_reason(line: str) -> str:
|
||||
"""The text a marker line carries after its rule list, ``""`` when bare."""
|
||||
match = _MARKER_RE.search(line)
|
||||
return match.group(1).strip().lstrip("-:—").strip() if match else ""
|
||||
|
||||
|
||||
def _indent(line: str) -> int:
|
||||
return len(line) - len(line.lstrip())
|
||||
|
||||
|
||||
def _governs_from_above(above: str, judged: str) -> bool:
|
||||
"""Whether a marker on *above* may govern the construct on *judged*.
|
||||
|
||||
Args:
|
||||
above: the nearest non-blank line above the construct.
|
||||
judged: the line the construct sits on.
|
||||
|
||||
Returns:
|
||||
True for a comment line, or for a data line that opens a shallower
|
||||
nesting level. A sibling (``shared:`` beside ``enabled:``) or a deeper
|
||||
line (an ``enabled:`` inside a ``modes:`` sub-block) is rejected: it is
|
||||
a construct of its own, and a marker written for it would otherwise
|
||||
silence the flag that happens to follow it.
|
||||
"""
|
||||
return bool(_COMMENT_LINE_RE.match(above)) or _indent(above) < _indent(judged)
|
||||
|
||||
|
||||
def _reason_at(
|
||||
lines: Sequence[str], line_no: int, bounds: tuple[int, int] | None = None
|
||||
) -> str | None:
|
||||
"""The rule's marker text governing 1-based *line_no*.
|
||||
|
||||
Args:
|
||||
lines: the file's lines.
|
||||
line_no: the 1-based line the construct sits on.
|
||||
bounds: an inclusive 1-based ``(first, last)`` range the marker has to
|
||||
resolve inside, e.g. one variant's span. ``None`` leaves the whole
|
||||
file open.
|
||||
|
||||
Returns:
|
||||
The marker's trailing reason, ``""`` for a bare marker, or ``None`` when
|
||||
no marker the placement rules accept carries one.
|
||||
"""
|
||||
if line_no < 1 or line_no > len(lines):
|
||||
return None
|
||||
candidates = [line_no - 1]
|
||||
previous = line_no - 2
|
||||
while previous >= 0 and not lines[previous].strip():
|
||||
previous -= 1
|
||||
if previous >= 0 and _governs_from_above(lines[previous], lines[line_no - 1]):
|
||||
candidates.append(previous)
|
||||
for index in candidates:
|
||||
if bounds and not bounds[0] <= index + 1 <= bounds[1]:
|
||||
continue
|
||||
if not line_has_rule(lines[index], _RULE):
|
||||
continue
|
||||
return _marker_reason(lines[index])
|
||||
return None
|
||||
|
||||
|
||||
def _offence_suffix(
|
||||
lines: Sequence[str], line_no: int, bounds: tuple[int, int] | None = None
|
||||
) -> str | None:
|
||||
"""Whether the construct at *line_no* is still an offence, and why.
|
||||
|
||||
Args:
|
||||
lines: the file's lines.
|
||||
line_no: the 1-based line the construct sits on.
|
||||
bounds: the inclusive 1-based range a marker has to resolve inside.
|
||||
|
||||
Returns:
|
||||
``None`` when a marker with a reason suppresses it, ``""`` when no
|
||||
marker applies, or the clause naming the bare marker.
|
||||
"""
|
||||
reason = _reason_at(lines, line_no, bounds)
|
||||
if reason is None:
|
||||
return ""
|
||||
if len(reason) < _MIN_REASON_CHARS:
|
||||
return (
|
||||
f"; its '# nocheck: {_RULE}' names no reason why the dependency "
|
||||
f"outlives the upstream's end of life"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _head_reason(role_dir: Path) -> str | None:
|
||||
"""The whole-rule marker's reason in the role's own ``meta/services.yml``.
|
||||
|
||||
Args:
|
||||
role_dir: the role directory.
|
||||
|
||||
Returns:
|
||||
The text after ``# nocheck: eol-dependency-provider`` in the file's
|
||||
first 30 lines, ``""`` when the marker is bare, or ``None`` when the
|
||||
head carries no such marker.
|
||||
"""
|
||||
for line in _lines(role_dir / ROLE_FILE_META_SERVICES)[:_HEAD_LINES]:
|
||||
if line_has_rule(line, _PROVIDER_RULE):
|
||||
return _marker_reason(line)
|
||||
return None
|
||||
|
||||
|
||||
def exempt_eol_roles() -> list[str]:
|
||||
"""EOL roles the rule's whole-file marker drops from the EOL set.
|
||||
|
||||
Returns:
|
||||
The role ids whose own ``meta/services.yml`` carries
|
||||
``# nocheck: eol-dependency-provider`` WITH a reason in its head, which
|
||||
exempts every consumer of that role at once. A bare marker grants
|
||||
nothing; :func:`bare_exemption_roles` reports it instead.
|
||||
"""
|
||||
return [
|
||||
role_dir.name
|
||||
for role_dir in _eol_role_dirs()
|
||||
if (reason := _head_reason(role_dir)) is not None
|
||||
and len(reason) >= _MIN_REASON_CHARS
|
||||
]
|
||||
|
||||
|
||||
def bare_exemption_roles() -> list[str]:
|
||||
"""EOL roles whose whole-rule marker names no reason.
|
||||
|
||||
Returns:
|
||||
The role ids whose own ``meta/services.yml`` head carries
|
||||
``# nocheck: eol-dependency-provider`` without a reason long enough to
|
||||
be one. The exemption is withheld, and the marker is reported so it
|
||||
cannot sit in the tree looking like a granted one.
|
||||
"""
|
||||
return [
|
||||
role_dir.name
|
||||
for role_dir in _eol_role_dirs()
|
||||
if (reason := _head_reason(role_dir)) is not None
|
||||
and len(reason) < _MIN_REASON_CHARS
|
||||
]
|
||||
|
||||
|
||||
def _alias_keys(services: dict[str, Any], entity: str) -> set[str]:
|
||||
"""Keys in the role's own file whose ``canonical`` normalises to ``entity``."""
|
||||
return {
|
||||
str(key)
|
||||
for key, entry in services.items()
|
||||
if isinstance(entry, dict)
|
||||
and isinstance(entry.get("canonical"), str)
|
||||
and entry["canonical"].strip() == entity
|
||||
}
|
||||
|
||||
|
||||
def _provided_keys(services: dict[str, Any], entity: str) -> set[str]:
|
||||
"""Consumer-facing keys the primary entity's ``provides`` declares."""
|
||||
raw = services.get(entity)
|
||||
if not isinstance(raw, dict):
|
||||
return set()
|
||||
provides = raw.get("provides")
|
||||
values = provides if isinstance(provides, list) else [provides]
|
||||
return {item.strip() for item in values if isinstance(item, str) and item.strip()}
|
||||
|
||||
|
||||
def own_eol_block_keys(role_dir: Path) -> set[str]:
|
||||
"""Keys of the EOL role's own blocks that requirement (a) stores ``false``.
|
||||
|
||||
Args:
|
||||
role_dir: the role directory, assumed to carry ``lifecycle: eol``.
|
||||
|
||||
Returns:
|
||||
The primary entity key plus every ``canonical`` alias key declared in
|
||||
the same file. A ``provides`` value is deliberately absent: it renames
|
||||
the key consumers bind, it never names a block in the provider's file.
|
||||
"""
|
||||
services = _load_mapping(role_dir / ROLE_FILE_META_SERVICES)
|
||||
entity = get_entity_name(role_dir.name)
|
||||
if not entity:
|
||||
return set()
|
||||
return {entity, *_alias_keys(services, entity)}
|
||||
|
||||
|
||||
def eol_service_keys() -> dict[str, str]:
|
||||
"""Consumer-facing service key to role id, for every EOL role the rule covers.
|
||||
|
||||
Returns:
|
||||
Every key a consumer may bind the role under - the primary entity name,
|
||||
each ``provides`` entry and each ``canonical`` alias - mapped to the
|
||||
role whose primary entity declares ``lifecycle: eol``. Roles listed by
|
||||
:func:`exempt_eol_roles` are left out.
|
||||
"""
|
||||
exempt = set(exempt_eol_roles())
|
||||
found: dict[str, str] = {}
|
||||
for role_dir in _eol_role_dirs():
|
||||
if role_dir.name in exempt:
|
||||
continue
|
||||
services = _load_mapping(role_dir / ROLE_FILE_META_SERVICES)
|
||||
entity = get_entity_name(role_dir.name)
|
||||
if not entity:
|
||||
continue
|
||||
keys = {
|
||||
entity,
|
||||
*_alias_keys(services, entity),
|
||||
*_provided_keys(services, entity),
|
||||
}
|
||||
for key in keys:
|
||||
found[key] = role_dir.name
|
||||
return found
|
||||
|
||||
|
||||
def _keys_to_check(role_dir: Path, eol: dict[str, str]) -> dict[str, str]:
|
||||
"""Service key to provider role, for the keys this role has to answer for.
|
||||
|
||||
Args:
|
||||
role_dir: the role being walked.
|
||||
eol: the output of :func:`eol_service_keys`.
|
||||
|
||||
Returns:
|
||||
For an EOL role, only its own block keys, which requirement (a) binds
|
||||
regardless; a foreign key inside an EOL role is omitted, because one
|
||||
dead integration may name another. For every other role, every EOL key.
|
||||
"""
|
||||
own = {key for key, provider in eol.items() if provider == role_dir.name}
|
||||
if get_role_lifecycle(role_dir) == _EOL:
|
||||
return dict.fromkeys(own & own_eol_block_keys(role_dir), role_dir.name)
|
||||
return {key: provider for key, provider in eol.items() if key not in own}
|
||||
|
||||
|
||||
def _key_column(raw: str) -> int:
|
||||
"""Column the mapping key starts in, counting ``- `` sequence markers."""
|
||||
stripped = raw.lstrip()
|
||||
column = len(raw) - len(stripped)
|
||||
while stripped.startswith("- "):
|
||||
column += 2
|
||||
stripped = stripped[2:]
|
||||
return column
|
||||
|
||||
|
||||
def _bare_key_line(raw: str) -> str:
|
||||
"""The line's content with indentation and ``- `` sequence markers removed."""
|
||||
stripped = raw.strip()
|
||||
while stripped.startswith("- "):
|
||||
stripped = stripped[2:]
|
||||
return stripped
|
||||
|
||||
|
||||
def _top_level_key_line(lines: Sequence[str], key: str) -> int:
|
||||
"""1-based line declaring top-level *key*, or 1 when the key is absent."""
|
||||
return next(
|
||||
(idx for idx, raw in enumerate(lines, start=1) if raw.startswith(f"{key}:")),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
def _block_field_line(lines: Sequence[str], key: str, field: str) -> int:
|
||||
"""1-based line of ``field`` one nesting level inside the top-level ``key``.
|
||||
|
||||
Args:
|
||||
lines: the ``meta/services.yml`` lines.
|
||||
key: the top-level service key.
|
||||
field: the field to locate directly inside that block.
|
||||
|
||||
Returns:
|
||||
The field's line when it sits at exactly the block's child indent, else
|
||||
the key's own line. Matching at any depth would hand the slot to the
|
||||
``enabled: true`` inside a ``modes:`` sub-block, and a ``# nocheck``
|
||||
marker on that harmless line would then silence the top-level flag.
|
||||
"""
|
||||
start = next(
|
||||
(idx for idx, raw in enumerate(lines) if raw.startswith(f"{key}:")), -1
|
||||
)
|
||||
if start < 0:
|
||||
return 1
|
||||
key_indent = len(lines[start]) - len(lines[start].lstrip())
|
||||
child_indent = -1
|
||||
for idx in range(start + 1, len(lines)):
|
||||
raw = lines[idx]
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
indent = len(raw) - len(raw.lstrip())
|
||||
if indent <= key_indent:
|
||||
break
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
if child_indent < 0:
|
||||
child_indent = indent
|
||||
if indent == child_indent and stripped.startswith(f"{field}:"):
|
||||
return idx + 1
|
||||
return start + 1
|
||||
|
||||
|
||||
def _variant_spans(lines: Sequence[str]) -> list[tuple[int, int]]:
|
||||
"""0-based ``[start, end)`` line span of every variant in a variants.yml.
|
||||
|
||||
A list entry opens with ``-`` followed by whitespace OR by nothing at all:
|
||||
a dash alone on its own line is a valid entry start, and missing it shifts
|
||||
every later span onto the wrong variant, so an offence anchors inside a
|
||||
neighbour and a marker placed there silences the real pin.
|
||||
"""
|
||||
starts = [idx for idx, raw in enumerate(lines) if _LIST_ENTRY_RE.match(raw)]
|
||||
return [
|
||||
(start, starts[pos + 1] if pos + 1 < len(starts) else len(lines))
|
||||
for pos, start in enumerate(starts)
|
||||
]
|
||||
|
||||
|
||||
def _variant_section_span(
|
||||
lines: Sequence[str], span: tuple[int, int], section: str
|
||||
) -> tuple[int, int]:
|
||||
"""0-based ``[start, end)`` span of one variant's ``services:``/``addons:``.
|
||||
|
||||
Args:
|
||||
lines: the ``meta/variants.yml`` lines.
|
||||
span: the variant's 0-based ``[start, end)`` line span.
|
||||
section: the top-of-variant key whose block to bound.
|
||||
|
||||
Returns:
|
||||
The section's own span, or the whole variant span when the section is
|
||||
absent - which is what a YAML alias (``addons: *addons_static_off``)
|
||||
leaves behind, since the pinned ids live at the anchor instead.
|
||||
"""
|
||||
start, end = span
|
||||
for idx in range(start, end):
|
||||
stripped = _bare_key_line(lines[idx])
|
||||
if stripped != f"{section}:" and not stripped.startswith(f"{section}: "):
|
||||
continue
|
||||
column = _key_column(lines[idx])
|
||||
for inner in range(idx + 1, end):
|
||||
if not lines[inner].strip():
|
||||
continue
|
||||
if _key_column(lines[inner]) <= column:
|
||||
return (idx, inner)
|
||||
return (idx, end)
|
||||
return span
|
||||
|
||||
|
||||
def _variant_field_line(
|
||||
lines: Sequence[str], span: tuple[int, int], key: str, field: str
|
||||
) -> int:
|
||||
"""1-based line of ``field`` under ``key`` inside one variant's span.
|
||||
|
||||
Args:
|
||||
lines: the ``meta/variants.yml`` lines.
|
||||
span: the 0-based ``[start, end)`` span to search.
|
||||
key: the service or addon key pinned inside it.
|
||||
field: the field to locate under that key.
|
||||
|
||||
Returns:
|
||||
The field's line, the key's own line when the field is absent, or the
|
||||
span's first line when the key is absent - which is also what a
|
||||
flow-mapping pin (``jira: {enabled: true}``) and an aliased block
|
||||
resolve to, because neither spells the key on a line of its own.
|
||||
"""
|
||||
start, end = span
|
||||
for idx in range(start, end):
|
||||
bare = _bare_key_line(lines[idx])
|
||||
if bare != f"{key}:" and not bare.startswith(f"{key}: "):
|
||||
continue
|
||||
indent = len(lines[idx]) - len(lines[idx].lstrip())
|
||||
for inner in range(idx + 1, end):
|
||||
stripped = lines[inner].strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if len(lines[inner]) - len(lines[inner].lstrip()) <= indent:
|
||||
break
|
||||
if stripped.startswith(f"{field}:"):
|
||||
return inner + 1
|
||||
return idx + 1
|
||||
return start + 1
|
||||
|
||||
|
||||
def _variant_section(variant: Any, section: str) -> dict[str, Any]:
|
||||
block = variant.get(section) if isinstance(variant, dict) else None
|
||||
return block if isinstance(block, dict) else {}
|
||||
|
||||
|
||||
def _variant_block(variant: Any, section: str, key: str) -> dict[str, Any]:
|
||||
entry = _variant_section(variant, section).get(key)
|
||||
return entry if isinstance(entry, dict) else {}
|
||||
|
||||
|
||||
def _nested_strings(value: Any) -> list[str]:
|
||||
"""Every non-blank string *value* holds, at any depth, keys included.
|
||||
|
||||
Strings come back stripped, because the addon runtime resolves a bridge name
|
||||
stripped: an unstripped ``"jira "`` would bind nothing here while binding the
|
||||
EOL partner at deploy time.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return [stripped] if (stripped := value.strip()) else []
|
||||
if isinstance(value, dict):
|
||||
return [
|
||||
found
|
||||
for key, entry in value.items()
|
||||
for item in (key, entry)
|
||||
for found in _nested_strings(item)
|
||||
]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [found for item in value for found in _nested_strings(item)]
|
||||
return []
|
||||
|
||||
|
||||
def _addon_bridges(role_dir: Path, addon_id: str) -> list[str]:
|
||||
"""Service keys the addon's ``bridges:`` key binds.
|
||||
|
||||
Args:
|
||||
role_dir: the role owning the addon.
|
||||
addon_id: the addon id, i.e. the ``meta/addons/<id>.yml`` stem.
|
||||
|
||||
Returns:
|
||||
``[]`` when the addon declares no ``bridges`` at all, otherwise every
|
||||
string the value holds at any depth - which for the canonical list of
|
||||
strings is that list. ``test_addons_schema`` is what holds the value to
|
||||
the canonical shape, but it is suppressible from the addon's head, so
|
||||
reading only that shape would let a scalar ``bridges: jira`` plus one
|
||||
``# nocheck: addon-schema`` bypass this rule entirely while the loader
|
||||
still resolves the addon.
|
||||
"""
|
||||
spec = _load_yaml(role_dir / ROLE_DIR_META_ADDONS / f"{addon_id}.yml")
|
||||
if not isinstance(spec, dict) or "bridges" not in spec:
|
||||
return []
|
||||
return _nested_strings(spec["bridges"])
|
||||
|
||||
|
||||
def _addon_gate_re(role: str, key: str) -> re.Pattern[str]:
|
||||
"""Anchored pattern for the one gate the stored literal contains.
|
||||
|
||||
Args:
|
||||
role: the role whose ``meta/services.yml`` stores the flag.
|
||||
key: the EOL service key.
|
||||
|
||||
Returns:
|
||||
A pattern matching the whole ``enabled`` expression, so a negated or
|
||||
``or``-ed variation of the same lookup cannot pass as containment.
|
||||
Either quote character and whitespace after ``lookup(`` and around the
|
||||
commas are accepted: they are the same expression, and rejecting them
|
||||
buys no containment.
|
||||
"""
|
||||
return re.compile(
|
||||
r"^\{\{\s*lookup\(\s*(['\"])config\1\s*,\s*(['\"])"
|
||||
+ re.escape(role)
|
||||
+ r"\2\s*,\s*(['\"])services\."
|
||||
+ re.escape(key)
|
||||
+ r"\.enabled\3\s*\)\s*\|\s*bool\s*\}\}$"
|
||||
)
|
||||
|
||||
|
||||
def _addon_enabled(spec: dict[str, Any]) -> Any:
|
||||
"""The addon's resolved enable state, as the loader resolves it.
|
||||
|
||||
Args:
|
||||
spec: the addon spec mapping.
|
||||
|
||||
Returns:
|
||||
The declared ``enabled`` value verbatim, or - when the key is absent -
|
||||
the boolean ``required`` falls back to, mirroring
|
||||
``utils.cache.applications._normalize_addons``.
|
||||
"""
|
||||
if _FLAG in spec:
|
||||
return spec[_FLAG]
|
||||
return bool(spec.get("required", False))
|
||||
|
||||
|
||||
def _exemption_footer() -> str:
|
||||
dropped = exempt_eol_roles()
|
||||
if not dropped:
|
||||
return ""
|
||||
return (
|
||||
f"\nDropped from the EOL set by '# nocheck: {_PROVIDER_RULE}' in the "
|
||||
f"head of their own meta/services.yml, so every consumer of them is "
|
||||
f"exempt: {', '.join(dropped)}."
|
||||
)
|
||||
|
||||
|
||||
class TestEolDependencyRequiresEolConsumer(unittest.TestCase):
|
||||
def test_no_eol_role_exempts_every_consumer(self) -> None:
|
||||
self.assertEqual(
|
||||
sorted(set(exempt_eol_roles()) - WHOLE_RULE_EXEMPTIONS),
|
||||
[],
|
||||
f"A '# nocheck: {_PROVIDER_RULE}' in the head of an EOL role's own "
|
||||
"meta/services.yml drops every consumer of that service across the "
|
||||
"whole repository, so it is not a per-case opt-out and may not land "
|
||||
"unreviewed. Keep the marker only for an EOL entity that still "
|
||||
"backs a live surface, and record the role in "
|
||||
"WHOLE_RULE_EXEMPTIONS in this file together with the reason:\n "
|
||||
+ "\n ".join(sorted(set(exempt_eol_roles()) - WHOLE_RULE_EXEMPTIONS)),
|
||||
)
|
||||
|
||||
def test_no_whole_rule_marker_is_bare(self) -> None:
|
||||
self.assertEqual(
|
||||
sorted(bare_exemption_roles()),
|
||||
[],
|
||||
f"A '# nocheck: {_PROVIDER_RULE}' exempts every consumer of the "
|
||||
"service at once, so it is held to the same bar as a per-flag "
|
||||
"marker: it MUST name why the entity still backs a live surface "
|
||||
"the rule has no claim on. A bare one grants no exemption and is "
|
||||
"reported here rather than left in the tree looking granted:\n "
|
||||
+ "\n ".join(sorted(bare_exemption_roles())),
|
||||
)
|
||||
|
||||
def test_no_role_enables_an_eol_service(self) -> None:
|
||||
eol = eol_service_keys()
|
||||
offenders: list[str] = []
|
||||
|
||||
for role_dir in _role_dirs():
|
||||
services_path = role_dir / ROLE_FILE_META_SERVICES
|
||||
services = _load_yaml(services_path)
|
||||
if not isinstance(services, dict):
|
||||
continue
|
||||
lines = _lines(services_path)
|
||||
rel = services_path.relative_to(PROJECT_ROOT)
|
||||
|
||||
for key, provider in sorted(_keys_to_check(role_dir, eol).items()):
|
||||
entry = services.get(key)
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
line_no = _block_field_line(lines, key, _FLAG)
|
||||
suffix = _offence_suffix(lines, line_no)
|
||||
if suffix is None:
|
||||
continue
|
||||
|
||||
if provider == role_dir.name and _FLAG not in entry:
|
||||
offenders.append(
|
||||
f"{role_dir.name}: {rel}:{line_no} stores no "
|
||||
f"services.{key}.{_FLAG}, but {provider} is "
|
||||
f"lifecycle: eol and has to carry the literal "
|
||||
f"false{suffix}"
|
||||
)
|
||||
elif _FLAG in entry and entry[_FLAG] is not False:
|
||||
offenders.append(
|
||||
f"{role_dir.name}: {rel}:{line_no} declares "
|
||||
f"services.{key}.{_FLAG} = {entry[_FLAG]!r}, but "
|
||||
f"{provider} is lifecycle: eol{suffix}"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"An EOL service MUST be stored off and may only be enabled by a "
|
||||
"role that is itself EOL; every other consumer binds a partner no "
|
||||
"CI round deploys. Store `enabled: false` as a literal (a `{{ "
|
||||
"'<role>' in group_names }}` gate turns true the moment the EOL "
|
||||
f"role joins an inventory), or suppress with '# nocheck: {_RULE}' "
|
||||
"naming why the dependency outlives the upstream:\n "
|
||||
+ "\n ".join(offenders)
|
||||
+ _exemption_footer(),
|
||||
)
|
||||
|
||||
def test_no_variant_pins_an_eol_service_on(self) -> None:
|
||||
eol = eol_service_keys()
|
||||
offenders: list[str] = []
|
||||
|
||||
for role_dir in _role_dirs():
|
||||
variants_path = role_dir / ROLE_FILE_META_VARIANTS
|
||||
variants = _load_yaml(variants_path)
|
||||
if not isinstance(variants, list) or not variants:
|
||||
continue
|
||||
lines = _lines(variants_path)
|
||||
spans = _variant_spans(lines)
|
||||
rel = variants_path.relative_to(PROJECT_ROOT)
|
||||
checks = _keys_to_check(role_dir, eol)
|
||||
|
||||
for index, variant in enumerate(variants):
|
||||
span = spans[index] if index < len(spans) else (0, len(lines))
|
||||
offenders.extend(
|
||||
self._variant_service_offenders(
|
||||
role_dir, variant, checks, lines, span, rel, index
|
||||
)
|
||||
)
|
||||
offenders.extend(
|
||||
self._variant_addon_offenders(
|
||||
role_dir, variant, checks, lines, span, rel, index
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"No variant may re-enable an EOL service, not even a variant of "
|
||||
"the EOL role itself, and not through an addon whose `bridges:` "
|
||||
"list names it; that round would deploy an app the lifecycle "
|
||||
"envelope excludes from every test. Remove the override so the "
|
||||
"literal `false` from meta/services.yml stands (restating it there "
|
||||
"is what `variants-static-override` flags), or suppress with "
|
||||
f"'# nocheck: {_RULE}' naming why the pin is sound:\n "
|
||||
+ "\n ".join(offenders)
|
||||
+ _exemption_footer(),
|
||||
)
|
||||
|
||||
def _variant_service_offenders(
|
||||
self,
|
||||
role_dir: Path,
|
||||
variant: Any,
|
||||
checks: dict[str, str],
|
||||
lines: Sequence[str],
|
||||
span: tuple[int, int],
|
||||
rel: Any,
|
||||
index: int,
|
||||
) -> list[str]:
|
||||
section = _variant_section_span(lines, span, "services")
|
||||
bounds = (span[0] + 1, span[1])
|
||||
offenders: list[str] = []
|
||||
for key, provider in sorted(checks.items()):
|
||||
block = _variant_block(variant, "services", key)
|
||||
if _FLAG not in block or block[_FLAG] is False:
|
||||
continue
|
||||
line_no = _variant_field_line(lines, section, key, _FLAG)
|
||||
suffix = _offence_suffix(lines, line_no, bounds)
|
||||
if suffix is None:
|
||||
continue
|
||||
offenders.append(
|
||||
f"{role_dir.name}: {rel}:{line_no} variant {index} pins "
|
||||
f"services.{key}.{_FLAG} = {block[_FLAG]!r}, but {provider} "
|
||||
f"is lifecycle: eol{suffix}"
|
||||
)
|
||||
return offenders
|
||||
|
||||
def _variant_addon_offenders(
|
||||
self,
|
||||
role_dir: Path,
|
||||
variant: Any,
|
||||
checks: dict[str, str],
|
||||
lines: Sequence[str],
|
||||
span: tuple[int, int],
|
||||
rel: Any,
|
||||
index: int,
|
||||
) -> list[str]:
|
||||
section = _variant_section_span(lines, span, "addons")
|
||||
bounds = (span[0] + 1, span[1])
|
||||
offenders: list[str] = []
|
||||
for addon_id in sorted(_variant_section(variant, "addons")):
|
||||
pin = _variant_block(variant, "addons", addon_id)
|
||||
if _FLAG not in pin or pin[_FLAG] is False:
|
||||
continue
|
||||
bound = sorted(
|
||||
key for key in _addon_bridges(role_dir, addon_id) if key in checks
|
||||
)
|
||||
if not bound:
|
||||
continue
|
||||
line_no = _variant_field_line(lines, section, addon_id, _FLAG)
|
||||
suffix = _offence_suffix(lines, line_no, bounds)
|
||||
if suffix is None:
|
||||
continue
|
||||
offenders.extend(
|
||||
f"{role_dir.name}: {rel}:{line_no} variant {index} pins "
|
||||
f"addons.{addon_id}.{_FLAG} = {pin[_FLAG]!r}, which bridges "
|
||||
f"'{key}', but {checks[key]} is lifecycle: eol{suffix}"
|
||||
for key in bound
|
||||
)
|
||||
return offenders
|
||||
|
||||
def test_no_addon_bridges_an_eol_service(self) -> None:
|
||||
eol = eol_service_keys()
|
||||
offenders: list[str] = []
|
||||
|
||||
for role_dir in _role_dirs():
|
||||
if get_role_lifecycle(role_dir) == _EOL:
|
||||
continue
|
||||
addons_dir = role_dir / ROLE_DIR_META_ADDONS
|
||||
if not addons_dir.is_dir():
|
||||
continue
|
||||
|
||||
for addon_path in sorted(addons_dir.glob("*.yml")):
|
||||
spec = _load_yaml(addon_path)
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
bound = sorted(
|
||||
{
|
||||
item
|
||||
for item in _addon_bridges(role_dir, addon_path.stem)
|
||||
if item in eol
|
||||
}
|
||||
)
|
||||
if not bound:
|
||||
continue
|
||||
enabled = _addon_enabled(spec)
|
||||
if enabled is False:
|
||||
continue
|
||||
lines = _lines(addon_path)
|
||||
rel = addon_path.relative_to(PROJECT_ROOT)
|
||||
line_no = _top_level_key_line(
|
||||
lines, _FLAG if _FLAG in spec else "bridges"
|
||||
)
|
||||
|
||||
for key in bound:
|
||||
if isinstance(enabled, str) and _addon_gate_re(
|
||||
role_dir.name, key
|
||||
).fullmatch(enabled.strip()):
|
||||
continue
|
||||
suffix = _offence_suffix(lines, line_no)
|
||||
if suffix is None:
|
||||
continue
|
||||
offenders.append(
|
||||
f"{role_dir.name}: {rel}:{line_no} bridges '{key}' with "
|
||||
f"{_FLAG} = {enabled!r}, but {eol[key]} is "
|
||||
f"lifecycle: eol{suffix}"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"An addon of a role that is not itself EOL may bridge an EOL "
|
||||
"service only when its own gate cannot turn true: either a literal "
|
||||
"`enabled: false`, or exactly `{{ lookup('config', '<own-role>', "
|
||||
"'services.<eol-key>.enabled') | bool }}`, which the services walk "
|
||||
"holds to a literal false. A gate that merely mentions that lookup "
|
||||
"may still render true, and anything else installs an integration "
|
||||
"against an app no CI round deploys. Fix the gate or suppress with "
|
||||
f"'# nocheck: {_RULE}' naming why the bridge outlives the "
|
||||
"upstream:\n " + "\n ".join(offenders) + _exemption_footer(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Lint guard: a role tagged ``lifecycle: eol`` carries the README banner.
|
||||
|
||||
``docs/contributing/design/role/services/lifecycle.md`` makes the banner a MUST
|
||||
for the ``eol`` stage: the README has to state that the project neither
|
||||
maintains nor tests the role and that an operator runs it at their own risk.
|
||||
Nothing enforced it, so all four EOL roles shipped without one - the flag flip
|
||||
and the banner are two separate edits, and only the flag has a lint behind it.
|
||||
|
||||
Three things are mechanical, and they are exactly the three the policy calls a
|
||||
MUST:
|
||||
|
||||
* position: the banner starts on the FIRST non-blank line after the H1.
|
||||
Searching the whole file instead would pass a banner buried at the bottom
|
||||
while the failure message demands it under the title, which is the gap this
|
||||
placement closes - an operator who reads the first screen of a README is the
|
||||
reader the banner exists for.
|
||||
* opening: that line starts with ``> **End of life.**``, so one blockquote
|
||||
shape covers every role. The ``>`` may or may not be followed by a space.
|
||||
* clauses: the blockquote states ``neither maintains nor tests`` and ``at your
|
||||
own risk``, the two claims the policy spells out. The clauses are matched over
|
||||
the whole contiguous ``>`` run, not over one physical line: a Markdown
|
||||
blockquote renders as one paragraph however it is wrapped, so judging the
|
||||
first line alone rejects a conforming banner with a message denying a clause
|
||||
the README does carry.
|
||||
|
||||
The vendor link the stage SHOULD carry is not mechanical and is not checked;
|
||||
a link to nowhere useful is a review question, not a regex.
|
||||
|
||||
No suppression marker exists. The stage's own criteria carry no exception: a
|
||||
role whose README stays silent about its end of life is a role an operator
|
||||
adopts believing the project stands behind it. Demote the ``lifecycle`` value
|
||||
instead when the banner does not apply.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from utils.cache.files import read_text
|
||||
from utils.roles.meta_lookup import get_role_lifecycle
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
ROLES_DIR = PROJECT_ROOT / "roles"
|
||||
_EOL = "eol"
|
||||
_README = "README.md"
|
||||
_PREFIX = "> **End of life.**"
|
||||
_PREFIX_RE = re.compile(r"^>\s?\*\*End of life\.\*\*")
|
||||
_H1_RE = re.compile(r"^#\s+\S")
|
||||
_CLAUSES: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("`neither maintains nor tests`", re.compile(r"neither maintains nor tests\b")),
|
||||
("`at your own risk`", re.compile(r"at your own risk\b")),
|
||||
)
|
||||
|
||||
|
||||
def _quote_run(lines: list[str], first: int) -> list[str]:
|
||||
"""The contiguous blockquote lines starting at 0-based *first*.
|
||||
|
||||
Args:
|
||||
lines: the README's lines.
|
||||
first: the index the run has to start on.
|
||||
|
||||
Returns:
|
||||
The run's lines with their leading indentation stripped, so the ``>``
|
||||
marker is the first character of the first line, or ``[]`` when *first*
|
||||
is not a blockquote line. Lines after the first need no ``>``: Markdown
|
||||
renders a lazy continuation as part of the same blockquote, so refusing
|
||||
one would reject a conforming banner that merely wraps. Four or more
|
||||
leading spaces make it a code block, not a quote.
|
||||
"""
|
||||
if len(lines[first]) - len(lines[first].lstrip(" ")) >= 4:
|
||||
return []
|
||||
run: list[str] = []
|
||||
for raw in lines[first:]:
|
||||
stripped = raw.lstrip()
|
||||
if not stripped:
|
||||
break
|
||||
if not stripped.startswith(">") and not run:
|
||||
break
|
||||
run.append(stripped)
|
||||
return run
|
||||
|
||||
|
||||
def banner_offence(text: str) -> str | None:
|
||||
"""Why *text* fails the banner contract.
|
||||
|
||||
Args:
|
||||
text: the README's full contents.
|
||||
|
||||
Returns:
|
||||
The clause of the offence, or ``None`` when the banner holds.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
h1 = next((idx for idx, raw in enumerate(lines) if _H1_RE.match(raw)), None)
|
||||
if h1 is None:
|
||||
return "carries no H1 for the banner to sit under"
|
||||
first = next((idx for idx in range(h1 + 1, len(lines)) if lines[idx].strip()), None)
|
||||
if first is None:
|
||||
return "ends after its H1, so it carries no end-of-life banner"
|
||||
run = _quote_run(lines, first)
|
||||
if not run or not _PREFIX_RE.match(run[0]):
|
||||
return (
|
||||
f"line {first + 1} is the first line under the H1 and does not open "
|
||||
f"the end-of-life banner with '{_PREFIX}'"
|
||||
)
|
||||
banner = " ".join(line.lstrip(">").strip() for line in run)
|
||||
missing = [name for name, pattern in _CLAUSES if not pattern.search(banner)]
|
||||
if missing:
|
||||
return (
|
||||
f"the banner opening on line {first + 1} states no "
|
||||
f"{' and no '.join(missing)} clause"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class TestEolReadmeBanner(unittest.TestCase):
|
||||
def test_every_eol_role_readme_carries_the_banner(self) -> None:
|
||||
offenders: list[str] = []
|
||||
|
||||
for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()):
|
||||
if get_role_lifecycle(role_dir) != _EOL:
|
||||
continue
|
||||
readme = role_dir / _README
|
||||
if not readme.is_file():
|
||||
offenders.append(f"{role_dir.name}: no {_README}")
|
||||
continue
|
||||
offence = banner_offence(read_text(str(readme)))
|
||||
if offence:
|
||||
offenders.append(
|
||||
f"{role_dir.name}: {readme.relative_to(PROJECT_ROOT)} {offence}"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"Every role whose primary entity declares `lifecycle: eol` MUST "
|
||||
"open its README with the banner, so an operator reading the role "
|
||||
"learns it is unmaintained and untested before deploying it. The "
|
||||
"banner is the first line under the H1:\n"
|
||||
" > **End of life.** Infinito.Nexus neither maintains nor tests "
|
||||
"this role, and the default `INFINITO_LIFECYCLES` envelope keeps it "
|
||||
"out of every CI round. Deploy it at your own risk and take support "
|
||||
"from [<vendor>](<url>).\n"
|
||||
"Offenders:\n " + "\n ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Lint: every ``INFINITO_LIFECYCLES`` token names a real lifecycle stage.
|
||||
|
||||
``INFINITO_LIFECYCLES`` in ``default.env`` is the envelope the CI test-deploy
|
||||
discovery exercises (``scripts/meta/resolve/apps.sh --lifecycles``). A token
|
||||
that matches no stage matches no role, and nothing says so: discovery just
|
||||
returns a shorter list. Measured on this repository, the envelope read
|
||||
``alpha beta rc stable maintained`` while the stage
|
||||
(``docs/contributing/design/role/services/lifecycle.md``) is ``maintenance``.
|
||||
No role sits at that stage today, so the typo cost nothing - and the first
|
||||
promotion to it would have dropped silently out of the tested envelope, with a
|
||||
green pipeline and an untested role.
|
||||
|
||||
The valid set is not restated here. It is ``ALLOWED_LIFECYCLES`` from
|
||||
``tests.lint.ansible.roles.meta.test_layout``, the same set that lint holds
|
||||
every role's ``meta/services.yml.<entity>.lifecycle`` to, so a stage added
|
||||
there reaches this rule in the same commit.
|
||||
|
||||
The envelope is split the way the Python consumer splits it:
|
||||
``utils.roles.lifecycle.tested_lifecycles`` calls ``str.split``, so whitespace
|
||||
is the only separator. A comma is NOT one, which is the whole point of reading
|
||||
it this way: ``alpha,beta`` reaches ``tested_lifecycles`` as a single token that
|
||||
matches no stage, while ``cli.meta.roles.applications.complexity.cli``'s
|
||||
``parse_lifecycles`` splits the ``--lifecycles`` argv on ``[,\\s]+`` and accepts
|
||||
it. Splitting on commas here would pass exactly the silent drop this rule
|
||||
exists to report. Tokens are lowercased the way ``parse_lifecycles`` lowercases
|
||||
them, and :meth:`test_the_lint_and_its_consumer_read_the_same_envelope` asserts
|
||||
the derived set against ``tested_lifecycles()`` itself, so the two can never
|
||||
drift apart unnoticed - a capitalised stage that the argv path accepts and the
|
||||
Python path does not is reported as that disagreement rather than as an unknown
|
||||
stage.
|
||||
|
||||
Only the tracked ``default.env`` is read. The generated ``.env`` is a local
|
||||
artefact a targeted test run can rewrite, so it is not evidence about the
|
||||
repository; after changing the value here, ``make dotenv-force`` is what
|
||||
refreshes it (plain ``make dotenv`` keeps the stale one).
|
||||
|
||||
No suppression marker exists: a token outside the axis is a typo, never a
|
||||
decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tests.lint.ansible.roles.meta.test_layout import ALLOWED_LIFECYCLES
|
||||
from utils.env.parser import parse_static_env
|
||||
from utils.roles import lifecycle
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
_KEY = "INFINITO_LIFECYCLES"
|
||||
|
||||
_TESTED_STAGES = frozenset({"alpha", "beta", "rc", "stable", "maintenance"})
|
||||
_UNTESTED_STAGES = frozenset(ALLOWED_LIFECYCLES) - _TESTED_STAGES
|
||||
|
||||
|
||||
def envelope_tokens(raw: str) -> list[str]:
|
||||
"""Split the envelope value the way ``tested_lifecycles`` consumes it.
|
||||
|
||||
Args:
|
||||
raw: the ``INFINITO_LIFECYCLES`` value.
|
||||
|
||||
Returns:
|
||||
The whitespace-separated tokens, lowercased, in declaration order. A
|
||||
comma stays part of its token: the consumer splits on whitespace only,
|
||||
so ``alpha,beta`` is one token that matches no stage, and reading it as
|
||||
two would hide that.
|
||||
"""
|
||||
return [token.lower() for token in raw.split()]
|
||||
|
||||
|
||||
class TestLifecycleEnvelopeTokens(unittest.TestCase):
|
||||
def test_every_envelope_token_is_a_lifecycle_stage(self) -> None:
|
||||
env = parse_static_env(PROJECT_ROOT / "default.env")
|
||||
self.assertIn(
|
||||
_KEY,
|
||||
env,
|
||||
f"default.env declares no {_KEY}; the CI test-deploy discovery "
|
||||
f"reads it to decide which roles have a row at all.",
|
||||
)
|
||||
|
||||
tokens = envelope_tokens(env[_KEY])
|
||||
self.assertTrue(
|
||||
tokens,
|
||||
f"default.env sets {_KEY} to an empty envelope, which "
|
||||
f"matches no role at all.",
|
||||
)
|
||||
|
||||
unknown = sorted({t for t in tokens if t not in ALLOWED_LIFECYCLES})
|
||||
self.assertEqual(
|
||||
unknown,
|
||||
[],
|
||||
f"Every token in {_KEY} MUST name a lifecycle stage, or it "
|
||||
f"silently matches no role and the stage drops out of the tested "
|
||||
f"envelope with a green pipeline. Known stages: "
|
||||
f"{', '.join(sorted(ALLOWED_LIFECYCLES))}. Fix default.env and run "
|
||||
f"`make dotenv-force` so the generated .env follows. Unknown:\n "
|
||||
+ "\n ".join(unknown),
|
||||
)
|
||||
|
||||
def test_the_lint_and_its_consumer_read_the_same_envelope(self) -> None:
|
||||
env = parse_static_env(PROJECT_ROOT / "default.env")
|
||||
self.assertIn(
|
||||
_KEY,
|
||||
env,
|
||||
f"default.env declares no {_KEY}; the CI test-deploy discovery "
|
||||
f"reads it to decide which roles have a row at all.",
|
||||
)
|
||||
derived = sorted(set(envelope_tokens(env[_KEY])))
|
||||
consumed = sorted(lifecycle.tested_lifecycles())
|
||||
self.assertEqual(
|
||||
derived,
|
||||
consumed,
|
||||
f"The envelope this rule judges MUST be the one the consumer "
|
||||
f"reads, or the rule can pass on a value that drops stages "
|
||||
f"silently. utils.roles.lifecycle.tested_lifecycles() splits "
|
||||
f"{_KEY} on whitespace and does not lowercase it, so any value "
|
||||
f"these two read differently - a comma, a capital letter - is a "
|
||||
f"stage the argv path "
|
||||
f"(cli.meta.roles.applications.complexity.cli.parse_lifecycles) "
|
||||
f"accepts while every Python consumer of the envelope matches no "
|
||||
f"role with it. Fix default.env:\n "
|
||||
f"this rule reads {derived}\n consumer reads {consumed}",
|
||||
)
|
||||
|
||||
def test_the_untested_stages_stay_out_of_the_envelope(self) -> None:
|
||||
env = parse_static_env(PROJECT_ROOT / "default.env")
|
||||
self.assertIn(
|
||||
_KEY,
|
||||
env,
|
||||
f"default.env declares no {_KEY}; the CI test-deploy discovery "
|
||||
f"reads it to decide which roles have a row at all.",
|
||||
)
|
||||
|
||||
admitted = sorted(set(envelope_tokens(env[_KEY])) & _UNTESTED_STAGES)
|
||||
self.assertEqual(
|
||||
admitted,
|
||||
[],
|
||||
f"{_KEY} MUST NOT admit a stage the lifecycle policy puts outside "
|
||||
f"the tested envelope. The eol rules rest on this: an end-of-life "
|
||||
f"role stays out of every default CI round, which is why its "
|
||||
f"consumers may store a literal false and ship no live "
|
||||
f"integration. Admitting one here makes that rationale false "
|
||||
f"while every other check stays green. Outside the envelope: "
|
||||
f"{', '.join(sorted(_UNTESTED_STAGES))}. Admitted:\n "
|
||||
+ "\n ".join(admitted),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user