diff --git a/.ansible-lint b/.ansible-lint index 133e729e72..b19c533c58 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -14,9 +14,11 @@ exclude_paths: - .cache/ - .claude/ - .github/ + - /cli/ - compose/ - - tests/integration/fixtures/ - - tests/lint/fixtures/ + - /docs/ + - /tests/ + - /utils/ - venv/ - .venv/ - build/ diff --git a/.claude/settings.json b/.claude/settings.json index 8ad08a249f..93d6f7a5c8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -22,16 +22,19 @@ "WebFetch(domain:accounts.google.com)", "WebFetch(domain:accounts.hetzner.com)", "WebFetch(domain:addons.mozilla.org)", + "WebFetch(domain:admin-docs.zammad.org)", "WebFetch(domain:agents.md)", "WebFetch(domain:agilemanifesto.org)", "WebFetch(domain:akaunting.com)", "WebFetch(domain:amble.blog)", + "WebFetch(domain:ansible.readthedocs.io)", "WebFetch(domain:api.bluesky)", "WebFetch(domain:api.bsky.app)", "WebFetch(domain:api.cloudflare.com)", "WebFetch(domain:api.maptiler.com)", "WebFetch(domain:api.openai.com)", "WebFetch(domain:api.telegram.org)", + "WebFetch(domain:apps.odoo.com)", "WebFetch(domain:archive.ubuntu.com)", "WebFetch(domain:archlinux.org)", "WebFetch(domain:askubuntu.com)", @@ -56,6 +59,7 @@ "WebFetch(domain:bsky.app)", "WebFetch(domain:bsky.network)", "WebFetch(domain:bsky.social)", + "WebFetch(domain:bugs.launchpad.net)", "WebFetch(domain:bugzilla.redhat.com)", "WebFetch(domain:business.adobe.com)", "WebFetch(domain:buymeacoffee.com)", @@ -68,6 +72,7 @@ "WebFetch(domain:certbot.eff.org)", "WebFetch(domain:charts.bitnami.com)", "WebFetch(domain:check.spamhaus.org)", + "WebFetch(domain:checkmk.com)", "WebFetch(domain:chromewebstore.google.com)", "WebFetch(domain:clients2.google.com)", "WebFetch(domain:cloud.veen.world)", @@ -99,64 +104,122 @@ "WebFetch(domain:developer.wordpress.org)", "WebFetch(domain:developers.cloudflare.com)", "WebFetch(domain:discuss.frappe.io)", + "WebFetch(domain:distribution.github.io)", "WebFetch(domain:dl-cdn.alpinelinux.org)", "WebFetch(domain:dl.fedoraproject.org)", "WebFetch(domain:dnf.readthedocs.io)", "WebFetch(domain:docker-registry.kixdesk.com)", "WebFetch(domain:docs.anthropic.com)", + "WebFetch(domain:docs.bigbluebutton.org)", "WebFetch(domain:docs.bsky.app)", + "WebFetch(domain:docs.checkmk.com)", "WebFetch(domain:docs.decidim.org)", "WebFetch(domain:docs.docker.com)", "WebFetch(domain:docs.ejabberd.im)", "WebFetch(domain:docs.espocrm.com)", + "WebFetch(domain:docs.flowiseai.com)", + "WebFetch(domain:docs.frappe.io)", "WebFetch(domain:docs.funkwhale.audio)", "WebFetch(domain:docs.gitlab.com)", "WebFetch(domain:docs.hetzner.com)", "WebFetch(domain:docs.joinbookwyrm.com)", + "WebFetch(domain:docs.joinmastodon.org)", + "WebFetch(domain:docs.joinpeertube.org)", "WebFetch(domain:docs.joomla.org)", "WebFetch(domain:docs.kernel.org)", "WebFetch(domain:docs.kixdesk.com)", + "WebFetch(domain:docs.libretro.com)", "WebFetch(domain:docs.livekit.io)", + "WebFetch(domain:docs.mattermost.com)", + "WebFetch(domain:docs.mau.fi)", "WebFetch(domain:docs.moodle.org)", + "WebFetch(domain:docs.n8n.io)", "WebFetch(domain:docs.nextcloud.com)", "WebFetch(domain:docs.opencloud.eu)", "WebFetch(domain:docs.opentalk.eu)", "WebFetch(domain:docs.openwebui.com)", + "WebFetch(domain:docs.pixelfed.org)", "WebFetch(domain:docs.pretix.eu)", "WebFetch(domain:docs.redhat.com)", + "WebFetch(domain:docs.semaphoreui.com)", + "WebFetch(domain:docs.suitecrm.com)", "WebFetch(domain:docs.zammad.org)", "WebFetch(domain:download-center.epson.com)", "WebFetch(domain:download.ebz.epson.net)", "WebFetch(domain:download.ebz.epson.net)", "WebFetch(domain:download.rockylinux.org)", "WebFetch(domain:dyn.manpages.debian.org)", + "WebFetch(domain:element.io)", "WebFetch(domain:elixir.bootlin.com)", + "WebFetch(domain:en.wikipedia.org)", + "WebFetch(domain:en.wordpress.org)", + "WebFetch(domain:erpnext.com)", + "WebFetch(domain:eslint.org)", + "WebFetch(domain:evangelostsak.com)", + "WebFetch(domain:events.bsky.app)", + "WebFetch(domain:extensions.gnome.org)", + "WebFetch(domain:extensions.joomla.org)", + "WebFetch(domain:extensions.xwiki.org)", + "WebFetch(domain:fed.brid.gy)", + "WebFetch(domain:federationtester.matrix.org)", + "WebFetch(domain:fediwall.social)", + "WebFetch(domain:fedoraproject.org)", + "WebFetch(domain:flowiseai.com)", + "WebFetch(domain:fonts.bunny.net)", + "WebFetch(domain:forum.checkmk.com)", "WebFetch(domain:forum.collaboraonline.com)", "WebFetch(domain:forum.keycloak.org)", + "WebFetch(domain:forum.manjaro.org)", + "WebFetch(domain:forum.openwrt.org)", "WebFetch(domain:forums.docker.com)", "WebFetch(domain:fossies.org)", "WebFetch(domain:frappe.io)", + "WebFetch(domain:frappeframework.com)", "WebFetch(domain:ftp.debian.org)", "WebFetch(domain:fvonline-db.bplaced.net)", + "WebFetch(domain:gcc.gnu.org)", + "WebFetch(domain:geminicli.com)", "WebFetch(domain:gergap.de)", "WebFetch(domain:gergap.de)", + "WebFetch(domain:getcomposer.org)", + "WebFetch(domain:getfider.com)", + "WebFetch(domain:gh.io)", "WebFetch(domain:git.coopcloud.tech)", + "WebFetch(domain:git.friendi.ca)", "WebFetch(domain:git.kernel.org)", "WebFetch(domain:git.linux-nfs.org)", "WebFetch(domain:git.yoctoproject.org)", + "WebFetch(domain:gitea.fudaoyuan.icu)", + "WebFetch(domain:gitea.io)", "WebFetch(domain:github.com)", "WebFetch(domain:gitlab.com)", "WebFetch(domain:gitlab.opencode.de)", + "WebFetch(domain:gohugo.io)", "WebFetch(domain:grep.app)", + "WebFetch(domain:help.penpot.app)", "WebFetch(domain:help.ubuntu.com)", "WebFetch(domain:helpcenter.onlyoffice.com)", + "WebFetch(domain:hexdocs.pm)", + "WebFetch(domain:hluk.github.io)", "WebFetch(domain:hub.docker.com)", + "WebFetch(domain:img.shields.io)", + "WebFetch(domain:jellyfin.org)", "WebFetch(domain:jitsi.github.io)", + "WebFetch(domain:jitsi.org)", + "WebFetch(domain:joeeey.com)", + "WebFetch(domain:joinmastodon.org)", + "WebFetch(domain:jrnl.sh)", + "WebFetch(domain:ka-f.fontawesome.com)", + "WebFetch(domain:keepassxc.org)", "WebFetch(domain:kmpontech.wordpress.com)", + "WebFetch(domain:learn.microsoft.com)", + "WebFetch(domain:lima-vm.io)", "WebFetch(domain:links.sonatype.com)", "WebFetch(domain:linux-nfs.org)", "WebFetch(domain:listmonk.app)", "WebFetch(domain:lore.kernel.org)", + "WebFetch(domain:magento.com)", + "WebFetch(domain:mailu.io)", "WebFetch(domain:man.archlinux.org)", "WebFetch(domain:man.freebsd.org)", "WebFetch(domain:man7.org)", @@ -175,12 +238,15 @@ "WebFetch(domain:micro-editor.github.io)", "WebFetch(domain:mobilizon.org)", "WebFetch(domain:moodle.org)", + "WebFetch(domain:moodledev.io)", "WebFetch(domain:mxtoolbox.com)", "WebFetch(domain:my.telegram.org)", "WebFetch(domain:myportfolio.com)", "WebFetch(domain:myportfolio.com)", + "WebFetch(domain:n8n.io)", "WebFetch(domain:nektos.com)", "WebFetch(domain:neovim.io)", + "WebFetch(domain:next.erp)", "WebFetch(domain:nextcloud.com)", "WebFetch(domain:nexus.xwiki.org)", "WebFetch(domain:nfsv4.linux-nfs.narkive.com)", @@ -194,13 +260,18 @@ "WebFetch(domain:ollama.com)", "WebFetch(domain:oneuptime.com)", "WebFetch(domain:open.spotify.com)", + "WebFetch(domain:opencloud.eu)", + "WebFetch(domain:opencollective.com)", "WebFetch(domain:openid.net)", "WebFetch(domain:openresty.org)", "WebFetch(domain:opensearch.org)", + "WebFetch(domain:opentalk.eu)", "WebFetch(domain:openwebui.com)", "WebFetch(domain:owncloud.dev)", "WebFetch(domain:packages.debian.org)", "WebFetch(domain:patchwork.kernel.org)", + "WebFetch(domain:penpot.app)", + "WebFetch(domain:pi-hole.net)", "WebFetch(domain:piehost.com)", "WebFetch(domain:pixelfed.org)", "WebFetch(domain:pkg.go.dev)", @@ -225,8 +296,10 @@ "WebFetch(domain:ralph.blog.imixs.com)", "WebFetch(domain:ramblings.narrabilis.com)", "WebFetch(domain:ramblings.narrabilis.com)", + "WebFetch(domain:raw.git)", "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:redis.io)", "WebFetch(domain:refspecs.linuxfoundation.org)", "WebFetch(domain:registry-1.docker.io)", "WebFetch(domain:registry.gitlab.com)", @@ -248,6 +321,8 @@ "WebFetch(domain:security.debian.org)", "WebFetch(domain:security.ubuntu.com)", "WebFetch(domain:security.ubuntu.com)", + "WebFetch(domain:semaphoreui.com)", + "WebFetch(domain:semver.org)", "WebFetch(domain:senders.yahooinc.com)", "WebFetch(domain:sentry.com)", "WebFetch(domain:sentry.matrix.org)", @@ -262,6 +337,7 @@ "WebFetch(domain:stackoverflow.com)", "WebFetch(domain:store.shopware.com)", "WebFetch(domain:suitecrm.com)", + "WebFetch(domain:support.atlassian.com)", "WebFetch(domain:systemd.io)", "WebFetch(domain:t.me)", "WebFetch(domain:taiga.io)", @@ -287,6 +363,7 @@ "WebFetch(domain:wiki.manjaro.org)", "WebFetch(domain:wireguard.com)", "WebFetch(domain:wolfgang.gassler.org)", + "WebFetch(domain:wordpress.org)", "WebFetch(domain:www.2daygeek.com)", "WebFetch(domain:www.arduino.cc)", "WebFetch(domain:www.atlassian.com)", @@ -324,6 +401,7 @@ "WebFetch(domain:www.man7.org)", "WebFetch(domain:www.mediawiki.org)", "WebFetch(domain:www.min.io)", + "WebFetch(domain:www.npmjs.com)", "WebFetch(domain:www.odoo.com)", "WebFetch(domain:www.openldap.org)", "WebFetch(domain:www.openproject.org)", @@ -338,6 +416,7 @@ "WebFetch(domain:www.postgresql.org)", "WebFetch(domain:www.primary.com)", "WebFetch(domain:www.python.org)", + "WebFetch(domain:www.rabbitmq.com)", "WebFetch(domain:www.redhat.com)", "WebFetch(domain:www.retroarch.com)", "WebFetch(domain:www.rfc-editor.org)", @@ -361,6 +440,7 @@ "WebFetch(domain:www.zsh.org)", "WebFetch(domain:yourls.org)", "WebFetch(domain:youtu.be)", + "WebFetch(domain:zammad.org)", "WebFetch(domain:zoom.us)", "WebSearch", "Workflow", @@ -467,6 +547,24 @@ "/tmp" ] }, + "enabledPlugins": { + "caveman@caveman": true, + "ponytail@ponytail": true + }, + "extraKnownMarketplaces": { + "caveman": { + "source": { + "source": "github", + "repo": "JuliusBrussee/caveman" + } + }, + "ponytail": { + "source": { + "source": "github", + "repo": "DietrichGebert/ponytail" + } + } + }, "sandbox": { "enabled": true, "failIfUnavailable": true, @@ -524,23 +622,5 @@ "~/.kube" ] } - }, - "extraKnownMarketplaces": { - "caveman": { - "source": { - "source": "github", - "repo": "JuliusBrussee/caveman" - } - }, - "ponytail": { - "source": { - "source": "github", - "repo": "DietrichGebert/ponytail" - } - } - }, - "enabledPlugins": { - "caveman@caveman": true, - "ponytail@ponytail": true } } diff --git a/Makefile b/Makefile index 9e6edee036..b8f0091e39 100644 --- a/Makefile +++ b/Makefile @@ -473,8 +473,10 @@ network-trust-ca: @bash scripts/system/tls/trust/wsl2.sh .PHONY: onboard -# Set up a developer workstation end to end: dependencies, project setup, agent skills, and terminal aliases. +# Set up a developer workstation end to end: dependencies, project setup, agent skills, terminal aliases, and the dev extras inside the running dev container. onboard: bootstrap install-skills install-alias + @"$(MAKE)" compose-up + @"$(MAKE)" compose-exec cmd="bash scripts/install/dev-extras.sh" .PHONY: quality # Autoformat then run the full test suite in one shot (pre-commit gate). diff --git a/pyproject.toml b/pyproject.toml index 5a5df088f2..4944513a9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ dev = [ "import-linter", "pre-commit", "git-maintainer-tools", + "pytest>=9.1", + "pytest-xdist", ] [project.scripts] @@ -273,20 +275,18 @@ root_package = "cli" [[tool.importlinter.contracts]] # The dev/CI deploy package was repeatedly hit by CodeQL # `py/unsafe-cyclic-import`. The fix split the env/arg helpers into the -# leaf module `env`, giving the load-time DAG compose -> common -> deps -# -> env. This layers contract locks that order in: a lower layer -# importing a higher one (i.e. a new cycle) breaks the build. -name = "cli.administration.deploy.development stays acyclic (compose -> common -> deps -> env)" +# leaf module `env`, giving the load-time DAG compose -> common -> env. +# This layers contract locks that order in: a lower layer importing a +# higher one (i.e. a new cycle) breaks the build. +name = "cli.administration.deploy.development stays acyclic (compose -> common -> env)" type = "layers" layers = [ "cli.administration.deploy.development.compose", "cli.administration.deploy.development.common", - "cli.administration.deploy.development.deps", "cli.administration.deploy.development.env", ] # Type-checking-only / lazily-deferred back-references to `compose` do # not run at import time, so they cannot form a load-time cycle. ignore_imports = [ "cli.administration.deploy.development.common -> cli.administration.deploy.development.compose", - "cli.administration.deploy.development.deps -> cli.administration.deploy.development.compose", ] diff --git a/roles/svc-bkp-nfs-2-local/files/script.sh b/roles/svc-bkp-nfs-2-local/files/script.sh index 7a2ca0bd5b..190401fec4 100644 --- a/roles/svc-bkp-nfs-2-local/files/script.sh +++ b/roles/svc-bkp-nfs-2-local/files/script.sh @@ -6,6 +6,10 @@ # $1 SOURCE_DIR NFS export base to back up # $2 BACKUPS_DIR local backup root # $3 REPO_NAME repository directory name inside the machine hash dir +# +# Environment: +# BKP_NFS_2_LOCAL_GENERATION optional generation name override +# (defaults to the current timestamp) set -euo pipefail SOURCE_DIR="${1:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME}" @@ -19,7 +23,7 @@ fi MACHINE_HASH="$(sha256sum /etc/machine-id | cut -c1-64)" REPO_DIR="${BACKUPS_DIR%/}/${MACHINE_HASH}/${REPO_NAME}" -GENERATION="$(date +%Y%m%d%H%M%S)" +GENERATION="${BKP_NFS_2_LOCAL_GENERATION:-$(date +%Y%m%d%H%M%S)}" DEST_DIR="${REPO_DIR}/${GENERATION}/files" PREVIOUS_FILES="" diff --git a/scripts/install/dev-extras.sh b/scripts/install/dev-extras.sh new file mode 100755 index 0000000000..15a2bbf894 --- /dev/null +++ b/scripts/install/dev-extras.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Install the dev extras into the resolved venv once. A stamp inside the +# venv skips reruns until pyproject.toml changes or the venv is recreated, +# so the container-side install disappears together with its venv. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +cd "${REPO_ROOT}" + +# shellcheck source=scripts/meta/env/python.sh +source scripts/meta/env/python.sh +: "${VENV:?no venv resolved}" + +STAMP="${VENV%/}/.dev-extras.stamp" + +if [[ ! -w "${VENV}" ]]; then + echo "[dev-extras] venv not writable (${VENV}); skipping" + exit 0 +fi + +if [[ -f "${STAMP}" && "${STAMP}" -nt pyproject.toml ]]; then + echo "[dev-extras] up to date (${STAMP})" + exit 0 +fi + +bash scripts/install/python.sh dev +touch "${STAMP}" +echo "[dev-extras] installed and stamped (${STAMP})" diff --git a/scripts/lint/ansible.sh b/scripts/lint/ansible.sh index e4b84788c5..e7c3c62a5d 100755 --- a/scripts/lint/ansible.sh +++ b/scripts/lint/ansible.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # shellcheck shell=bash - +# Syntax-check the playbook, then run ansible-lint behind a content +# fingerprint gate: when no file ansible-lint walks (or its config) changed +# since the last green run, the lint is skipped; any change triggers the +# full run again, so coverage never shrinks. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" @@ -20,8 +23,38 @@ ansible_args+=(playbook.yml --syntax-check) ansible-playbook "${ansible_args[@]}" -if command -v ansible-lint >/dev/null 2>&1; then - ansible-lint -else +if ! command -v ansible-lint >/dev/null 2>&1; then echo "ansible-lint not installed; skipping. Run 'make install-lint' first." >&2 + exit 0 +fi + +LINT_SCOPE=(roles tasks group_vars host_vars library plugins playbook.yml ansible.cfg .ansible-lint .ansible-lint-ignore) +STAMP="build/ansible-lint.stamp" + +fingerprint() { + { + git rev-parse HEAD 2>/dev/null + git ls-files -s -- "${LINT_SCOPE[@]}" 2>/dev/null + git status --porcelain -- "${LINT_SCOPE[@]}" 2>/dev/null | + awk '{print $NF}' | sort -u | + while IFS= read -r dirty; do + [ -f "${dirty}" ] && sha256sum "${dirty}" + done + } | sha256sum | cut -d' ' -f1 +} + +current="" +if git rev-parse HEAD >/dev/null 2>&1; then + current="$(fingerprint)" +fi +if [[ -n "${current}" && -f "${STAMP}" && "$(cat "${STAMP}")" == "${current}" ]]; then + echo "ansible-lint: no lintable file changed since the last green run; skipping" + exit 0 +fi + +ansible-lint + +if [[ -n "${current}" ]]; then + mkdir -p "$(dirname "${STAMP}")" + printf '%s\n' "${current}" >"${STAMP}" fi diff --git a/scripts/tests/code/run.sh b/scripts/tests/code/run.sh index c88d8e876e..4c77710ffe 100755 --- a/scripts/tests/code/run.sh +++ b/scripts/tests/code/run.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Runs the actual unittest discover invocation for the test suite picked -# by INFINITO_TEST_TYPE / INFINITO_TEST_PATTERN. Invoked either directly on the host or +# Runs the test suite picked by INFINITO_TEST_TYPE / INFINITO_TEST_PATTERN: +# parallel via pytest-xdist when pytest and xdist are importable, serial +# unittest discover otherwise. Invoked either directly on the host or # inside the infinito compose container by scripts/tests/code/wrapper.sh # -- the body is identical for both runners. set -euo pipefail @@ -22,4 +23,22 @@ if [ -n "${PYTHON:-}" ]; then fi make setup -"${PYTHON}" -m unittest discover -s "tests/${INFINITO_TEST_TYPE}" -t . -p "${INFINITO_TEST_PATTERN}" # nocheck: makefile-supplied +bash "$(dirname "${BASH_SOURCE[0]}")/../../install/dev-extras.sh" || echo "dev extras install failed; running serial" >&2 +_suite_dir="tests/${INFINITO_TEST_TYPE}" # nocheck: makefile-supplied +if "${PYTHON}" -c 'import pytest, xdist' >/dev/null 2>&1; then + # Exception: consider_namespace_packages + importlib import mode are + # required for hyphenated tests/unit/roles// dirs; pytest's default + # identifier walk-up collapses every role's filter_plugins/files package + # onto one top-level name and cross-imports the wrong module. + # python_functions= pins collection to unittest-style TestCase methods, + # the exact set `unittest discover` runs. PYTHONDONTWRITEBYTECODE keeps + # namespace probing from dropping __pycache__ into roles/. + PYTHONDONTWRITEBYTECODE=1 "${PYTHON}" -m pytest "${_suite_dir}" -q -n auto -rP -p no:cacheprovider \ + --import-mode=importlib \ + -o consider_namespace_packages=true \ + -o "norecursedirs=.* *.egg dist node_modules venv" \ + -o python_functions= \ + -o "python_files=${INFINITO_TEST_PATTERN}" +else + "${PYTHON}" -m unittest discover -s "${_suite_dir}" -t . -p "${INFINITO_TEST_PATTERN}" +fi diff --git a/tests/integration/infrastructure/services/test_resolvable.py b/tests/integration/infrastructure/services/test_resolvable.py index c620a43300..e17771a3e3 100644 --- a/tests/integration/infrastructure/services/test_resolvable.py +++ b/tests/integration/infrastructure/services/test_resolvable.py @@ -12,11 +12,14 @@ from . import PROJECT_ROOT class TestServicesResolvable(unittest.TestCase): """Discovered service keys and provider roles must resolve via the service lookup.""" + @classmethod + def setUpClass(cls): + cls.repo_root = PROJECT_ROOT + cls.roles_dir = cls.repo_root / "roles" + cls.applications = load_applications_from_roles_dir(cls.roles_dir) + cls.service_registry = build_service_registry_from_roles_dir(cls.roles_dir) + def setUp(self): - self.repo_root = PROJECT_ROOT - self.roles_dir = self.repo_root / "roles" - self.applications = load_applications_from_roles_dir(self.roles_dir) - self.service_registry = build_service_registry_from_roles_dir(self.roles_dir) self.assertGreater( len(self.service_registry), 0, diff --git a/tests/unit/plugins/lookup/test_applications.py b/tests/unit/plugins/lookup/test_applications.py index a827502ff8..05fdf48d55 100644 --- a/tests/unit/plugins/lookup/test_applications.py +++ b/tests/unit/plugins/lookup/test_applications.py @@ -8,6 +8,7 @@ from pathlib import Path from ansible.errors import AnsibleError from plugins.lookup.applications import LookupModule, _reset_cache_for_tests +from utils.cache import base as cache_base from utils.cache.yaml import dump_yaml_str from utils.roles.mapping import ROLE_FILE_META_SERVICES @@ -19,13 +20,21 @@ def _write_config(base_dir: Path, application_id: str, config: dict) -> None: class TestApplicationsLookup(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + _reset_cache_for_tests() + cls.addClassCleanup(_reset_cache_for_tests) + def setUp(self) -> None: + # Exception: the fingerprint memo keys by id(); a GC'd variables dict + # can hand its id to the next test's dict and alias a stale signature, + # so it must clear per test despite the class-shared caches. + cache_base._reset() self.lookup = LookupModule() self._cwd = str(Path.cwd()) self._tmpdir = tempfile.TemporaryDirectory() self._tmp = Path(self._tmpdir.name) os.chdir(self._tmp) - _reset_cache_for_tests() _write_config( self._tmp, @@ -35,13 +44,10 @@ class TestApplicationsLookup(unittest.TestCase): _write_config(self._tmp, "web-app-bar", {}) def tearDown(self) -> None: - _reset_cache_for_tests() os.chdir(self._cwd) self._tmpdir.cleanup() def test_returns_full_mapping(self) -> None: - # Per the file root of meta/services.yml is the services map, - # so the loader puts it under `services` in the materialised payload. result = self.lookup.run([], variables={}, roles_dir=str(self._tmp / "roles"))[ 0 ] diff --git a/tests/unit/plugins/lookup/test_config_lookup.py b/tests/unit/plugins/lookup/test_config_lookup.py index 4dceb00ff4..f25044be31 100644 --- a/tests/unit/plugins/lookup/test_config_lookup.py +++ b/tests/unit/plugins/lookup/test_config_lookup.py @@ -10,6 +10,7 @@ from ansible.errors import AnsibleError from plugins.lookup.config import LookupModule from utils.cache import _reset_cache_for_tests +from utils.cache import base as cache_base from utils.cache.yaml import dump_yaml_str from utils.roles.applications.config import AppConfigKeyError, ConfigEntryNotSetError from utils.roles.mapping import ( @@ -62,18 +63,23 @@ class _DummyTemplar: class TestConfigLookup(unittest.TestCase): - def setUp(self) -> None: - self.lm = LookupModule() + @classmethod + def setUpClass(cls) -> None: _reset_cache_for_tests() + cls.addClassCleanup(_reset_cache_for_tests) - # Create a temp working directory in /tmp and chdir into it + def setUp(self) -> None: + # Exception: the fingerprint memo keys by id(); a GC'd variables dict + # can hand its id to the next test's dict and alias a stale signature, + # so it must clear per test despite the class-shared caches. + cache_base._reset() + self.lm = LookupModule() self._cwd = str(Path.cwd()) self._tmpdir = tempfile.TemporaryDirectory() self._tmp = Path(self._tmpdir.name) os.chdir(self._tmp) def tearDown(self) -> None: - _reset_cache_for_tests() os.chdir(self._cwd) self._tmpdir.cleanup() @@ -86,8 +92,6 @@ class TestConfigLookup(unittest.TestCase): self.lm.run(["a", "b", "c", "d"], variables={"applications": {}}) def test_resolves_from_roles_without_applications_var(self) -> None: - # meta/services.yml file root IS the services map: the - # consumer path therefore reads `services..<…>`. _write_config( self._tmp, "web-app-foo", @@ -163,7 +167,7 @@ class TestConfigLookup(unittest.TestCase): _write_schema( self._tmp, "web-app-foo", - {"smtp": {"host": {}, "port": {}}}, # port is defined in schema + {"smtp": {"host": {}, "port": {}}}, ) variables = { "applications": {"web-app-foo": {"smtp": {"host": "mail.example.org"}}} diff --git a/tests/unit/plugins/lookup/test_email.py b/tests/unit/plugins/lookup/test_email.py index fc810e3dc8..0d055a6c69 100644 --- a/tests/unit/plugins/lookup/test_email.py +++ b/tests/unit/plugins/lookup/test_email.py @@ -11,6 +11,7 @@ from ansible.errors import AnsibleError from plugins.lookup.email import LookupModule from utils.cache import _reset_cache_for_tests +from utils.cache import base as cache_base from utils.cache import users as cache_users from utils.cache.yaml import dump_yaml_str from utils.roles.mapping import ROLE_FILE_META_SERVICES @@ -23,7 +24,16 @@ def _write_role_config(base_dir: Path, role_name: str, payload: dict) -> None: class TestEmailLookup(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + _reset_cache_for_tests() + cls.addClassCleanup(_reset_cache_for_tests) + def setUp(self) -> None: + # Exception: the fingerprint memo keys by id(); a GC'd variables dict + # can hand its id to the next test's dict and alias a stale signature, + # so it must clear per test despite the class-shared caches. + cache_base._reset() self.lookup = LookupModule() self.lookup._templar = None self._cwd = str(Path.cwd()) @@ -31,7 +41,6 @@ class TestEmailLookup(unittest.TestCase): self._tmp = Path(self._tmpdir.name) (self._tmp / "roles").mkdir(parents=True, exist_ok=True) os.chdir(self._tmp) - _reset_cache_for_tests() self._tokens_store_patcher = patch.object( cache_users, "_load_store_users", return_value={} ) @@ -39,7 +48,6 @@ class TestEmailLookup(unittest.TestCase): def tearDown(self) -> None: self._tokens_store_patcher.stop() - _reset_cache_for_tests() os.chdir(self._cwd) self._tmpdir.cleanup() diff --git a/tests/unit/roles/svc-bkp-nfs-2-local/files/test_script.py b/tests/unit/roles/svc-bkp-nfs-2-local/files/test_script.py index 198cea2a41..77f7b80b9b 100644 --- a/tests/unit/roles/svc-bkp-nfs-2-local/files/test_script.py +++ b/tests/unit/roles/svc-bkp-nfs-2-local/files/test_script.py @@ -7,7 +7,6 @@ into older generations.""" import os import subprocess import tempfile -import time import unittest from pathlib import Path @@ -29,7 +28,10 @@ class NfsBackupScriptTests(unittest.TestCase): def tearDown(self): self.tmp.cleanup() - def _run(self, source=None): + def _run(self, source=None, generation=None): + env = os.environ.copy() + if generation is not None: + env["BKP_NFS_2_LOCAL_GENERATION"] = generation return subprocess.run( [ "bash", @@ -41,6 +43,7 @@ class NfsBackupScriptTests(unittest.TestCase): capture_output=True, text=True, check=False, + env=env, ) def _generations(self): @@ -68,9 +71,8 @@ class NfsBackupScriptTests(unittest.TestCase): def test_second_run_hardlinks_unchanged_files(self): (self.source / "a.txt").write_text("hello") - self.assertEqual(self._run().returncode, 0) - time.sleep(1.1) - self.assertEqual(self._run().returncode, 0) + self.assertEqual(self._run(generation="20240101000000").returncode, 0) + self.assertEqual(self._run(generation="20240101000001").returncode, 0) generations = self._generations() self.assertEqual(len(generations), 2) @@ -80,11 +82,12 @@ class NfsBackupScriptTests(unittest.TestCase): self.assertEqual(second.stat().st_nlink, 2) def test_changed_file_gets_own_inode_and_history_survives(self): - (self.source / "a.txt").write_text("v1") - self.assertEqual(self._run().returncode, 0) - time.sleep(1.1) - (self.source / "a.txt").write_text("v2") - self.assertEqual(self._run().returncode, 0) + changed = self.source / "a.txt" + changed.write_text("v1") + os.utime(changed, (1000000000, 1000000000)) + self.assertEqual(self._run(generation="20240101000000").returncode, 0) + changed.write_text("v2") + self.assertEqual(self._run(generation="20240101000001").returncode, 0) generations = self._generations() first = generations[0] / "files" / "a.txt" @@ -110,10 +113,9 @@ class NfsBackupScriptTests(unittest.TestCase): def test_deleted_file_vanishes_only_from_new_generation(self): (self.source / "keep.txt").write_text("keep") (self.source / "gone.txt").write_text("gone") - self.assertEqual(self._run().returncode, 0) - time.sleep(1.1) + self.assertEqual(self._run(generation="20240101000000").returncode, 0) (self.source / "gone.txt").unlink() - self.assertEqual(self._run().returncode, 0) + self.assertEqual(self._run(generation="20240101000001").returncode, 0) generations = self._generations() self.assertTrue((generations[0] / "files" / "gone.txt").is_file())