perf(tests): parallelize suites, hoist fixtures, gate ansible-lint

Run the python suites through pytest-xdist when pytest is importable
(id-level collection parity with unittest discover verified per suite;
serial unittest stays the fallback), with -rP so warnings printed by
passing tests, like the update annotations, still reach the log. run.sh
self-provisions the dev extras through scripts/install/dev-extras.sh,
which stamps inside the venv and skips read-only venvs; make onboard
pre-warms the same path in the running container.

Hoist per-test cache resets and roles scans to setUpClass in the lookup
and resolvable suites (a per-test fingerprint-memo reset closes an
id()-reuse cache-poisoning flake the hoist exposed), inject generation
names into the nfs backup script test instead of sleeping, and narrow
ansible-lint scope to real ansible content. ansible-lint additionally
sits behind a content fingerprint gate that skips the run when nothing
it walks has changed; without a usable git (container bind mounts) the
gate disables itself and the full run remains.

Drop the import-linter layer and ignore for the deleted deps module;
the contract now locks compose -> common -> env. Measured on the host:
unit 101s to 20s, integration 90s to 24s, lint tests 43s to 19s,
repeated ansible-lint 53s to 0.4s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-07-10 21:20:46 +02:00
1 parent 9b4d002052
commit 1a908a6e5b
13 files changed
+257 -65

No files matched your search

+4 -2
View File
@@ -14,9 +14,11 @@ exclude_paths:
- .cache/
- .claude/
- .github/
- /cli/
- compose/
- tests/integration/fixtures/
- tests/lint/fixtures/
- /docs/
- /tests/
- /utils/
- venv/
- .venv/
- build/
+98 -18
View File
@@ -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
}
}
+3 -1
View File
@@ -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).
+6 -6
View File
@@ -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",
]
+5 -1
View File
@@ -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=""
+29
View File
@@ -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})"
+37 -4
View File
@@ -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
+22 -3
View File
@@ -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/<role>/ 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
@@ -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,
+10 -4
View File
@@ -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
]
@@ -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.<entity>.<…>`.
_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"}}}
+10 -2
View File
@@ -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()
@@ -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())