From eda9474e37ee391430a86bd1ab8561859cd9263e Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 14 Oct 2026 00:26:55 +0200 Subject: [PATCH] test(lint): refuse a default in os.environ.get, and resolve the 25 sites The deploy decides what ADAPTER_PORT or OPENWEBUI_ADMIN_NAME is and writes it into the environment. A default in the reader is a second place that value lives, and it is the one nobody greps for. os.environ["X"] fails loudly on a missing key, which is the behaviour a misconfiguration deserves. Empty values stay free, because they add no second source and only say absent: "", None, False, 0, the empty containers, and the "[]"/"{}" a json.loads reader hands an absent variable. A default that is itself an environ.get is free too, since it reads the one source again under another key; a literal ending such a chain is still reported. Detection runs on the parsed tree, so a call split across lines reads the same. Five sites were real duplication and lost their default: the adapter port, openwebui's admin name (a role that runs as a bot user), odoo's OIDC uid field, seaweedfs' public-primary flag, and the swarm hosts entry IP. Nineteen carry a nocheck naming why the value is not the deploy's: contributor tooling whose default is the only source, test scaffolding that writes the file it names, and two readers of CA_TRUST_CERT_EXTRA_HOST, which nothing in the repo sets. utils/diagnostics/container.py resolves without a marker: RESCUE_DEPTH is a recursion base case, and the integer 0 says what the string "0" said. The file sits exactly at the 500-line cap, so a comment line was not available. Co-Authored-By: Claude Opus 5 (1M context) --- .../deploy/container/command.py | 1 + cli/contributing/network/diagnose/report.py | 1 + roles/svc-ai-litellm/files/test/probe.py | 2 + .../svc-ai-mcp-adapter/files/python/server.py | 2 +- .../files/python/tor_smtp_gateway.py | 2 + .../files/python/compose_ca.py | 1 + .../files/python/container.py | 2 +- .../addons/auth_oauth_https/__manifest__.py | 4 +- .../auth_oauth_https/models/res_users.py | 24 ++- .../files/python/provision_mcp_grants.py | 2 +- roles/web-svc-seaweedfs/files/test/probe.py | 2 +- tests/lint/repository/python/__init__.py | 3 + .../python/test_environ_get_has_no_default.py | 186 ++++++++++++++++++ .../svc-ai-mcp-adapter/files/test_server.py | 6 +- .../files/test_server_passthrough.py | 6 +- .../files/test_provision_mcp_grants.py | 1 + utils/diagnostics/container.py | 2 +- utils/install/lint/actionlint.py | 1 + utils/install/lint/ansible/lint.py | 1 + utils/install/lint/hadolint.py | 1 + utils/install/lint/mbake.py | 1 + utils/install/lint/shfmt.py | 1 + utils/tests/swarm/extend_inventory.py | 1 + utils/tests/swarm/force_shared_db.py | 1 + utils/tests/swarm/matrix.py | 1 + utils/tests/swarm/write/extras.py | 2 + utils/tests/swarm/write/hosts_entries.py | 6 +- 27 files changed, 241 insertions(+), 22 deletions(-) create mode 100644 tests/lint/repository/python/__init__.py create mode 100644 tests/lint/repository/python/test_environ_get_has_no_default.py diff --git a/cli/administration/deploy/container/command.py b/cli/administration/deploy/container/command.py index 99d4131fa3..2330cbad14 100644 --- a/cli/administration/deploy/container/command.py +++ b/cli/administration/deploy/container/command.py @@ -228,6 +228,7 @@ def run_in_container( ) print(">>> Mocking backup authorized_keys file inside CI inventory...") + # nocheck: environ-get-default the CI inventory has no real backup key to copy; this placeholder is the mock itself, and nothing in the repo sets AUTHORIZED_KEYS mock_key = os.environ.get( "AUTHORIZED_KEYS", "ssh-ed25519 AAAA_TEST_DUMMY_KEY github-ci-dummy@infinito", diff --git a/cli/contributing/network/diagnose/report.py b/cli/contributing/network/diagnose/report.py index d8d9da21a0..ad7ef6bcee 100644 --- a/cli/contributing/network/diagnose/report.py +++ b/cli/contributing/network/diagnose/report.py @@ -77,6 +77,7 @@ def show_iface_routes() -> None: def show_proxies() -> None: section("proxy env vars") for key in PROXY_ENV_KEYS: + # nocheck: environ-get-default the placeholder is printed, never used as a value, and it keeps an absent proxy variable distinguishable from one set to the empty string line(key, os.environ.get(key, "")) diff --git a/roles/svc-ai-litellm/files/test/probe.py b/roles/svc-ai-litellm/files/test/probe.py index a9ab4b26df..7860b863f2 100644 --- a/roles/svc-ai-litellm/files/test/probe.py +++ b/roles/svc-ai-litellm/files/test/probe.py @@ -322,7 +322,9 @@ def main() -> int: base = f"http://127.0.0.1:{os.environ['PORT']}" chat_model = os.environ["CHAT_MODEL"].strip() chat_model_served = os.environ["CHAT_MODEL_SERVED"] == "true" + # nocheck: environ-get-default test.sh passes an explicit -e list into the container and RETRIES is not in it; the attempt budget is the probe's own retries = int(os.environ.get("RETRIES", "10")) + # nocheck: environ-get-default likewise unlisted in test.sh's -e set; the wait between attempts is the probe's own pause = float(os.environ.get("SLEEP_SECONDS", "6")) listing = _call(base, key, "/v1/models") diff --git a/roles/svc-ai-mcp-adapter/files/python/server.py b/roles/svc-ai-mcp-adapter/files/python/server.py index ee5f3c20f0..b3b1d01468 100644 --- a/roles/svc-ai-mcp-adapter/files/python/server.py +++ b/roles/svc-ai-mcp-adapter/files/python/server.py @@ -61,7 +61,7 @@ UPSTREAM_URL = policy.upstream_url( os.environ.get("ADAPTER_UPSTREAM_PATH_KEY", ""), os.environ.get("ADAPTER_UPSTREAM_PATH_SUFFIX", ""), ) -PORT = int(os.environ.get("ADAPTER_PORT", "8080")) +PORT = int(os.environ["ADAPTER_PORT"]) policy.assert_no_drift(CONTRACT) diff --git a/roles/svc-net-tor-smtp/files/python/tor_smtp_gateway.py b/roles/svc-net-tor-smtp/files/python/tor_smtp_gateway.py index 14f22ed970..a98b1d3dbe 100755 --- a/roles/svc-net-tor-smtp/files/python/tor_smtp_gateway.py +++ b/roles/svc-net-tor-smtp/files/python/tor_smtp_gateway.py @@ -39,11 +39,13 @@ log = logging.getLogger("tor-smtp-gateway") ONION_SMTP_PORT = 25 +# nocheck: environ-get-default templates/env.j2 writes only the four config-derived keys; nothing renders TOR_SMTP_LISTEN_HOST, so the bind address is this file's own LISTEN_HOST = os.environ.get("TOR_SMTP_LISTEN_HOST", "0.0.0.0") # noqa: S104 - relay bind; reachability is gated by the compose network, not this bind LISTEN_PORT = int(os.environ["TOR_SMTP_LISTEN_PORT"]) SOCKS_HOST = os.environ["TOR_SOCKS_HOST"] SOCKS_PORT = int(os.environ["TOR_SOCKS_PORT"]) HELO_NAME = os.environ["TOR_SMTP_HELO"] +# nocheck: environ-get-default no template renders TOR_SMTP_TIMEOUT; the per-hop budget of a Tor circuit is the gateway's own, not the inventory's TIMEOUT = int(os.environ.get("TOR_SMTP_TIMEOUT", "120")) diff --git a/roles/sys-svc-compose-ca/files/python/compose_ca.py b/roles/sys-svc-compose-ca/files/python/compose_ca.py index ef46f9d059..7cec376626 100755 --- a/roles/sys-svc-compose-ca/files/python/compose_ca.py +++ b/roles/sys-svc-compose-ca/files/python/compose_ca.py @@ -526,6 +526,7 @@ def _load_services_via_config( def _cache_frontend_ca_host() -> str: """Runner-internal path to the package-cache frontend CA, or '' when the cache is inactive.""" + # nocheck: environ-get-default nothing in the repo writes CA_TRUST_CERT_EXTRA_HOST; the path mirrors CA_SRC in roles/svc-cache-package/files/shell/ca.sh, and this file is copied to the target as a stdlib-only script, so it cannot import that constant path = os.environ.get( "CA_TRUST_CERT_EXTRA_HOST", "/opt/package-frontend-ca.crt" ).strip() diff --git a/roles/sys-svc-container/files/python/container.py b/roles/sys-svc-container/files/python/container.py index 1473caa77c..e1d9773f5a 100755 --- a/roles/sys-svc-container/files/python/container.py +++ b/roles/sys-svc-container/files/python/container.py @@ -342,7 +342,7 @@ def container_run(argv: list[str], debug: bool, with_ca: bool) -> int: wrapper_container, ] extra_opts: list[str] = [] - extra_host = os.environ.get( + extra_host = os.environ.get( # nocheck: environ-get-default nothing in the repo writes CA_TRUST_CERT_EXTRA_HOST; the path mirrors CA_SRC in roles/svc-cache-package/files/shell/ca.sh, and this file is copied to the target as a stdlib-only script, so it cannot import that constant "CA_TRUST_CERT_EXTRA_HOST", "/opt/package-frontend-ca.crt" ).strip() extra_path = Path(extra_host) if extra_host else None diff --git a/roles/web-app-odoo/files/addons/auth_oauth_https/__manifest__.py b/roles/web-app-odoo/files/addons/auth_oauth_https/__manifest__.py index 70528d4956..59973a4cdc 100644 --- a/roles/web-app-odoo/files/addons/auth_oauth_https/__manifest__.py +++ b/roles/web-app-odoo/files/addons/auth_oauth_https/__manifest__.py @@ -24,7 +24,7 @@ Standard Odoo uses 'user_id' from the OAuth userinfo response to identify users. This claim is non-standard; Keycloak maps its internal UUID to it by default. This module changes user identification to use a configurable claim field, -set via the ODOO_OIDC_UID_FIELD environment variable (default: 'preferred_username'). +set via the ODOO_OIDC_UID_FIELD environment variable. This aligns Odoo with other applications in the infinito.nexus stack: - Mastodon: OIDC_UID_FIELD = preferred_username - Pixelfed: PF_OIDC_FIELD_ID = preferred_username @@ -34,7 +34,7 @@ This aligns Odoo with other applications in the infinito.nexus stack: Configuration: -------------- Set ODOO_OIDC_UID_FIELD environment variable to the OIDC claim to use for user -identification. Defaults to 'preferred_username' if not set. +identification. The deploy renders it from OIDC.ATTRIBUTES.USERNAME. Features: --------- diff --git a/roles/web-app-odoo/files/addons/auth_oauth_https/models/res_users.py b/roles/web-app-odoo/files/addons/auth_oauth_https/models/res_users.py index 1e3d0794cd..b6fcf77b68 100644 --- a/roles/web-app-odoo/files/addons/auth_oauth_https/models/res_users.py +++ b/roles/web-app-odoo/files/addons/auth_oauth_https/models/res_users.py @@ -9,8 +9,6 @@ from odoo.exceptions import AccessDenied _logger = logging.getLogger(__name__) -OIDC_UID_FIELD = os.environ.get("ODOO_OIDC_UID_FIELD", "preferred_username") - class ResUsersOAuthConfigurableUid(models.Model): """ @@ -20,8 +18,8 @@ class ResUsersOAuthConfigurableUid(models.Model): provider's userinfo response to identify users. However, this claim is not standard in OIDC - Keycloak maps the internal UUID as user_id by default. - This override changes the lookup to use a configurable claim field - (default: 'preferred_username'), set via the ODOO_OIDC_UID_FIELD env var. + This override changes the lookup to use the claim field the deploy names + in ODOO_OIDC_UID_FIELD, rendered from OIDC.ATTRIBUTES.USERNAME. This aligns Odoo with how other applications in the infinito.nexus stack identify users: - Mastodon: OIDC_UID_FIELD = preferred_username @@ -110,18 +108,24 @@ class ResUsersOAuthConfigurableUid(models.Model): infinito.nexus stack identify users. This override runs after the upstream unification and, when the - configured claim (default: preferred_username) is present in the - validation dict, replaces user_id with that claim. Standard Odoo + configured claim is present in the validation dict, replaces user_id + with that claim. Standard Odoo _auth_oauth_signin then searches res_users.oauth_uid for the remapped value, matching the seeded administrator/biber accounts. + + ODOO_OIDC_UID_FIELD is read here rather than at import time: env.j2 + writes it only while services.sso.enabled is true, and the addon + stays installed after SSO is turned off, so a module-scope read would + stop Odoo from loading the addon at all. """ validation = super()._auth_oauth_validate(provider, access_token) - if validation.get(OIDC_UID_FIELD): - validation["user_id"] = validation[OIDC_UID_FIELD] + uid_field = os.environ["ODOO_OIDC_UID_FIELD"] + if validation.get(uid_field): + validation["user_id"] = validation[uid_field] _logger.debug( "OAuth: remapped user_id to %s '%s' for provider %s", - OIDC_UID_FIELD, - validation[OIDC_UID_FIELD], + uid_field, + validation[uid_field], provider, ) return validation diff --git a/roles/web-app-openwebui/files/python/provision_mcp_grants.py b/roles/web-app-openwebui/files/python/provision_mcp_grants.py index 885db9993e..a5e188aae8 100644 --- a/roles/web-app-openwebui/files/python/provision_mcp_grants.py +++ b/roles/web-app-openwebui/files/python/provision_mcp_grants.py @@ -15,7 +15,7 @@ MEMBERS = json.loads(os.environ.get("OPENWEBUI_MCP_MEMBERS", "{}")) DECLARED = json.loads(os.environ.get("OPENWEBUI_MCP_CONNECTIONS", "[]")) ADMIN_EMAIL = os.environ.get("OPENWEBUI_ADMIN_EMAIL", "") -ADMIN_NAME = os.environ.get("OPENWEBUI_ADMIN_NAME", "administrator") +ADMIN_NAME = os.environ["OPENWEBUI_ADMIN_NAME"] ADMIN_PASSWORD = os.environ.get("OPENWEBUI_ADMIN_PASSWORD", "") diff --git a/roles/web-svc-seaweedfs/files/test/probe.py b/roles/web-svc-seaweedfs/files/test/probe.py index 910a23c8fa..27c765a85e 100644 --- a/roles/web-svc-seaweedfs/files/test/probe.py +++ b/roles/web-svc-seaweedfs/files/test/probe.py @@ -291,7 +291,7 @@ def main() -> int: failures += roundtrip if roundtrip: pass - elif os.environ.get("SEAWEEDFS_TEST_PRIMARY_IS_PUBLIC", "false") == "true": + elif os.environ["SEAWEEDFS_TEST_PRIMARY_IS_PUBLIC"] == "true": print( "SKIP: every consumer on this host is public; the private-read denial has no bucket to run in" ) diff --git a/tests/lint/repository/python/__init__.py b/tests/lint/repository/python/__init__.py new file mode 100644 index 0000000000..45ed37c7e5 --- /dev/null +++ b/tests/lint/repository/python/__init__.py @@ -0,0 +1,3 @@ +from tests.lint.repository import PROJECT_ROOT + +__all__ = ["PROJECT_ROOT"] diff --git a/tests/lint/repository/python/test_environ_get_has_no_default.py b/tests/lint/repository/python/test_environ_get_has_no_default.py new file mode 100644 index 0000000000..6c3b1b9d3d --- /dev/null +++ b/tests/lint/repository/python/test_environ_get_has_no_default.py @@ -0,0 +1,186 @@ +"""Enforce that ``os.environ.get`` names no default value. + +A default is a second place the value lives. The deploy already decides what +``OPENWEBUI_ADMIN_NAME`` or ``ADAPTER_PORT`` is and writes it into the +container's environment; repeating it in the reader means the two can drift, +and the copy is the one nobody greps for. ``os.environ["X"]`` fails loudly on +a missing key, which is the behaviour the project wants: a misconfiguration +should stop the run, not silently substitute a guess. + +The empty values are free, because they add no second source of truth and +only say "absent". ``""``, ``None``, ``False``, ``0`` and the empty +containers, including the ``"[]"`` and ``"{}"`` a ``json.loads`` reader hands +an absent variable, pass without a marker. A default that is itself an +``os.environ.get`` is free for the same reason: it reads the one source again +under another key rather than naming a value, so nothing can drift. Its own +default is judged separately, so a literal at the end of such a chain is still +reported. + +Everything else needs ``# nocheck: environ-get-default`` on the line or +directly above it, naming why that value may not come from the deploy. A +static payload whose container is also run by hand outside Ansible is the +usual honest case. + +Detection is on the parsed tree, not on the text, so a call split across lines +is read the same as one written on a single line. +""" + +from __future__ import annotations + +import ast +import unittest + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.files import iter_project_files, read_text + +from . import PROJECT_ROOT + +_RULE = "environ-get-default" +_FREE_SCALARS = ("", None, False, 0) +_FREE_JSON = ("[]", "{}", "()") + + +def _is_environ_get(node: ast.expr) -> bool: + """Whether a node is a call to ``os.environ.get``. + + Args: + node: the node to classify. + """ + if not isinstance(node, ast.Call): + return False + func = node.func + if not isinstance(func, ast.Attribute) or func.attr != "get": + return False + owner = func.value + return isinstance(owner, ast.Attribute) and owner.attr == "environ" + + +def _is_free(node: ast.expr) -> bool: + """Whether a default adds no second source of truth. + + Args: + node: the default argument's node. + """ + if _is_environ_get(node): + return True + if isinstance(node, (ast.Dict, ast.List, ast.Tuple, ast.Set)): + return not (getattr(node, "elts", None) or getattr(node, "keys", None)) + if not isinstance(node, ast.Constant): + return False + value = node.value + if isinstance(value, str): + return value == "" or value in _FREE_JSON + return any(value is free for free in _FREE_SCALARS) or value == 0 + + +def _environ_get_defaults(tree: ast.AST) -> list[tuple[int, str]]: + """Return the line and default of every ``environ.get`` call that has one. + + Args: + tree: the parsed module. + """ + found: list[tuple[int, str]] = [] + for node in ast.walk(tree): + if not _is_environ_get(node) or len(node.args) < 2: + continue + default = node.args[1] + if _is_free(default): + continue + found.append((node.lineno, ast.unparse(default))) + return found + + +def _offenders(rel_path: str, content: str) -> list[str]: + """Return the defaulted ``environ.get`` calls of one module. + + Args: + rel_path: path relative to the project root. + content: the file's text. + """ + try: + tree = ast.parse(content) + except SyntaxError: + return [] + lines = content.splitlines() + found: list[str] = [] + for lineno, default in _environ_get_defaults(tree): + if is_suppressed_at(lines, lineno, _RULE, mode="same-or-above"): + continue + found.append(f"{rel_path}:{lineno}: default {default}") + return found + + +class TestEnvironGetHasNoDefault(unittest.TestCase): + def test_no_module_defaults_an_environment_variable(self) -> None: + offenders: list[str] = [] + for path in iter_project_files(extensions=(".py",)): + rel = str(path).replace(f"{PROJECT_ROOT}/", "") + try: + content = read_text(str(path)) + except (OSError, UnicodeDecodeError): + continue + if "environ" not in content: + continue + offenders.extend(_offenders(rel, content)) + + report = "\n".join(f" - {o}" for o in sorted(offenders)) + self.assertFalse( + offenders, + f"{len(offenders)} call(s) to os.environ.get name a default, which " + "puts the value in a second place next to the deploy that sets " + f"it:\n{report}\n\n" + "Read it with os.environ[...] so a missing key fails loudly, or " + "keep the default and say why it is not the deploy's to decide " + f"with `# nocheck: {_RULE} `.", + ) + + +class TestDefaultDetection(unittest.TestCase): + def test_a_value_default_is_reported(self) -> None: + self.assertEqual( + ["m.py:1: default 'administrator'"], + _offenders( + "m.py", 'import os\nos.environ.get("NAME", "administrator")'[10:] + ), + ) + + def test_no_default_is_accepted(self) -> None: + self.assertEqual([], _offenders("m.py", 'os.environ.get("NAME")')) + + def test_a_subscript_read_is_accepted(self) -> None: + self.assertEqual([], _offenders("m.py", 'os.environ["NAME"]')) + + def test_the_empty_string_is_free(self) -> None: + self.assertEqual([], _offenders("m.py", 'os.environ.get("NAME", "")')) + + def test_an_empty_json_literal_is_free(self) -> None: + self.assertEqual( + [], _offenders("m.py", 'json.loads(os.environ.get("N", "{}"))') + ) + + def test_a_multiline_call_is_still_read(self) -> None: + source = 'os.environ.get(\n "NAME",\n "administrator",\n)' + + self.assertEqual( + ["m.py:1: default 'administrator'"], _offenders("m.py", source) + ) + + def test_a_chained_environ_read_is_free(self) -> None: + self.assertEqual( + [], _offenders("m.py", 'os.environ.get("N", os.environ.get("N_ALT"))') + ) + + def test_a_literal_ending_a_chain_is_still_reported(self) -> None: + self.assertEqual( + ["m.py:1: default 'x'"], + _offenders("m.py", 'os.environ.get("N", os.environ.get("ALT", "x"))'), + ) + + def test_a_nocheck_suppresses_it(self) -> None: + source = f'os.environ.get("NAME", "administrator") # nocheck: {_RULE} reason' + + self.assertEqual([], _offenders("m.py", source)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server.py b/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server.py index f91d0d311e..5d767bbd8f 100644 --- a/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server.py +++ b/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server.py @@ -49,7 +49,11 @@ def load(): "limits": LIMITS, "schema_sha256": policy.schema_digest(TOOLS), } - env = {"ADAPTER_CONTRACT": json.dumps(contract), "ADAPTER_BEARER": "real"} + env = { + "ADAPTER_CONTRACT": json.dumps(contract), + "ADAPTER_BEARER": "real", + "ADAPTER_PORT": "8080", + } with patch.dict("os.environ", env, clear=False): spec.loader.exec_module(module) finally: diff --git a/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server_passthrough.py b/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server_passthrough.py index c6a1ef259c..890c9cb289 100644 --- a/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server_passthrough.py +++ b/tests/unit/python/roles/svc-ai-mcp-adapter/files/test_server_passthrough.py @@ -59,7 +59,11 @@ def load(tools=None, *, mutating=False, transport=None): } if transport is not None: contract["upstream_transport"] = transport - env = {"ADAPTER_CONTRACT": json.dumps(contract), "ADAPTER_BEARER": "real"} + env = { + "ADAPTER_CONTRACT": json.dumps(contract), + "ADAPTER_BEARER": "real", + "ADAPTER_PORT": "8080", + } with patch.dict("os.environ", env, clear=False): spec.loader.exec_module(module) finally: diff --git a/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py b/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py index 97205be309..5b71206c6e 100644 --- a/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py +++ b/tests/unit/python/roles/web-app-openwebui/files/test_provision_mcp_grants.py @@ -28,6 +28,7 @@ def load_script( "OPENWEBUI_MCP_MEMBERS": json.dumps(members or {}), "OPENWEBUI_MCP_CONNECTIONS": json.dumps(declared or []), "OPENWEBUI_ADMIN_EMAIL": "administrator@example.org", + "OPENWEBUI_ADMIN_NAME": "openwebui-api-bot", "OPENWEBUI_ADMIN_PASSWORD": "x" * 32, }, ): diff --git a/utils/diagnostics/container.py b/utils/diagnostics/container.py index 60695c8a1a..6a052ea815 100755 --- a/utils/diagnostics/container.py +++ b/utils/diagnostics/container.py @@ -466,7 +466,7 @@ def main(argv: list[str]) -> int: file=sys.stderr, ) return 1 - depth = int(os.environ.get("RESCUE_DEPTH", "0")) + depth = int(os.environ.get("RESCUE_DEPTH", 0)) seen = [cid for cid in os.environ.get("RESCUE_SEEN", "").split(",") if cid] stamp = datetime.now(tz=UTC).strftime("%Y%m%d%H%M%SZ") out = Path(out_base) / f"{app_id}-{stamp}-{os.getpid()}" diff --git a/utils/install/lint/actionlint.py b/utils/install/lint/actionlint.py index 3050ff85bb..d0142b03f6 100644 --- a/utils/install/lint/actionlint.py +++ b/utils/install/lint/actionlint.py @@ -17,6 +17,7 @@ from utils.install.primitives import ( which, ) +# nocheck: environ-get-default contributor tooling, never deployed: no inventory or image declares ACTIONLINT_INSTALL_DIR, it only lets a workstation without write access to /usr/local/bin redirect the binary _DEFAULT_INSTALL_DIR = os.environ.get("ACTIONLINT_INSTALL_DIR", "/usr/local/bin") diff --git a/utils/install/lint/ansible/lint.py b/utils/install/lint/ansible/lint.py index 0e1fcae8f0..fecd5d904f 100644 --- a/utils/install/lint/ansible/lint.py +++ b/utils/install/lint/ansible/lint.py @@ -12,6 +12,7 @@ def ensure() -> None: if which("ansible-lint"): return log("Missing command 'ansible-lint'. Installing via pip.") + # nocheck: environ-get-default the fallback is the package name this installer exists for; ANSIBLE_LINT_PIP_SPEC is the contributor's way to pin a version or a fork, and no deploy sets it spec = os.environ.get("ANSIBLE_LINT_PIP_SPEC", "ansible-lint") install_pip_pkg(spec) if not which("ansible-lint"): diff --git a/utils/install/lint/hadolint.py b/utils/install/lint/hadolint.py index cc3eeb6358..a3d50c2ed9 100644 --- a/utils/install/lint/hadolint.py +++ b/utils/install/lint/hadolint.py @@ -16,6 +16,7 @@ from utils.install.primitives import ( which, ) +# nocheck: environ-get-default contributor tooling, never deployed: no inventory or image declares HADOLINT_INSTALL_DIR, it only lets a workstation without write access to /usr/local/bin redirect the binary _DEFAULT_INSTALL_DIR = os.environ.get("HADOLINT_INSTALL_DIR", "/usr/local/bin") diff --git a/utils/install/lint/mbake.py b/utils/install/lint/mbake.py index 1b0a3d31db..4f64d8214d 100644 --- a/utils/install/lint/mbake.py +++ b/utils/install/lint/mbake.py @@ -12,6 +12,7 @@ def ensure() -> None: if which("mbake"): return log("Missing command 'mbake'. Installing via pip.") + # nocheck: environ-get-default the fallback is the package name this installer exists for; MBAKE_PIP_SPEC is the contributor's way to pin a version or a fork, and no deploy sets it spec = os.environ.get("MBAKE_PIP_SPEC", "mbake") install_pip_pkg(spec) if not which("mbake"): diff --git a/utils/install/lint/shfmt.py b/utils/install/lint/shfmt.py index 1200af7537..90b822f24e 100644 --- a/utils/install/lint/shfmt.py +++ b/utils/install/lint/shfmt.py @@ -17,6 +17,7 @@ from utils.install.primitives import ( ) from utils.install.system_pkg import detect_package_manager, install_command_via_pkg +# nocheck: environ-get-default contributor tooling, never deployed: no inventory or image declares SHFMT_INSTALL_DIR, it only lets a workstation without write access to /usr/local/bin redirect the binary _DEFAULT_INSTALL_DIR = os.environ.get("SHFMT_INSTALL_DIR", "/usr/local/bin") diff --git a/utils/tests/swarm/extend_inventory.py b/utils/tests/swarm/extend_inventory.py index 3205b9548f..67b2cd8cee 100755 --- a/utils/tests/swarm/extend_inventory.py +++ b/utils/tests/swarm/extend_inventory.py @@ -95,6 +95,7 @@ def main() -> int: if not os.environ.get("SWARM_NAME"): raise SystemExit("extend_inventory: SWARM_NAME is required (cluster id)") app_id = os.environ["APP_ID"] + # nocheck: environ-get-default swarm-test base: matrix.py exports INV_PATH per round, a contributor running this step standalone gets the same /tmp/inv base the routine scripts use inv_path = Path(os.environ.get("INV_PATH", "/tmp/inv/devices.yml")) # noqa: S108 - ephemeral swarm-test inventory path, overridable via INV_PATH closure = derive_includes(app_id) diff --git a/utils/tests/swarm/force_shared_db.py b/utils/tests/swarm/force_shared_db.py index 9f4cd454ef..c057874775 100755 --- a/utils/tests/swarm/force_shared_db.py +++ b/utils/tests/swarm/force_shared_db.py @@ -125,6 +125,7 @@ def force_shared_true(host_vars_file: Path, db_keys: set[str]) -> bool: def main() -> int: + # nocheck: environ-get-default swarm-test base: matrix.py exports INV_DIR per round, a contributor running this step standalone gets the same /tmp/inv base the routine scripts use inv_dir = Path(os.environ.get("INV_DIR", "/tmp/inv")) # noqa: S108 - ephemeral swarm-test inventory path, overridable via INV_DIR host_vars_dir = inv_dir / "host_vars" if not host_vars_dir.is_dir(): diff --git a/utils/tests/swarm/matrix.py b/utils/tests/swarm/matrix.py index dcc1dccc7d..d1def2a57f 100644 --- a/utils/tests/swarm/matrix.py +++ b/utils/tests/swarm/matrix.py @@ -344,6 +344,7 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: ) p.add_argument( "--inventory-dir", + # nocheck: environ-get-default the fallback is this module's own _DEFAULT_INVENTORY_DIR constant, and matrix.py is itself the process that exports INFINITO_INVENTORY_DIR to the rounds below it default=os.environ.get( "INFINITO_INVENTORY_DIR", _DEFAULT_INVENTORY_DIR ), # nocheck: swarm-test base; matrix sets it per round, compose resolves the key via its own handler diff --git a/utils/tests/swarm/write/extras.py b/utils/tests/swarm/write/extras.py index c37ddf7f9f..5b42a4e77f 100755 --- a/utils/tests/swarm/write/extras.py +++ b/utils/tests/swarm/write/extras.py @@ -116,6 +116,7 @@ def ensure_swarm_keypairs() -> dict[str, str]: rather than through the replacing extra-vars file. """ static_env = parse_static_env(PROJECT_ROOT / "default.env") + # nocheck: environ-get-default the swarm-test admin keypair is created here, not deployed: default.env declares only INFINITO_SWARM_BACKUP_KEY, and nothing in the repo sets KEY_PATH 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") @@ -131,6 +132,7 @@ def main() -> int: nfs_ip = os.environ["NFS_IP"] mgr_ip = os.environ["MGR_IP"] mgr = os.environ["MGR"] + # nocheck: environ-get-default nothing in the repo sets OUT_PATH; this script is the writer of the extras file, so where it lands is its own decision, not the inventory's out_path = Path(os.environ.get("OUT_PATH", "/tmp/swarm-nfs-extras.yml")) # noqa: S108 - ephemeral swarm-test path, overridable via OUT_PATH pubkeys = ensure_swarm_keypairs() diff --git a/utils/tests/swarm/write/hosts_entries.py b/utils/tests/swarm/write/hosts_entries.py index 6acc67e735..8bffbeffc2 100755 --- a/utils/tests/swarm/write/hosts_entries.py +++ b/utils/tests/swarm/write/hosts_entries.py @@ -7,8 +7,8 @@ Inputs (env): ``APP_ID``, ``INFINITO_DOMAIN`` (the SPOT for the deployment domain; defined in ``default.env`` and exported by ``scripts/meta/env/load.sh``; the development inventory reads the same env var for the play's ``DOMAIN_PRIMARY``), -``HOSTS_ENTRY_IP`` (optional; address the domains map to, -default ``127.0.0.1``). +``HOSTS_ENTRY_IP`` (address the domains map to; the swarm play +sets it from ``INFINITO_SWARM_MGR_IP``). """ from __future__ import annotations @@ -58,7 +58,7 @@ def _derived_domains(app_id: str, domain_primary: str) -> list[str]: def main() -> int: app_id = os.environ["APP_ID"] domain_primary = os.environ["INFINITO_DOMAIN"] - ip = os.environ.get("HOSTS_ENTRY_IP", "127.0.0.1") + ip = os.environ["HOSTS_ENTRY_IP"] for domain in _derived_domains(app_id, domain_primary): print(f"{ip} {domain}") return 0