From 67ddb8c4b085ec4459382bd1adc1cbbb69a46b97 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 20:16:42 +0200 Subject: [PATCH 01/24] fix(worktree): stop prune from unregistering live worktrees Staleness was decided by reading gitdir inside the metadata dir. Under the agent sandbox that dir is pinned as read-only bind mounts, so a live worktree can read as having no gitdir at all -- prune then removed gitdir and HEAD and left the checkout as 'fatal: not a git repository' with no way back. Decide liveness from git's own worktree list plus each checkout's .git pointer instead, keep entries whose checkout base is not visible rather than guessing, drop the expected EBUSY noise, and report the pinned leftovers once with the command that clears them. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/system/worktree/prune.sh | 72 ++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/scripts/system/worktree/prune.sh b/scripts/system/worktree/prune.sh index 25c9844a43..692df8e461 100755 --- a/scripts/system/worktree/prune.sh +++ b/scripts/system/worktree/prune.sh @@ -19,7 +19,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" cd "${REPO_ROOT}" -if ! git worktree prune -v; then +if ! git worktree prune -v 2>/dev/null; then echo ">>> git could not delete every stale entry; clearing the metadata by hand" fi @@ -29,21 +29,79 @@ if [ ! -d "${meta_dir}" ]; then exit 0 fi +live_ids=() +while IFS= read -r line; do + case "${line}" in + "worktree "*) ;; + *) continue ;; + esac + checkout="${line#worktree }" + [ -f "${checkout}/.git" ] || continue + live_ids+=("$(basename "$(sed -n 's/^gitdir: //p' "${checkout}/.git")")") +done < <(git worktree list --porcelain) + +is_live() { + local known + for known in ${live_ids[@]+"${live_ids[@]}"}; do + [ "${known}" = "${1}" ] && return 0 + done + return 1 +} + pruned=0 +held=() +unverifiable=() for entry in "${meta_dir}"/*; do [ -d "${entry}" ] || continue - if [ -f "${entry}/gitdir" ] && [ -e "$(cat "${entry}/gitdir")" ]; then + if is_live "$(basename "${entry}")"; then continue fi + + gitdir="" + if [ -f "${entry}/gitdir" ]; then + gitdir="$(cat "${entry}/gitdir")" + fi + if [ -n "${gitdir}" ] && [ -e "${gitdir}" ]; then + continue + fi + + if [ -n "${gitdir}" ]; then + base="$(dirname "$(dirname "${gitdir}")")" + if [ ! -d "${base}" ]; then + unverifiable+=("$(basename "${entry}") -> ${gitdir}") + continue + fi + fi + + registered=false + if [ -e "${entry}/gitdir" ] || [ -e "${entry}/HEAD" ]; then + registered=true + fi + rm -f "${entry}/gitdir" "${entry}/HEAD" - if rm -rf "${entry}"; then + if ! rm -rf "${entry}" 2>/dev/null; then + held+=("${entry}") + fi + + if [ "${registered}" = true ]; then echo "Pruned $(basename "${entry}")" - else - echo "Pruned $(basename "${entry}") (sandbox-held leftovers stay in ${entry})" + pruned=$((pruned + 1)) fi - pruned=$((pruned + 1)) done -if [ "${pruned}" -eq 0 ]; then +if [ "${pruned}" -eq 0 ] && [ "${#unverifiable[@]}" -eq 0 ]; then echo "No stale worktree registrations left." fi + +if [ "${#held[@]}" -gt 0 ]; then + echo ">>> Unregistered, but the sandbox pins these metadata dirs; git ignores them now:" + printf ' %s\n' "${held[@]}" + echo ">>> Clear them outside the sandbox: rm -rf ${held[*]}" +fi + +if [ "${#unverifiable[@]}" -gt 0 ]; then + echo ">>> Kept registered โ€” their checkout base is not visible from here, so" + echo ">>> whether the checkout is gone cannot be decided:" + printf ' %s\n' "${unverifiable[@]}" + echo ">>> Re-run outside the sandbox to prune them." +fi From 3257376fcee0d051986cf4bd058b189670c807c4 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 21:24:23 +0200 Subject: [PATCH 02/24] refactor(ci): resolve the dev-runtime matrix through a named module The workspace workflow asked the distro SPOT two questions through embedded python one-liners: the matrix image list, and the distro behind a matrix entry. Give both a name -- distro_of_dev_runtime_image() as the counterpart to dev_runtime_image(), and a cli.meta.ci.dev_runtime module shaped like its image_ref neighbour. The empty-SPOT guard that protected the matrix from silently skipping every job moves along with it. No caller yet; the workflow switches over in the next commit. Co-Authored-By: Claude Opus 5 (1M context) --- cli/meta/ci/dev_runtime.py | 52 ++++++++++++++++++++++++++++++++ tests/unit/utils/test_distros.py | 9 ++++++ utils/distros.py | 13 +++++++- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 cli/meta/ci/dev_runtime.py diff --git a/cli/meta/ci/dev_runtime.py b/cli/meta/ci/dev_runtime.py new file mode 100644 index 0000000000..59bb021002 --- /dev/null +++ b/cli/meta/ci/dev_runtime.py @@ -0,0 +1,52 @@ +"""Answer the development-runtime matrix questions from the distro SPOT. + +Exists so workflow steps can ask for the matrix, and for the distro behind a +matrix entry, without embedding a python one-liner per question. + + python3 -m cli.meta.ci.dev_runtime images + python3 -m cli.meta.ci.dev_runtime distro --image fedora:latest +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from utils.distros import ( + FILE_META_DISTROS, + dev_runtime_images, + distro_of_dev_runtime_image, +) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("images", help="Print the matrix images as a JSON array.") + distro = commands.add_parser( + "distro", help="Print the distro a matrix image belongs to." + ) + distro.add_argument( + "--image", required=True, help="Matrix image, e.g. fedora:latest." + ) + args = parser.parse_args(argv) + + if args.command == "images": + images = list(dev_runtime_images()) + if not images: + print( + f"{FILE_META_DISTROS} declares no dev runtime image; " + "an empty matrix would skip every workspace job silently", + file=sys.stderr, + ) + return 1 + print(json.dumps(images)) + return 0 + + print(distro_of_dev_runtime_image(args.image)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/utils/test_distros.py b/tests/unit/utils/test_distros.py index 6a758477a2..2bb7a77a85 100644 --- a/tests/unit/utils/test_distros.py +++ b/tests/unit/utils/test_distros.py @@ -10,6 +10,7 @@ from utils.distros import ( dev_runtime_images, distro_family, distro_names, + distro_of_dev_runtime_image, environment_image, galaxy_platforms, pkgmgr_image, @@ -65,6 +66,14 @@ class TestDistroSpot(unittest.TestCase): with self.assertRaises(UnknownDistroError): pkgmgr_image("gentoo", owner="acme", tag="stable") + def test_dev_runtime_image_round_trips_to_its_distro(self) -> None: + for name in distro_names(): + self.assertEqual(distro_of_dev_runtime_image(dev_runtime_image(name)), name) + + def test_unknown_dev_runtime_image_is_rejected(self) -> None: + with self.assertRaises(UnknownDistroError): + distro_of_dev_runtime_image("gentoo:latest") + if __name__ == "__main__": unittest.main() diff --git a/utils/distros.py b/utils/distros.py index a96f025327..c03e33d947 100644 --- a/utils/distros.py +++ b/utils/distros.py @@ -36,7 +36,7 @@ IMAGE_ENVIRONMENT = "environment" class UnknownDistroError(ValueError): - """Raised for a distro id that ``meta/distros.yml`` does not declare.""" + """Raised for a distro id or image that ``meta/distros.yml`` does not declare.""" def _doc() -> dict: @@ -90,6 +90,17 @@ def dev_runtime_image(distro: str) -> str: return _spec(distro)["dev_runtime_image"] +def distro_of_dev_runtime_image(image: str) -> str: + """Distro id whose development environment boots from ``image``.""" + for name, spec in _specs().items(): + if spec["dev_runtime_image"] == image: + return name + raise UnknownDistroError( + f"{image!r} is not a dev_runtime_image in {FILE_META_DISTROS}. " + f"Declared: {', '.join(dev_runtime_images())}." + ) + + def image_template(kind: str) -> str: """Raw reference template, for callers rendering a non-distro slug. From 82f2cb86526ce2bcbb8d9c597404970213e7d37c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 21:26:53 +0200 Subject: [PATCH 03/24] fix(ci): run each workspace leg against its own distro and image Every workspace leg pinned INFINITO_DISTRO=debian and drilled all five distros sequentially, pulling its swarm nodes from ghcr :latest -- an image from 2026-03-21 whose nfs-ganesha 7.2 segfaults on any accepted TCP connection on 2049. The controller mount then failed with ECONNREFUSED for 26 minutes and took run 30446813110 down after 5h13m, with 3 of 5 distros never reached. Derive the distro from the leg's own matrix entry, build that image, and drill only it: swarm-zombie takes an optional distros= that defaults to INFINITO_DISTRO, and 00_one.sh prefers the locally built image over the published one. 12_swarm.sh refuses to start when that image is missing, so a broken build fails loudly instead of being masked by a stale registry pull. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-workspace.yml | 15 +++++++++------ Makefile | 5 +++-- scripts/tests/deploy/swarm/routine/00_one.sh | 8 +++++++- scripts/tests/environment/12_swarm.sh | 6 ++++++ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-workspace.yml b/.github/workflows/test-workspace.yml index d36ae56e5a..01f9d9dc19 100644 --- a/.github/workflows/test-workspace.yml +++ b/.github/workflows/test-workspace.yml @@ -25,7 +25,7 @@ jobs: id: matrix shell: bash run: | - python3 -c 'import json, sys; from utils.distros import dev_runtime_images; images = list(dev_runtime_images()); images or sys.exit("meta/distros.yml declares no dev runtime image; an empty matrix would skip every workspace job silently"); print("images=" + json.dumps(images))' | tee -a "$GITHUB_OUTPUT" + echo "images=$(python3 -m cli.meta.ci.dev_runtime images)" | tee -a "$GITHUB_OUTPUT" test-workspace: name: ๐Ÿง‘โ€๐Ÿ’ป Workspace (${{ matrix.dev_runtime_image }}) @@ -65,12 +65,15 @@ jobs: - name: โฌ‡๏ธ Checkout repository uses: actions/checkout@v7.0.0 - - name: "๐Ÿ“ฅ Load rescue-diagnostics SPOT into job env" + - name: "๐Ÿ“ฅ Load workspace SPOT values into job env" shell: bash run: | set -euo pipefail - grep '^INFINITO_RESCUE_DIAGNOSTICS_BASE=' default.env >> "$GITHUB_ENV" - echo "INFINITO_WORKSPACE_SLUG=$(printf '%s' "${DEV_RUNTIME_IMAGE}" | tr '/:' '--')" >> "$GITHUB_ENV" + { + grep '^INFINITO_RESCUE_DIAGNOSTICS_BASE=' default.env + echo "INFINITO_WORKSPACE_SLUG=$(printf '%s' "${DEV_RUNTIME_IMAGE}" | tr '/:' '--')" + echo "INFINITO_DISTRO=$(python3 -m cli.meta.ci.dev_runtime distro --image "${DEV_RUNTIME_IMAGE}")" + } >> "$GITHUB_ENV" - name: โ–ถ๏ธ Run workspace routine shell: bash @@ -84,8 +87,8 @@ jobs: -e GITHUB_REPOSITORY_OWNER="${{ github.repository_owner }}" \ -e GITHUB_SHA="${{ github.sha }}" \ -e DEV_RUNTIME_IMAGE \ - -e INFINITO_DISTRO=debian \ - -e INFINITO_IMAGE=infinito-debian \ + -e INFINITO_DISTRO \ + -e INFINITO_IMAGE="infinito-${INFINITO_DISTRO}" \ -e INFINITO_PULL_POLICY=never \ -e INFINITO_BUILD=1 \ -e NIX_CONFIG \ diff --git a/Makefile b/Makefile index df29d17654..31820e75be 100644 --- a/Makefile +++ b/Makefile @@ -685,13 +685,14 @@ swarm-shell: .PHONY: swarm-zombie # Run a swarm matrix-app test and leave the cluster alive afterwards for post-mortem inspection. # Param app: matrix application id (e.g. web-app-baserow). +# Param distros: optional single distro the cluster runs on (default: INFINITO_DISTRO from .env). # Param variant: optional matrix variant index to deploy (default 0); a multi-variant app runs one cluster per swarm-zombie, so pick the round to validate. # Param disable: optional comma-separated provider keys removed from the test inventory (e.g. matomo,dashboard,prometheus,email,css). # Param name: optional cluster-id prefix for the container + network names (parallel/named clusters); release with the same name=. # Param step_timeout: optional minute budget for the matrix-deploy step (default 690). # Note: Use `make swarm-exec` / `make swarm-shell` to inspect, `make swarm-down` to release. swarm-zombie: install-act - @test -n '$(app)' || { echo 'usage: make swarm-zombie app= [variant=] [name=] [disable=]'; exit 2; } + @test -n '$(app)' || { echo 'usage: make swarm-zombie app= [distros=] [variant=] [name=] [disable=]'; exit 2; } @SWARM_NAME='$(or $(name),$(app))' INFINITO_KEEP_SWARM_NODES=false bash scripts/tests/deploy/swarm/utils/clean/teardown.sh @bash scripts/tests/deploy/act/down_act_outer.sh @ACT_RM=false \ @@ -700,7 +701,7 @@ swarm-zombie: install-act ACT_WORKFLOW=.github/workflows/test-deploy-swarm.yml \ ACT_JOB=swarm \ ACT_MATRIX='apps:$(app);variant:$(or $(variant),0)' \ - ACT_INPUTS='whitelist=$(app)' \ + ACT_INPUTS="whitelist=$(app) distros=$(or $(distros),$${INFINITO_DISTRO:?})" \ bash scripts/tests/deploy/act/workflow.sh .PHONY: system-purge diff --git a/scripts/tests/deploy/swarm/routine/00_one.sh b/scripts/tests/deploy/swarm/routine/00_one.sh index f3d204aa29..09e3825ca8 100755 --- a/scripts/tests/deploy/swarm/routine/00_one.sh +++ b/scripts/tests/deploy/swarm/routine/00_one.sh @@ -57,7 +57,13 @@ collect_and_teardown() { } trap collect_and_teardown EXIT -INFINITO_IMAGE="$(bash "${REPO_ROOT}/scripts/meta/resolve/image/ci.sh")" +INFINITO_IMAGE="$(bash "${REPO_ROOT}/scripts/meta/resolve/image/local.sh"):${INFINITO_IMAGE_TAG:?}" +if docker image inspect "${INFINITO_IMAGE}" >/dev/null 2>&1; then + echo "==> node image: locally built ${INFINITO_IMAGE}" +else + INFINITO_IMAGE="$(bash "${REPO_ROOT}/scripts/meta/resolve/image/ci.sh")" + echo "==> node image: published ${INFINITO_IMAGE:-}" +fi export INFINITO_IMAGE bash "${SCRIPT_DIR}/01_bootstrap.sh" diff --git a/scripts/tests/environment/12_swarm.sh b/scripts/tests/environment/12_swarm.sh index 756e29c732..7644d6fbcc 100755 --- a/scripts/tests/environment/12_swarm.sh +++ b/scripts/tests/environment/12_swarm.sh @@ -8,6 +8,12 @@ source "${SCRIPT_DIR}/utils/common.sh" cd "${REPO_ROOT}" +node_image="$(bash scripts/meta/resolve/image/local.sh):${INFINITO_IMAGE_TAG:?}" +docker image inspect "${node_image}" >/dev/null 2>&1 || { + echo "FAILURE: ${node_image} is missing. 03_build.sh builds it for ${INFINITO_DISTRO:?}; the swarm nodes must run that image, not a published one." >&2 + exit 1 +} + echo "Deploying the MariaDB database role ${MARIADB_APP} on a throwaway swarm cluster." make swarm-zombie app="${MARIADB_APP}" From 9d72aee69c16612198f25957cce5d8783a7250f5 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 21:28:20 +0200 Subject: [PATCH 04/24] refactor(ci): keep the distro default at the entry workflow only The distros input carried the full five-distro list as a default on ci-orchestrator and on both triggers of test-deploy-swarm, so a caller that forgot it silently inherited everything -- which is how a single workspace leg ended up drilling all five distros sequentially. test-deploy-compose already required the value; make swarm and the orchestrator match it. Every caller passes distros explicitly today, and entry-manual keeps the only default a human ever sees. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-orchestrator.yml | 3 +-- .github/workflows/test-deploy-swarm.yml | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-orchestrator.yml b/.github/workflows/ci-orchestrator.yml index aac6af39a1..5d34c3385b 100644 --- a/.github/workflows/ci-orchestrator.yml +++ b/.github/workflows/ci-orchestrator.yml @@ -4,9 +4,8 @@ on: inputs: distros: description: "Space-separated distro list" - required: false + required: true type: string - default: "arch debian ubuntu fedora centos" whitelist: description: "Optional space-separated whitelist of app ids" required: false diff --git a/.github/workflows/test-deploy-swarm.yml b/.github/workflows/test-deploy-swarm.yml index e1ebab94dd..076dcc0257 100644 --- a/.github/workflows/test-deploy-swarm.yml +++ b/.github/workflows/test-deploy-swarm.yml @@ -5,9 +5,8 @@ on: inputs: distros: description: "Space-separated distro list (shown in the discover job name)" - required: false + required: true type: string - default: "arch debian ubuntu fedora centos" whitelist: description: "Optional space-separated whitelist of app ids" required: false @@ -32,9 +31,8 @@ on: inputs: distros: description: "Space-separated distro list (shown in the discover job name)" - required: false + required: true type: string - default: "arch debian ubuntu fedora centos" whitelist: description: "Optional space-separated whitelist of app ids" required: false From c991a3ac2a0a989c6046acf48469a0d33d106813 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 22:36:26 +0200 Subject: [PATCH 05/24] fix(ci): let a failing distro resolver abort the workspace step Both steps resolved their value as `echo "X=$(python3 ...)"`. A command substitution's exit status is discarded when it is only an argument, so echo's 0 won: the step stayed green and wrote an empty value. Measured against the real module -- old shape exit 0 with `INFINITO_DISTRO=`, assignment form exit 1 and nothing written. The empty matrix then surfaced as an unexplained fromJson error one job later, and the empty distro as `INFINITO_IMAGE=infinito-` deep inside the docker run, where set -u cannot help because GITHUB_ENV defined the variable as empty rather than unset. The prepare step also had no `set` line of its own; it inherited -eo pipefail from the shell wrapper and never had -u. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-workspace.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-workspace.yml b/.github/workflows/test-workspace.yml index 01f9d9dc19..6f6d89d5de 100644 --- a/.github/workflows/test-workspace.yml +++ b/.github/workflows/test-workspace.yml @@ -25,7 +25,9 @@ jobs: id: matrix shell: bash run: | - echo "images=$(python3 -m cli.meta.ci.dev_runtime images)" | tee -a "$GITHUB_OUTPUT" + set -euo pipefail + images="$(python3 -m cli.meta.ci.dev_runtime images)" + echo "images=${images}" | tee -a "$GITHUB_OUTPUT" test-workspace: name: ๐Ÿง‘โ€๐Ÿ’ป Workspace (${{ matrix.dev_runtime_image }}) @@ -69,10 +71,11 @@ jobs: shell: bash run: | set -euo pipefail + distro="$(python3 -m cli.meta.ci.dev_runtime distro --image "${DEV_RUNTIME_IMAGE}")" { grep '^INFINITO_RESCUE_DIAGNOSTICS_BASE=' default.env echo "INFINITO_WORKSPACE_SLUG=$(printf '%s' "${DEV_RUNTIME_IMAGE}" | tr '/:' '--')" - echo "INFINITO_DISTRO=$(python3 -m cli.meta.ci.dev_runtime distro --image "${DEV_RUNTIME_IMAGE}")" + echo "INFINITO_DISTRO=${distro}" } >> "$GITHUB_ENV" - name: โ–ถ๏ธ Run workspace routine From 005775a3330e73f1a033277f968f2908e460f2b5 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 22:40:12 +0200 Subject: [PATCH 06/24] fix(worktree): prune only what is provably gone 841a5e0e1 claimed to keep entries whose checkout cannot be inspected, but the check resolved dirname(dirname(gitdir)) -- the checkout's PARENT, e.g. /tmp -- which always exists. The branch was dead code and every entry fell through to `rm -f gitdir HEAD`, including a live worktree whose metadata git can no longer read: that path drops it from the liveness list and it was then unregistered, which is the reported symptom. Make the checkout directory itself the criterion, and refuse to conclude anything when its parent is not traversable -- `test -d` succeeds on a directory without +x, so a first attempt using -d alone reproduced the same blind spot. Reproduced in throwaway repos: a live worktree with a 0-byte commondir keeps its registration, a deleted checkout is pruned, and a metadata corpse without gitdir is cleaned. Remaining limit: `git worktree prune`, which the script runs first, shares the blind spot and wins where it can unlink; this guard therefore only decides in the sandbox-pinned case that produced the bug. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/system/worktree/prune.sh | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/scripts/system/worktree/prune.sh b/scripts/system/worktree/prune.sh index 692df8e461..e15f33bfc7 100755 --- a/scripts/system/worktree/prune.sh +++ b/scripts/system/worktree/prune.sh @@ -61,14 +61,12 @@ for entry in "${meta_dir}"/*; do if [ -f "${entry}/gitdir" ]; then gitdir="$(cat "${entry}/gitdir")" fi - if [ -n "${gitdir}" ] && [ -e "${gitdir}" ]; then - continue - fi if [ -n "${gitdir}" ]; then - base="$(dirname "$(dirname "${gitdir}")")" - if [ ! -d "${base}" ]; then - unverifiable+=("$(basename "${entry}") -> ${gitdir}") + checkout="$(dirname "${gitdir}")" + base="$(dirname "${checkout}")" + if [ -e "${checkout}" ] || [ ! -d "${base}" ] || [ ! -x "${base}" ]; then + unverifiable+=("$(basename "${entry}") -> ${checkout}") continue fi fi @@ -100,8 +98,8 @@ if [ "${#held[@]}" -gt 0 ]; then fi if [ "${#unverifiable[@]}" -gt 0 ]; then - echo ">>> Kept registered โ€” their checkout base is not visible from here, so" - echo ">>> whether the checkout is gone cannot be decided:" + echo ">>> Kept registered โ€” their checkout is still on disk (or its parent is not" + echo ">>> readable from here), so 'gone' cannot be established:" printf ' %s\n' "${unverifiable[@]}" - echo ">>> Re-run outside the sandbox to prune them." + echo ">>> Release them with 'make worktree-down branch=' instead." fi From 170efd1796e1972ac6fbf4133f4376444975058a Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 22:42:02 +0200 Subject: [PATCH 07/24] fix(swarm): declare the node-image tag as a pre-flight requirement f660dd6bd introduced `${INFINITO_IMAGE_TAG:?}` in the image resolution, a hard requirement the replaced call deliberately did not have -- ci.sh exits 0 before its own assert when no registry owner is in scope. The check sat after the EXIT trap was armed and before the env layer is sourced, so a missing tag ran collect_and_teardown against a cluster that was never created, and the variable appeared in neither the # Param header nor the strict guard block that carries the other four required inputs. Move it into that guard block, which runs before mktemp and before the trap, and drop the now-redundant inline assert. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tests/deploy/swarm/routine/00_one.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/tests/deploy/swarm/routine/00_one.sh b/scripts/tests/deploy/swarm/routine/00_one.sh index 09e3825ca8..4998349aa0 100755 --- a/scripts/tests/deploy/swarm/routine/00_one.sh +++ b/scripts/tests/deploy/swarm/routine/00_one.sh @@ -14,6 +14,7 @@ set -euo pipefail # APP_ID app id under test # SWARM_NAME cluster id # INFINITO_DISTRO distro under test (exported by distros.sh) +# INFINITO_IMAGE_TAG tag the node image is looked up under # INFINITO_RESCUE_DIAGNOSTICS_BASE host dir the rescue snapshots land under # INFINITO_SWARM_STEP_TIMEOUT_MINUTES deploy-step ceiling # variant optional matrix variant pin @@ -36,6 +37,7 @@ export SWARM_DRILL_ENV : "${APP_ID:?APP_ID is required (matrix.apps)}" : "${INFINITO_DISTRO:?INFINITO_DISTRO is required (exported by scripts/tests/deploy/distros.sh)}" +: "${INFINITO_IMAGE_TAG:?INFINITO_IMAGE_TAG is required (source scripts/meta/env/load.sh before invoking this script)}" : "${INFINITO_RESCUE_DIAGNOSTICS_BASE:?INFINITO_RESCUE_DIAGNOSTICS_BASE is required}" : "${INFINITO_SWARM_STEP_TIMEOUT_MINUTES:?INFINITO_SWARM_STEP_TIMEOUT_MINUTES is required}" @@ -57,7 +59,7 @@ collect_and_teardown() { } trap collect_and_teardown EXIT -INFINITO_IMAGE="$(bash "${REPO_ROOT}/scripts/meta/resolve/image/local.sh"):${INFINITO_IMAGE_TAG:?}" +INFINITO_IMAGE="$(bash "${REPO_ROOT}/scripts/meta/resolve/image/local.sh"):${INFINITO_IMAGE_TAG}" if docker image inspect "${INFINITO_IMAGE}" >/dev/null 2>&1; then echo "==> node image: locally built ${INFINITO_IMAGE}" else From f6c5f38760f7d13024bb212257d821122e2b5fae Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 22:43:24 +0200 Subject: [PATCH 08/24] docs(ci): realign the workflow table with the actual inputs Three rows disagreed with their workflow files: ci-orchestrator still advertised a distros default that 0f7334b3d removed, test-deploy-swarm omitted distros entirely although it is its only required input, and test-instructions listed one input with a svc-db-postgres default where the workflow declares four on both triggers and picks a random role when role is empty. test-dns is left alone -- its 'required on call; default debian on manual' already describes the real split. Co-Authored-By: Claude Opus 5 (1M context) --- docs/contributing/tools/github/actions/workflows.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/contributing/tools/github/actions/workflows.md b/docs/contributing/tools/github/actions/workflows.md index d773101ccd..fd7dcea098 100644 --- a/docs/contributing/tools/github/actions/workflows.md +++ b/docs/contributing/tools/github/actions/workflows.md @@ -35,7 +35,7 @@ Trigger column legend: **auto** = fires automatically (push, pull_request, sched | Workflow | Description | Trigger | Inputs | |---|---|---|---| -| [ci-orchestrator.yml](../../../../../.github/workflows/ci-orchestrator.yml): `๐ŸŽต CI: Orchestrator` | Central coordinator. Runs fork-prereq wait, security, linting, CI image build, code tests, code-quality gate, DNS tests, mirror, deploy tests (optionally split into a priority line and a regular line via `priority`; each mode's discover job renders its own plan table via `cli.meta.ci.plan` into the run summary), install tests, `test-workspace`, `test-instructions`, final `done` gate. | reusable | `distros` (default `arch debian ubuntu fedora centos`), `whitelist` (optional), `priority` (optional; app ids deployed first per mode, then excluded from the regular line, which only starts after every priority job succeeded), `image_wait_attempts` (default `1980`), `image_wait_sleep_seconds` (default `10`), `workspace` (default `auto`), `instructions` (default `auto`) | +| [ci-orchestrator.yml](../../../../../.github/workflows/ci-orchestrator.yml): `๐ŸŽต CI: Orchestrator` | Central coordinator. Runs fork-prereq wait, security, linting, CI image build, code tests, code-quality gate, DNS tests, mirror, deploy tests (optionally split into a priority line and a regular line via `priority`; each mode's discover job renders its own plan table via `cli.meta.ci.plan` into the run summary), install tests, `test-workspace`, `test-instructions`, final `done` gate. | reusable | `distros` (required), `whitelist` (optional), `priority` (optional; app ids deployed first per mode, then excluded from the regular line, which only starts after every priority job succeeded), `image_wait_attempts` (default `1980`), `image_wait_sleep_seconds` (default `10`), `workspace` (default `auto`), `instructions` (default `auto`) | ### Security and linting ๐Ÿ›ก๏ธ๐Ÿ” @@ -73,8 +73,8 @@ Trigger column legend: **auto** = fires automatically (push, pull_request, sched | [test-deploy-single-node.yml](../../../../../.github/workflows/test-deploy-single-node.yml): `๐Ÿ๏ธ Test: Deploy (single-node)` | Single point of truth for the non-swarm deploy tests: fans out to `test-deploy-compose.yml` and `test-deploy-host.yml`, gated per mode via the `modes` input. Called from the orchestrator. | reusable | `distros` (required), `whitelist`, `lifecycles`, `modes` (default `compose host`), `blacklist` (optional; app ids dropped from discovery) | | [test-deploy-compose.yml](../../../../../.github/workflows/test-deploy-compose.yml): `๐Ÿ“ฆ Test: Deploy (compose)` | Deploy test (compose mode) across all deployment types (`INFINITO_DISCOVER_ALL`; lifecycle-filtered to the `INFINITO_LIFECYCLES` envelope, so `server`/`universal`/`workstation` scopes but not just `web-*`). When `whitelist` is empty, the discover job derives one from the branch's diff vs `origin/main`; see the "Diff-driven app selection" subsection in [pipeline.md](../../../artefact/git/pipeline.md). | reusable | `distros` (required), `whitelist` (optional; explicit value wins over diff), `lifecycles`, `blacklist` (optional) | | [test-deploy-host.yml](../../../../../.github/workflows/test-deploy-host.yml): `๐Ÿ–ฅ๏ธ Test: Deploy (host)` | Deploy test (host mode) for invokable non-stack roles that configure the host instead of shipping a container stack. Same diff-driven whitelist resolution as `test-deploy-compose.yml`; no Playwright stage (host roles have no web UI). | reusable | `distros` (required), `whitelist` (optional; explicit value wins over diff), `lifecycles`, `blacklist` (optional) | -| [test-deploy-swarm.yml](../../../../../.github/workflows/test-deploy-swarm.yml): `๐Ÿ Test: Deploy (swarm)` | Deploy test (swarm mode) across the full application matrix; each app gets a simulated 3-node DinD cluster with an NFS server, then a drain/reschedule state-survival assertion. Same diff-driven whitelist resolution as `test-deploy-compose.yml`. | reusable, manual | `whitelist` (optional; explicit value wins over diff), `lifecycles`, `blacklist` (optional; reusable trigger only) | -| [test-instructions.yml](../../../../../.github/workflows/test-instructions.yml): `๐Ÿ“– Test: Instructions` | Extracts the `### Production` command from a role README (default `svc-db-postgres`) and executes it against the runner: `` becomes `localhost` (ansible_connection local) and the Docker socket replaces the TTY flag. Verifies the documented one-command deploy actually works, seeding the runยดs freshly built CI image (needs `images-build-ci.yml`) instead of the published default. Gates the final `done` job. | reusable, manual | `role` (default `svc-db-postgres`) | +| [test-deploy-swarm.yml](../../../../../.github/workflows/test-deploy-swarm.yml): `๐Ÿ Test: Deploy (swarm)` | Deploy test (swarm mode) across the full application matrix; each app gets a simulated 3-node DinD cluster with an NFS server, then a drain/reschedule state-survival assertion. Same diff-driven whitelist resolution as `test-deploy-compose.yml`. | reusable, manual | `distros` (required), `whitelist` (optional; explicit value wins over diff), `lifecycles`, `blacklist` (optional; reusable trigger only) | +| [test-instructions.yml](../../../../../.github/workflows/test-instructions.yml): `๐Ÿ“– Test: Instructions` | Extracts the `### Production` command from a role README (a random role when `role` is empty) and executes it against the runner: `` becomes `localhost` (ansible_connection local) and the Docker socket replaces the TTY flag. Verifies the documented one-command deploy actually works, seeding the runยดs freshly built CI image (needs `images-build-ci.yml`) instead of the published default. Gates the final `done` job. | reusable, manual | `distros` (required), `role` (optional; empty picks one at random), `priority` (optional), `whitelist` (optional) | | [test-install-make.yml](../../../../../.github/workflows/test-install-make.yml): `๐Ÿ“ฅ Test: Install Make` | Validates the `make install` entry points. | reusable | none | | [test-install-pkgmgr.yml](../../../../../.github/workflows/test-install-pkgmgr.yml): `๐Ÿ“ฅ Test: Install Package Manager` | Validates installation via the upstream package manager. | reusable | none | | [test-workspace.yml](../../../../../.github/workflows/test-workspace.yml): `๐Ÿ’ป Test: Workspace` | Builds and exercises the dev-runtime image matrix. | reusable, manual | none | From 92ddeb07506da44d126307f25a834c5fd10da038 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 21:26:35 +0200 Subject: [PATCH 09/24] fix(ci-image): build node images locally under act instead of resolving ghcr act fills the GITHUB_REPOSITORY* vars with its own placeholders, so the existing owner guard passes and the resolver hands back ghcr.io/nektos/act/ . That path does not exist and the swarm node build died on a 403 three minutes into every local run. Returning nothing is what the callers already treat as 'build it locally'. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/meta/resolve/image/ci.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/meta/resolve/image/ci.sh b/scripts/meta/resolve/image/ci.sh index 4605e15e6e..cb5d82c3a8 100755 --- a/scripts/meta/resolve/image/ci.sh +++ b/scripts/meta/resolve/image/ci.sh @@ -8,12 +8,19 @@ # # Output: the image reference, or nothing when no registry owner is in scope, # which is the caller's signal to build the image locally instead. +# +# act sets the GITHUB_REPOSITORY* vars to placeholders (nektos/act), so the +# owner guard below passes and the resolved path 403s on ghcr.io. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" cd "${REPO_ROOT}" +if [[ "${ACT:-}" == "true" ]]; then + exit 0 +fi + if [[ -z "${OWNER:-}" && -z "${GITHUB_REPOSITORY_OWNER:-}" && -z "${GITHUB_REPOSITORY:-}" ]]; then exit 0 fi From b62b09aece23e869c8cb1b7572711e2d11c478f0 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 23:06:22 +0200 Subject: [PATCH 10/24] fix(swarm): drop the shell-side default from the node-image log line f660dd6bd wrote the placeholder into a parameter expansion, `${INFINITO_IMAGE:-}`, which tests/lint/filesystem/shell/test_no_infinito_defaults.py rejects: an INFINITO_* default in a shell file is a second source next to default.env that drifts silently, and only the empty form is allowed. It was the sole failure of test-lint. Split the message into the resolved and the unresolved case instead of suppressing the lint; the output is also no longer self-contradictory, since 'published ' could not both be true. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tests/deploy/swarm/routine/00_one.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/tests/deploy/swarm/routine/00_one.sh b/scripts/tests/deploy/swarm/routine/00_one.sh index 4998349aa0..1c82ec3f85 100755 --- a/scripts/tests/deploy/swarm/routine/00_one.sh +++ b/scripts/tests/deploy/swarm/routine/00_one.sh @@ -64,7 +64,11 @@ if docker image inspect "${INFINITO_IMAGE}" >/dev/null 2>&1; then echo "==> node image: locally built ${INFINITO_IMAGE}" else INFINITO_IMAGE="$(bash "${REPO_ROOT}/scripts/meta/resolve/image/ci.sh")" - echo "==> node image: published ${INFINITO_IMAGE:-}" + if [ -n "${INFINITO_IMAGE}" ]; then + echo "==> node image: published ${INFINITO_IMAGE}" + else + echo "==> node image: unresolved, bootstrap will build it locally" + fi fi export INFINITO_IMAGE From 4a5a9e2c24725f125338c736dbe26be91caeefe7 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 29 Jul 2026 23:08:39 +0200 Subject: [PATCH 11/24] docs(gitea): point the authentication link at its current location Gitea moved the authentication docs from usage/ to administration/; the old path returns 404 and failed the URL reachability test. Probed the candidates: usage/authentication and usage/authentication/ both 404, administration/authentication 308s to administration/authentication/ which answers 200 -- use the 200 form so the result does not depend on whether the checker follows redirects. Co-Authored-By: Claude Opus 5 (1M context) --- roles/web-app-gitea/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-gitea/README.md b/roles/web-app-gitea/README.md index 11b76c0907..e8e0a524a6 100644 --- a/roles/web-app-gitea/README.md +++ b/roles/web-app-gitea/README.md @@ -109,7 +109,7 @@ docker run --rm -it \ ## Further Resources - [Gitea Official Website](https://gitea.io/) -- [Gitea LDAP integration](https://docs.gitea.com/usage/authentication) +- [Gitea LDAP integration](https://docs.gitea.com/administration/authentication/) ## Credits From 5e1deb9ebbb2d1af0edc4c7fe309185c70dc2eff Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 11:03:13 +0200 Subject: [PATCH 12/24] fix(ci): budget the distro sweep under the deploy step cap The sweep's own governor was sized against the 6h platform kill (19800s) while the deploy step was capped at 300 minutes, so it could never fire: GitHub hard-killed the step mid-distro and reported a run that simply did not fit all distros as a job failure. Size the ladder instead, 10 and 5 minutes apart: sweep budget 340min < deploy step 350 < job 355 < platform kill 360. The swarm per-leg ceiling moves to 350 so it stops matching the job cap, where it was dead code. The rescue-state dump drops from 10 to 5 minutes so it cannot alone consume the window between the job kill and the platform kill; measured teardown is 10-38 seconds. A new lint asserts the budget stays under the governing step cap, the lower half of the invariant whose upper half the headroom lint already carried; that lint's ceiling moves from 345 to 355. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-deploy-compose.yml | 6 +- .github/workflows/test-deploy-host.yml | 6 +- .github/workflows/test-deploy-swarm.yml | 2 +- default.env | 8 +- .../test_distro_budget_fits_timeout.py | 108 ++++++++++++++++++ .../test_workflow_timeout_headroom.py | 8 +- 6 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 tests/lint/repository/test_distro_budget_fits_timeout.py diff --git a/.github/workflows/test-deploy-compose.yml b/.github/workflows/test-deploy-compose.yml index bb68308b08..e3513617ef 100644 --- a/.github/workflows/test-deploy-compose.yml +++ b/.github/workflows/test-deploy-compose.yml @@ -111,7 +111,7 @@ jobs: name: ๐Ÿณ ${{ matrix.apps }}${{ matrix.variant && format(' {0}',matrix.variant) || '' }} if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]' runs-on: ubuntu-latest - timeout-minutes: 345 + timeout-minutes: 355 needs: discover strategy: @@ -157,7 +157,7 @@ jobs: run: ./scripts/install/apt.sh jq - name: ๐Ÿš€ Deploy app - timeout-minutes: 300 + timeout-minutes: 350 shell: bash env: apps: ${{ matrix.apps }} @@ -190,7 +190,7 @@ jobs: - name: ๐Ÿฉบ Dump runner state (on failure) if: failure() - timeout-minutes: 10 + timeout-minutes: 5 shell: bash run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})" diff --git a/.github/workflows/test-deploy-host.yml b/.github/workflows/test-deploy-host.yml index 7fe17ebc16..df5bc93ba0 100644 --- a/.github/workflows/test-deploy-host.yml +++ b/.github/workflows/test-deploy-host.yml @@ -117,7 +117,7 @@ jobs: name: ๐Ÿ’ป ${{ matrix.apps }}${{ matrix.variant && format(' {0}',matrix.variant) || '' }} if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]' runs-on: ubuntu-latest - timeout-minutes: 345 + timeout-minutes: 355 needs: discover strategy: @@ -163,7 +163,7 @@ jobs: run: ./scripts/install/apt.sh jq - name: ๐Ÿš€ Deploy host role - timeout-minutes: 300 + timeout-minutes: 350 shell: bash env: apps: ${{ matrix.apps }} @@ -196,7 +196,7 @@ jobs: - name: ๐Ÿฉบ Dump runner state (on failure) if: failure() - timeout-minutes: 10 + timeout-minutes: 5 shell: bash run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})" diff --git a/.github/workflows/test-deploy-swarm.yml b/.github/workflows/test-deploy-swarm.yml index 076dcc0257..f38d706499 100644 --- a/.github/workflows/test-deploy-swarm.yml +++ b/.github/workflows/test-deploy-swarm.yml @@ -136,7 +136,7 @@ jobs: name: "๐Ÿ ${{ matrix.apps }}${{ matrix.variant && format(' {0}',matrix.variant) || '' }}" if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]' runs-on: ubuntu-latest - timeout-minutes: 345 + timeout-minutes: 355 needs: discover strategy: diff --git a/default.env b/default.env index 5f0833faf3..9466f62c6e 100644 --- a/default.env +++ b/default.env @@ -85,8 +85,8 @@ INFINITO_CACHE_REGISTRY_IP=172.30.0.6 # Bind-mounted registry-cache proxy configuration consumed by the cache container. INFINITO_CACHE_REGISTRY_PROXY_CONF=./compose/registry-cache/proxy.conf -# Budget scripts/tests/deploy/distros.sh spends before skipping the remaining distros; 5.5h is the largest that fits a 6h hosted-runner job. -INFINITO_CI_DISTRO_BUDGET_SECONDS=19800 +# Budget distros.sh spends before skipping the rest; stays under the 350-min step cap, which stays under the 355-min job cap. +INFINITO_CI_DISTRO_BUDGET_SECONDS=20400 # Job budget per workflow run cli.meta.ci.slots divides between the deploy matrices (GitHub matrix cap: 256). INFINITO_CI_JOB_LIMIT=256 @@ -283,8 +283,8 @@ INFINITO_SWARM_NFS_IP=192.168.244.13 # NFS-server sidecar base name in the act-swarm-test cluster. INFINITO_SWARM_NFS_NAME=nfs-server -# Ceiling in minutes for one distro's swarm matrix deploy in scripts/tests/deploy/swarm/routine/00_one.sh. -INFINITO_SWARM_STEP_TIMEOUT_MINUTES=345 +# Ceiling in minutes for one distro's swarm matrix deploy in scripts/tests/deploy/swarm/routine/00_one.sh; swarm's step level. +INFINITO_SWARM_STEP_TIMEOUT_MINUTES=350 # Docker label stamped on every act-swarm-test container (DinD + NFS sidecar) so cleanup selects by label, not a guessed name. INFINITO_SWARM_TEST_LABEL=infinito.swarm-test diff --git a/tests/lint/repository/test_distro_budget_fits_timeout.py b/tests/lint/repository/test_distro_budget_fits_timeout.py new file mode 100644 index 0000000000..887ddadbc4 --- /dev/null +++ b/tests/lint/repository/test_distro_budget_fits_timeout.py @@ -0,0 +1,108 @@ +"""Assert the distro sweep's own budget stays below the step timeout that governs it. + +``scripts/tests/deploy/distros.sh`` stops gracefully once +``INFINITO_CI_DISTRO_BUDGET_SECONDS`` is spent and reports the remaining +distros as skipped. That governor is only reachable while the budget is +smaller than the ``timeout-minutes`` of the workflow step that invokes the +sweep. Sized above it, GitHub hard-kills the step mid-distro and a partially +covered run turns into a job failure instead. + +This is the lower half of the invariant whose upper half lives in +``test_workflow_timeout_headroom.py``. +""" + +from __future__ import annotations + +import re +import unittest + +from utils.cache.files import iter_project_files_with_content, read_text + +from . import PROJECT_ROOT + +_SWEEP = "scripts/tests/deploy/distros.sh" +_BUDGET_KEY = "INFINITO_CI_DISTRO_BUDGET_SECONDS" + +_PREAMBLE_ALLOWANCE_SECONDS = 600 +"""Checkout, dependency install and runner preparation run inside the job but +outside the sweep, and the post-failure diagnostics/upload steps run after it.""" + +_BUDGET = re.compile(rf"^{_BUDGET_KEY}=(\d+)\s*$", re.MULTILINE) +_STEP_TIMEOUT = re.compile(r"^\s{6,}timeout-minutes:\s*(\d+)\b") +_STEP_BUDGET = re.compile(rf"^\s+{_BUDGET_KEY}:\s*(\d+)\b") +_STEP_START = re.compile(r"^\s{6}- name:") + + +def _relative(path: str) -> str: + return str(path).removeprefix(f"{PROJECT_ROOT}/") + + +def _budget_seconds() -> int: + match = _BUDGET.search(read_text(str(PROJECT_ROOT / "default.env"))) + if not match: + raise AssertionError(f"{_BUDGET_KEY} is not declared in default.env") + return int(match.group(1)) + + +def _sweep_entrypoints() -> set[str]: + """The sweep plus every script that invokes it, since workflows call wrappers.""" + entries = {_SWEEP} + for path, content in iter_project_files_with_content(extensions=(".sh",)): + rel = _relative(path) + if rel != _SWEEP and _SWEEP in content: + entries.add(rel) + return entries + + +def _governing_steps() -> list[tuple[str, int, int]]: + """Return (location, cap_seconds, effective_budget) per capped step running the sweep.""" + entries = _sweep_entrypoints() + fallback = _budget_seconds() + found: list[tuple[str, int, int]] = [] + for path, content in iter_project_files_with_content(extensions=(".yml",)): + rel = _relative(path) + if not rel.startswith(".github/workflows/"): + continue + timeout: int | None = None + budget = fallback + start = 0 + for lineno, line in enumerate(content.splitlines(), 1): + if _STEP_START.match(line): + timeout, budget, start = None, fallback, lineno + match = _STEP_TIMEOUT.match(line) + if match: + timeout = int(match.group(1)) + override = _STEP_BUDGET.match(line) + if override: + budget = int(override.group(1)) + if timeout is not None and any(entry in line for entry in entries): + found.append((f"{rel}:{start}", timeout * 60, budget)) + return found + + +class TestDistroBudgetFitsTimeout(unittest.TestCase): + def test_budget_stays_below_every_governing_step_timeout(self) -> None: + steps = _governing_steps() + self.assertTrue(steps, "no capped workflow step invoking the sweep was found") + + offenders = [ + f"{where}: step timeout {cap}s - allowance " + f"{_PREAMBLE_ALLOWANCE_SECONDS}s = {cap - _PREAMBLE_ALLOWANCE_SECONDS}s " + f"< effective {_BUDGET_KEY}={budget}s" + for where, cap, budget in steps + if budget > cap - _PREAMBLE_ALLOWANCE_SECONDS + ] + + if offenders: + self.fail( + f"The effective {_BUDGET_KEY} does not fit the step timeout " + "that governs the distro sweep. GitHub then hard-kills the " + "step mid-distro before the sweep can stop gracefully, so a " + "run that simply did not fit all distros is reported as a job " + "failure. Override the budget on the step (env:) or raise the " + "cap.\n" + "\n".join(offenders) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/repository/test_workflow_timeout_headroom.py b/tests/lint/repository/test_workflow_timeout_headroom.py index 7da074c8dd..f1074744dd 100644 --- a/tests/lint/repository/test_workflow_timeout_headroom.py +++ b/tests/lint/repository/test_workflow_timeout_headroom.py @@ -1,10 +1,10 @@ -"""Forbid ``timeout-minutes`` above 345 in GitHub workflow files. +"""Forbid ``timeout-minutes`` above 355 in GitHub workflow files. GitHub kills a hosted-runner job hard at 360 minutes (6h). A job or step -budgeted above 345 leaves the post-failure steps (rescue diagnostics, +budgeted above 355 leaves the post-failure steps (rescue diagnostics, artifact uploads) too little runtime: the platform kill fires before or together with the configured timeout and the failure logs are lost. -Budget at most 345 so ``if: failure()`` / ``if: always()`` steps keep +Budget at most 355 so ``if: failure()`` / ``if: always()`` steps keep enough headroom to extract them. """ @@ -19,7 +19,7 @@ from . import PROJECT_ROOT _WORKFLOWS_DIR = PROJECT_ROOT / ".github" / "workflows" -_MAX_TIMEOUT_MINUTES = 345 +_MAX_TIMEOUT_MINUTES = 355 _TIMEOUT = re.compile(r"^\s*timeout-minutes:\s*(\d+)\b") From 8aa251d0ac0ee5735a2153292a3a37e99beea5ed Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 11:18:35 +0200 Subject: [PATCH 13/24] fix(worktree): derive a persistent base and slot the lab node IPs The default base was /tmp, which is tmpfs on this host: a reboot took a whole worktree and its uncommitted work with it. Derive the base from the origin remote instead, as ~/.local/share/worktrees///, and let the branch keep its slashes so it nests, which also makes the infinito-wt- prefix redundant. Without an origin remote it now fails loudly rather than inventing a location. up.sh bumped only INFINITO_SWARM_LAB_SUBNET per slot while the five swarm lab node IPs stayed at default.env's 192.168.244.x, so every worktree from slot 1 on got a lab network in a different /24 than the nodes it places in it, and collided with the primary checkout's lab. Pin all six values from one octet. Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 8 ++++---- scripts/system/worktree/down.sh | 6 +++--- scripts/system/worktree/lib.sh | 14 +++++++++++--- scripts/system/worktree/up.sh | 23 +++++++++++++++++++---- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 31820e75be..8fb46bc047 100644 --- a/Makefile +++ b/Makefile @@ -771,11 +771,11 @@ test-unit: install # Stop a branch worktree's stack, release the checkout and free its slot. # Usage: make worktree-down branch= [base=] [force=true] # Param branch: branch the worktree was created for (required). -# Param base: parent directory the worktree lives in (default /tmp). +# Param base: parent directory the worktree lives in (default ~/.local/share/worktrees///). # Param force: true discards uncommitted changes in the worktree. worktree-down: @test -n '$(branch)' || { echo 'usage: make worktree-down branch= [base=] [force=true]'; exit 2; } - @bash scripts/system/worktree/down.sh '$(branch)' '$(or $(base),/tmp)' '$(or $(force),false)' + @bash scripts/system/worktree/down.sh '$(branch)' '$(base)' '$(or $(force),false)' .PHONY: worktree-prune # Drop registrations of worktrees whose directory is gone, freeing the branches they claim. @@ -788,10 +788,10 @@ worktree-prune: # Usage: make worktree-up branch= [base=] # Note: the worktree shares the primary checkout's cache stack instead of starting its own. # Param branch: branch to check out (required). -# Param base: parent directory for the worktree (default /tmp). +# Param base: parent directory for the worktree (default ~/.local/share/worktrees///). worktree-up: @test -n '$(branch)' || { echo 'usage: make worktree-up branch= [base=]'; exit 2; } - @bash scripts/system/worktree/up.sh '$(branch)' '$(or $(base),/tmp)' + @bash scripts/system/worktree/up.sh '$(branch)' '$(base)' .PHONY: wsl2-dns-setup # Set up DNS on WSL2. diff --git a/scripts/system/worktree/down.sh b/scripts/system/worktree/down.sh index 5a12a03c0e..835fbd6c69 100755 --- a/scripts/system/worktree/down.sh +++ b/scripts/system/worktree/down.sh @@ -16,13 +16,13 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" source "${SCRIPT_DIR}/lib.sh" branch="${1:?branch name required}" -base="${2:-/tmp}" +base="${2:-}" force="${3:-false}" cd "${REPO_ROOT}" -slug="$(worktree_slug "${branch}")" -path="$(worktree_path "${slug}" "${base}")" +base="${base:-$(worktree_default_base)}" +path="$(worktree_path "${branch}" "${base}")" if [ ! -d "${path}" ]; then echo "FAILURE: no worktree at ${path} for branch '${branch}'" >&2 diff --git a/scripts/system/worktree/lib.sh b/scripts/system/worktree/lib.sh index e142c23bbe..28b5eba11f 100755 --- a/scripts/system/worktree/lib.sh +++ b/scripts/system/worktree/lib.sh @@ -24,14 +24,22 @@ # # Sourced by up.sh and down.sh; not meant to be executed directly. -WORKTREE_DIR_PREFIX="infinito-wt-" - worktree_slug() { printf '%s' "$1" | sed -e 's#[^a-zA-Z0-9._-]#-#g' -e 's#^[^a-zA-Z0-9]*##' -e 's#[-.]*$##' } +worktree_default_base() { + local url + url="$(git remote get-url origin)" + url="${url%.git}" + url="${url#*://}" + url="${url#*@}" + printf '%s/.local/share/worktrees/%s/%s' "${HOME}" "${url%%[:/]*}" "${url#*[:/]}" +} + +# Exception: the branch keeps its slashes; git already forbids a branch `feature` beside `feature/x`, so the nested dirs cannot collide. worktree_path() { - printf '%s/%s%s' "$2" "${WORKTREE_DIR_PREFIX}" "$1" + printf '%s/%s' "$2" "$1" } worktree_slot_of() { diff --git a/scripts/system/worktree/up.sh b/scripts/system/worktree/up.sh index 87f4cdf686..f97a72aab2 100755 --- a/scripts/system/worktree/up.sh +++ b/scripts/system/worktree/up.sh @@ -17,18 +17,21 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" source "${SCRIPT_DIR}/lib.sh" branch="${1:?branch name required}" -base="${2:-/tmp}" +base="${2:-}" max_slot=255 cd "${REPO_ROOT}" +base="${base:-$(worktree_default_base)}" + slug="$(worktree_slug "${branch}")" if [ -z "${slug}" ]; then echo "FAILURE: branch '${branch}' slugs to an empty name" >&2 exit 1 fi -path="$(worktree_path "${slug}" "${base}")" +path="$(worktree_path "${branch}" "${base}")" +mkdir -p "$(dirname "${path}")" if [ -e "${path}" ]; then echo "FAILURE: ${path} already exists; run 'make worktree-down branch=${branch}' first" >&2 exit 1 @@ -50,6 +53,8 @@ git worktree add "${path}" "${branch}" git_common_dir="$(git rev-parse --path-format=absolute --git-common-dir)" +lab_octet="$((244 + slot))" + echo ">>> Pinning slot ${slot} in ${path}/custom.env" cat >"${path}/custom.env" < Date: Thu, 30 Jul 2026 11:42:48 +0200 Subject: [PATCH 14/24] fix(swarm): recreate tasks so in-container changes reach the process docker stack deploy leaves tasks running when the service spec is unchanged, so a change made inside a container never reaches the process. PeerTube's CLI installs plugins with register:false, so the OIDC plugin sat installed and enabled in the database while the running server had loaded nothing: /login rendered only the local form and both OIDC specs timed out through all three attempts. Compose never showed it because its handler force-recreates. Add a container recreate notify. In swarm it forces a task recreation per stack service and waits for convergence; in compose it maps onto the existing --force-recreate, so a role states the intent once and both modes honour it. In the peertube role the install probe now tests node_modules/, where pnpm puts the plugin code; the old path was the data directory that registerPlugin creates at boot, so it never existed and the plugin was reinstalled on every run. The settings upsert derives changed from the rowcount via RETURNING, because database_query reports changed from the kind of statement rather than from what it touched. A registration probe notifies when the running process has not loaded the plugin, so an already-broken instance heals itself instead of walking past both earlier probes. The plugin:enable task is gone: npm has no such script, the call always failed and failed_when swallowed it. The spec now polls the button lookup instead of counting once twenty milliseconds after goto, while still asserting on the URL so a failure keeps printing the address the browser actually sat on. Co-Authored-By: Claude Opus 5 (1M context) --- roles/sys-svc-compose/README.md | 2 +- roles/sys-svc-compose/handlers/compose.yml | 2 + roles/sys-svc-compose/handlers/swarm.yml | 15 +++++++ .../files/playwright/playwright.spec.js | 42 +++++++++---------- .../files/sql/upsert-oidc-plugin.sql | 3 +- roles/web-app-peertube/tasks/oidc/enable.yml | 19 +++++---- 6 files changed, 50 insertions(+), 33 deletions(-) diff --git a/roles/sys-svc-compose/README.md b/roles/sys-svc-compose/README.md index 45e867538a..4069a9e7bf 100644 --- a/roles/sys-svc-compose/README.md +++ b/roles/sys-svc-compose/README.md @@ -31,7 +31,7 @@ To offer a centralized, extensible system for managing containerized application - **Dynamic Directory Structure:** Creates per-application instance folders for Compose setups. - **Reset Logic:** Cleans previous Compose project files and data when `MODE_RESET` is enabled. -- **Handlers for Runtime Control:** Automatically builds, sets up, or restarts containers based on handlers. +- **Handlers for Runtime Control:** Automatically builds, sets up, or restarts containers based on handlers. Notify `compose up` when rendered files or the service spec changed; notify `container recreate` when only in-container or in-database state changed and the process must reload it โ€” the latter force-recreates in compose mode and forces a task recreation in swarm mode, where `docker stack deploy` alone would leave the running task untouched. - **Template-ready Service Files:** Predefined service base and health check templates. - **Integration Support:** Compatible with `sys-svc-proxy` and other Infinito.Nexus service roles. diff --git a/roles/sys-svc-compose/handlers/compose.yml b/roles/sys-svc-compose/handlers/compose.yml index 246290d217..efd05271db 100644 --- a/roles/sys-svc-compose/handlers/compose.yml +++ b/roles/sys-svc-compose/handlers/compose.yml @@ -4,6 +4,7 @@ listen: - compose up - compose build + - container recreate - name: Generate CA trust override ansible.builtin.shell: | @@ -88,3 +89,4 @@ executable: /bin/bash listen: - compose up + - container recreate diff --git a/roles/sys-svc-compose/handlers/swarm.yml b/roles/sys-svc-compose/handlers/swarm.yml index e6320a4989..88a7ef0bf1 100644 --- a/roles/sys-svc-compose/handlers/swarm.yml +++ b/roles/sys-svc-compose/handlers/swarm.yml @@ -137,3 +137,18 @@ - swarm deploy run_once: true delegate_to: "{{ STACK_HOST }}" + +- name: "swarm: force task recreation so in-container state is reloaded" + ansible.builtin.shell: | + set -euo pipefail + docker stack services --format '{{ "{{.Name}}" }}' {{ application_id | get_entity_name | quote }} \ + | xargs -r -n1 docker service update --detach=false --force + args: + executable: /bin/bash + register: _swarm_force_recreate + changed_when: true + timeout: "{{ lookup('timeout', 1800) | int }}" + listen: + - container recreate + run_once: true + delegate_to: "{{ STACK_HOST }}" diff --git a/roles/web-app-peertube/files/playwright/playwright.spec.js b/roles/web-app-peertube/files/playwright/playwright.spec.js index 4d56331a2d..e0fbc9a56f 100644 --- a/roles/web-app-peertube/files/playwright/playwright.spec.js +++ b/roles/web-app-peertube/files/playwright/playwright.spec.js @@ -50,7 +50,8 @@ test("peertube landing exposes canonical domain from applications lookup", async async function signInViaDashboardOidc(page, username, password, personaLabel) { const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`; - await page.goto(`${peertubeBaseUrl}/login`); + await page.goto(`${peertubeBaseUrl}/login`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle").catch(() => {}); const oidcButtonPatterns = [ oidcButtonText ? new RegExp(oidcButtonText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") : null, @@ -60,28 +61,25 @@ async function signInViaDashboardOidc(page, username, password, personaLabel) { /sign\s*in\s+with\s+oidc/i ].filter(Boolean); - const oidcSignIn = page - .locator("a, button") - .filter({ hasText: oidcButtonPatterns[0] }) - .first(); - - if ((await oidcSignIn.count().catch(() => 0)) > 0) { - await oidcSignIn.click(); - } else { - for (const pattern of oidcButtonPatterns.slice(1)) { - const candidate = page.locator("a, button").filter({ hasText: pattern }).first(); - if ((await candidate.count().catch(() => 0)) > 0) { - await candidate.click(); - break; - } - } - } - await expect - .poll(() => page.url(), { - timeout: 60_000, - message: `${personaLabel}: expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})` - }) + .poll( + async () => { + if (page.url().includes(expectedOidcAuthUrl)) return page.url(); + for (const pattern of oidcButtonPatterns) { + const candidate = page.locator("a, button").filter({ hasText: pattern }).first(); + if ((await candidate.count().catch(() => 0)) > 0) { + await candidate.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {}); + await candidate.click().catch(() => {}); + break; + } + } + return page.url(); + }, + { + timeout: 60_000, + message: `${personaLabel}: no OIDC sign-in button on ${peertubeBaseUrl}/login and no redirect to Keycloak; PeerTube has not registered the auth-openid-connect plugin` + } + ) .toContain(expectedOidcAuthUrl); await performKeycloakLoginForm(page, username, password); diff --git a/roles/web-app-peertube/files/sql/upsert-oidc-plugin.sql b/roles/web-app-peertube/files/sql/upsert-oidc-plugin.sql index 2c9c69058d..5716e0d248 100644 --- a/roles/web-app-peertube/files/sql/upsert-oidc-plugin.sql +++ b/roles/web-app-peertube/files/sql/upsert-oidc-plugin.sql @@ -11,4 +11,5 @@ WHERE name = 'auth-openid-connect' settings IS DISTINCT FROM %(settings)s::jsonb OR enabled IS DISTINCT FROM TRUE OR uninstalled IS DISTINCT FROM FALSE - ); + ) +RETURNING name; diff --git a/roles/web-app-peertube/tasks/oidc/enable.yml b/roles/web-app-peertube/tasks/oidc/enable.yml index ba3d55b192..9b71c34119 100644 --- a/roles/web-app-peertube/tasks/oidc/enable.yml +++ b/roles/web-app-peertube/tasks/oidc/enable.yml @@ -13,7 +13,7 @@ - name: ๐Ÿ”Ž Check if OIDC plugin is already installed shell: | # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node - container exec {{ PEERTUBE_LOCAL_CID | quote }} test -d /data/plugins/data/peertube-plugin-auth-openid-connect + container exec {{ PEERTUBE_LOCAL_CID | quote }} test -d /data/plugins/node_modules/{{ PEERTUBE_OIDC_PLUGIN }} register: peertube_oidc_plugin_check failed_when: false changed_when: false @@ -44,7 +44,7 @@ delay: 15 until: peertube_oidc_install.rc == 0 delegate_to: "{{ PEERTUBE_HOST_NODE }}" - notify: compose up + notify: container recreate # Exception: happy-path only; on failure the rescue leaves bumped limits so diagnostics reflect install-time state, and the next `compose up` restores compose.yml defaults. - name: "โ™ป๏ธ PEERTUBE | Restore compose-defined mem_limit" @@ -63,19 +63,20 @@ query: "{{ lookup('file', role_path ~ '/files/sql/upsert-oidc-plugin.sql') }}" # nocheck: database-query-raw controller-read so the SQL travels to the remote swarm host that lacks the repo files named_args: settings: "{{ oidc_settings | to_json }}" + expect_rows: true register: _peertube_oidc_update retries: 5 delay: 3 until: _peertube_oidc_update is succeeded - notify: compose up + changed_when: _peertube_oidc_update.rows | default([]) | length > 0 + notify: container recreate -- name: "โœ… Ensure plugin is enabled in PeerTube (CLI)" +- name: "๐Ÿ”Ž Check if the running PeerTube has registered the OIDC plugin" shell: | # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node - container exec {{ PEERTUBE_LOCAL_CID | quote }} npm run plugin:enable -- --npm-name {{ PEERTUBE_OIDC_PLUGIN }} - register: _peertube_enable + container exec {{ PEERTUBE_LOCAL_CID | quote }} test -d /data/plugins/data/{{ PEERTUBE_OIDC_PLUGIN }} + register: _peertube_oidc_registered failed_when: false - changed_when: > - _peertube_enable.stdout is defined and - ('already enabled' not in _peertube_enable.stdout) + changed_when: _peertube_oidc_registered.rc != 0 delegate_to: "{{ PEERTUBE_HOST_NODE }}" + notify: container recreate From 36cd39533ae58d28fa52d747e11eb0da6581029c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 11:47:41 +0200 Subject: [PATCH 15/24] fix(ci): stop reporting success for work that did not happen The pipeline gate only listed twelve of seventeen jobs in its needs, leaving out wait-fork-prereq-run, fork-prereqs-ready, build-ci-images, images-mirror-missing and test-dns. A failure in any of them turns its dependents into skipped rather than failure, and the gate's check matches only failure and cancelled, so the run would have concluded green without images ever being built. A caller-supplied whitelist was also passed through verbatim. Run 30492550883 was dispatched with web-app-prometheu; discovery dropped the unknown id without a warning, the empty resolution became [], the deploy workflows skipped their whole matrix on that condition and the parent job still concluded success. Validate every id against roles/ and exit naming the offenders, so a typo cannot quietly remove an application from a run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-orchestrator.yml | 5 +++++ scripts/github/resolve/effective_whitelist.sh | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/ci-orchestrator.yml b/.github/workflows/ci-orchestrator.yml index 5d34c3385b..0f29f8d4d1 100644 --- a/.github/workflows/ci-orchestrator.yml +++ b/.github/workflows/ci-orchestrator.yml @@ -293,6 +293,11 @@ jobs: done: name: ๐Ÿ Finish pipeline needs: + - wait-fork-prereq-run + - fork-prereqs-ready + - build-ci-images + - images-mirror-missing + - test-dns - test-install-make - test-install-pkgmgr - test-deploy-single-node-priority diff --git a/scripts/github/resolve/effective_whitelist.sh b/scripts/github/resolve/effective_whitelist.sh index be445b3c55..256481238f 100755 --- a/scripts/github/resolve/effective_whitelist.sh +++ b/scripts/github/resolve/effective_whitelist.sh @@ -38,6 +38,15 @@ fi shopt -u nocasematch if [[ -n "${input_trimmed}" ]]; then + unknown=() + for role in ${input}; do + [[ -d "roles/${role}" ]] || unknown+=("${role}") + done + if ((${#unknown[@]})); then + echo "[ERROR] Unknown role id(s) in the caller-supplied whitelist: ${unknown[*]}" >&2 + echo "[ERROR] They match no directory under roles/, so they would be dropped silently and never deployed." >&2 + exit 1 + fi printf 'whitelist=%s\n' "${input}" >>"${GITHUB_OUTPUT}" echo "Using caller-supplied whitelist: ${input}" exit 0 From 914d90cd8dc89c6315e1ce1c19a44be1fe70b439 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 12:08:00 +0200 Subject: [PATCH 16/24] fix(swarm): make the forced recreation survive a paused update and prove it worked The new handler had neither retries nor an assertion. update_config carries no failure_action, so Docker's default is pause: one flapping healthcheck inside the monitor window pauses the rolling update, docker service update exits non-zero, pipefail propagates it and any_errors_fatal kills the whole deploy - where the neighbouring converge handler would have retried. Give it five attempts fifteen seconds apart, matching how the registry-sync handler beside it already treats a transient action. The registration probe only notified; nothing checked that the recreation actually registered the plugin. A plugin that can never register - corrupt pnpm tree, engine mismatch, invalid library code - therefore reported changed on every run, recreated the stack every time and still left the play green. Flush the recreation, re-resolve the container id the recreation invalidated, and assert the registration with a bounded wait, so that case fails loudly in the run where it happens instead of churning forever in silence. Co-Authored-By: Claude Opus 5 (1M context) --- roles/sys-svc-compose/handlers/swarm.yml | 3 +++ roles/web-app-peertube/tasks/oidc/enable.yml | 21 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/roles/sys-svc-compose/handlers/swarm.yml b/roles/sys-svc-compose/handlers/swarm.yml index 88a7ef0bf1..4b630d0b10 100644 --- a/roles/sys-svc-compose/handlers/swarm.yml +++ b/roles/sys-svc-compose/handlers/swarm.yml @@ -148,6 +148,9 @@ register: _swarm_force_recreate changed_when: true timeout: "{{ lookup('timeout', 1800) | int }}" + retries: 5 + delay: 15 + until: _swarm_force_recreate is succeeded listen: - container recreate run_once: true diff --git a/roles/web-app-peertube/tasks/oidc/enable.yml b/roles/web-app-peertube/tasks/oidc/enable.yml index 9b71c34119..9953cb8a0b 100644 --- a/roles/web-app-peertube/tasks/oidc/enable.yml +++ b/roles/web-app-peertube/tasks/oidc/enable.yml @@ -80,3 +80,24 @@ changed_when: _peertube_oidc_registered.rc != 0 delegate_to: "{{ PEERTUBE_HOST_NODE }}" notify: container recreate + +- name: "๐Ÿšฟ Flush the container recreation before verifying the OIDC registration" + meta: flush_handlers + +- name: "๐Ÿงญ Re-resolve PeerTube host node + local container id after the recreation" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" + vars: + resolve_service_key: "{{ entity_name }}" + resolve_output_prefix: PEERTUBE + resolve_compose_cid: "{{ PEERTUBE_CONTAINER }}" + +- name: "๐Ÿ›‘ Fail if the running PeerTube has still not registered the OIDC plugin" + shell: | + # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec {{ PEERTUBE_LOCAL_CID | quote }} test -d /data/plugins/data/{{ PEERTUBE_OIDC_PLUGIN }} + register: _peertube_oidc_verified + retries: 30 + delay: 10 + until: _peertube_oidc_verified.rc == 0 + changed_when: false + delegate_to: "{{ PEERTUBE_HOST_NODE }}" From 1d1d47a3353028ecfb7be221f228149820e6dc80 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 12:46:34 +0200 Subject: [PATCH 17/24] fix(act): let the requested distro survive into the nested sweep act merges its --env-file, which defaults to .env, after the workflow-level env block (pkg/runner/run_context.go:84 at v0.2.89) and interpolates only afterwards, so INFINITO_DISTROS: ${{ inputs.distros }} was replaced by the repository's own five-distro list before the expression was ever evaluated. Every workspace leg therefore swept all five distros instead of its own: 25 distro deploys where 5 were intended, which is what walked the five legs into their 345-minute cap. The drill step's name kept rendering the single distro because it is interpolated from the workflow model rather than from the env map, which is why the logs read as a contradiction. Pass INFINITO_DISTROS as an --env pair instead, the layer act itself wins with, where --env beats the env-file because the file only fills keys that are absent (cmd/root.go:364-366). The expression was duplicated across ACT_ENV and ACT_INPUTS, so name it once. Wrapping the assignment for readability required the splitter to tolerate whitespace: inside double quotes the shell removes the backslash-newline but keeps the continuation line's indentation, so every pair after the first arrived with a leading tab. Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 12 ++++++++++-- scripts/tests/deploy/act/workflow.sh | 6 +++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 8fb46bc047..3a03f182f2 100644 --- a/Makefile +++ b/Makefile @@ -682,6 +682,8 @@ swarm-shell: @test -n '$(name)' || { echo 'usage: make swarm-shell name= [node=]'; exit 2; } @SWARM_NAME='$(name)' node='$(node)' bash scripts/tests/deploy/act/shell_node.sh +SWARM_DISTROS = $(or $(distros),$${INFINITO_DISTRO:?}) + .PHONY: swarm-zombie # Run a swarm matrix-app test and leave the cluster alive afterwards for post-mortem inspection. # Param app: matrix application id (e.g. web-app-baserow). @@ -697,11 +699,17 @@ swarm-zombie: install-act @bash scripts/tests/deploy/act/down_act_outer.sh @ACT_RM=false \ ACT_BIND=true \ - ACT_ENV='INFINITO_KEEP_SWARM_NODES=true;INFINITO_APP_DISCOVERY_RUNNER=host;INFINITO_DEPLOY_MODE=swarm;disable=$(disable);SWARM_NAME=$(or $(name),$(app));INFINITO_SWARM_STEP_TIMEOUT_MINUTES=$(or $(step_timeout),690)' \ + ACT_ENV="INFINITO_KEEP_SWARM_NODES=true; \ + INFINITO_APP_DISCOVERY_RUNNER=host; \ + INFINITO_DEPLOY_MODE=swarm; \ + disable=$(disable); \ + SWARM_NAME=$(or $(name),$(app)); \ + INFINITO_SWARM_STEP_TIMEOUT_MINUTES=$(or $(step_timeout),690); \ + INFINITO_DISTROS=$(SWARM_DISTROS)" \ ACT_WORKFLOW=.github/workflows/test-deploy-swarm.yml \ ACT_JOB=swarm \ ACT_MATRIX='apps:$(app);variant:$(or $(variant),0)' \ - ACT_INPUTS="whitelist=$(app) distros=$(or $(distros),$${INFINITO_DISTRO:?})" \ + ACT_INPUTS="whitelist=$(app) distros=$(SWARM_DISTROS)" \ bash scripts/tests/deploy/act/workflow.sh .PHONY: system-purge diff --git a/scripts/tests/deploy/act/workflow.sh b/scripts/tests/deploy/act/workflow.sh index 1e15e4fb69..d503c63617 100755 --- a/scripts/tests/deploy/act/workflow.sh +++ b/scripts/tests/deploy/act/workflow.sh @@ -56,7 +56,11 @@ fi if [[ -n "${ACT_ENV}" ]]; then IFS=';' read -ra _act_env_pairs <<<"${ACT_ENV}" for pair in "${_act_env_pairs[@]}"; do - cmd+=(--env "${pair}") + pair="${pair#"${pair%%[![:space:]]*}"}" + pair="${pair%"${pair##*[![:space:]]}"}" + if [[ -n "${pair}" ]]; then + cmd+=(--env "${pair}") + fi done fi if [[ -n "${ACT_CONTAINER_OPTIONS}" ]]; then From 7062d9c02d246f4913e908a97ce5fc4bf1b0c565 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 12:56:30 +0200 Subject: [PATCH 18/24] fix(test-e2e): report the abort instead of dying on it An aborted Playwright run - watchdog SIGTERM, task timeout - returns a result dict with neither stdout nor rc. The reporting task dereferenced both, so it became the fatal error itself: the log ended on "object of type 'dict' has no attribute 'stdout'" while the real cause, a disk-exhaustion abort with rc 75, sat eight thousand lines earlier. Guard all three reads, fall back to msg for stderr because that is what Ansible fills on an abort, and treat an absent rc as a failure so the run is recorded and named by the aggregate check rather than vanishing behind a template error. The distro sweep's summary also mislabelled the distros it never started: they kept the initial "not reached within the budget" note even when the sweep aborted on a failure with 12214 seconds of budget left, pointing at a budget problem that did not exist. Say what happened instead. Co-Authored-By: Claude Opus 5 (1M context) --- roles/test-e2e-playwright/tasks/02_run_one.yml | 10 ++++++---- scripts/tests/deploy/distros.sh | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/roles/test-e2e-playwright/tasks/02_run_one.yml b/roles/test-e2e-playwright/tasks/02_run_one.yml index f5957b2ba1..fbcc6cb3c2 100644 --- a/roles/test-e2e-playwright/tasks/02_run_one.yml +++ b/roles/test-e2e-playwright/tasks/02_run_one.yml @@ -289,18 +289,20 @@ - name: ๐Ÿ’ฌ Print Playwright output (on failure) ansible.builtin.debug: msg: | + rc: {{ _playwright_run.rc | default('') }} + stdout: - {{ _playwright_run.stdout }} + {{ _playwright_run.stdout | default('') }} stderr: - {{ _playwright_run.stderr }} - when: _playwright_run.rc != 0 + {{ _playwright_run.stderr | default(_playwright_run.msg | default('')) }} + when: (_playwright_run.rc | default(1)) != 0 changed_when: false - name: ๐Ÿ“ Record failure ansible.builtin.set_fact: _playwright_failures: "{{ (_playwright_failures | default([])) + [application_id] }}" - when: _playwright_run.rc != 0 + when: (_playwright_run.rc | default(1)) != 0 - name: ๐Ÿ—„๏ธ Archive Playwright reports for this variant + pass ansible.builtin.script: >- diff --git a/scripts/tests/deploy/distros.sh b/scripts/tests/deploy/distros.sh index 7badec0856..60e09ebb63 100755 --- a/scripts/tests/deploy/distros.sh +++ b/scripts/tests/deploy/distros.sh @@ -144,6 +144,9 @@ for i in "${!distro_arr[@]}"; do statuses[i]="failed" notes[i]="rc=${rc}" skipped=$((skipped + ${#distro_arr[@]} - i - 1)) + for ((j = i + 1; j < ${#distro_arr[@]}; j++)); do + notes[j]="not run: aborted after distro=${distro} failed" + done echo "[ERROR] Run failed for distro=${distro} (rc=${rc})" >&2 render_summary exit "$rc" From eb5d04dcedc5fbf85eb064ba48a15e6e5226a5cf Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 13:06:25 +0200 Subject: [PATCH 19/24] fix(swarm): give the lab topology its per-instance overrides The topology SPOT read default.env only, so every lab took the same subnet no matter what the generated .env said. Two labs on one host therefore collided with "invalid pool request: Pool overlaps", which is what stopped a worktree lab from running beside the primary checkout's. Collect the generated .env first, then default.env, and emit each assignment as := so precedence becomes exported environment, then .env, then default.env. default.env stays the fallback because the workflow sources this file before make dotenv has run, which was the original reason for reading it directly. A line that does not parse as a balanced assignment now aborts naming the line. Sourced as-is it would have become an unconditional assignment and silently defeated the precedence this change exists to establish. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/deploy/swarm/utils/topology/base.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/tests/deploy/swarm/utils/topology/base.sh b/scripts/tests/deploy/swarm/utils/topology/base.sh index 86feb7ee10..4b08270025 100755 --- a/scripts/tests/deploy/swarm/utils/topology/base.sh +++ b/scripts/tests/deploy/swarm/utils/topology/base.sh @@ -2,12 +2,25 @@ # shellcheck disable=SC2034 # variables are consumed by callers that source this file # Naming constants are the SPOT in default.env, shared with the Python harness -# (utils/tests/swarm/*). Read default.env directly (not the generated .env): the -# workflow sources this file before `make dotenv` runs, so .env may not exist yet. +# (utils/tests/swarm/*). _repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../../.." && pwd)" _default_env="${_repo_root}/default.env" + +_swarm_topology_sources() { + if [ -f "${_repo_root}/.env" ]; then + grep -hE '^INFINITO_SWARM_[A-Z0-9_]+=' "${_repo_root}/.env" || [ "$?" -eq 1 ] + fi + grep -hE '^INFINITO_SWARM_[A-Z0-9_]+=' "$_default_env" +} + +_swarm_topology_unparsable="$(_swarm_topology_sources | grep -vE '^[A-Z0-9_]+=([^"]*|"[^"]*")$' || [ "$?" -eq 1 ])" +if [ -n "${_swarm_topology_unparsable}" ]; then + echo "[ERROR] unparsable swarm topology assignment(s): ${_swarm_topology_unparsable}" >&2 + exit 1 +fi + # shellcheck source=/dev/null -source <(grep -E '^INFINITO_SWARM_[A-Z0-9_]+=' "$_default_env") +source <(_swarm_topology_sources | sed -E 's/^([A-Z0-9_]+)="?([^"]*)"?$/: "${\1:=\2}"/') : "${SWARM_NAME:?SWARM_NAME is required (cluster id) - pass name= to the make target}" SWARM_PREFIX="${SWARM_NAME}-" From 5f58fcc77d3beaa72936e9d9957a1445b80545ff Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 13:07:38 +0200 Subject: [PATCH 20/24] style(worktree): drop the explaining exception comment It shipped with the persistent-base change; the constraint it described - git forbids a branch feature beside feature/x, so the nested directories cannot collide - belongs in that commit's message, where it already is. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/system/worktree/lib.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/system/worktree/lib.sh b/scripts/system/worktree/lib.sh index 28b5eba11f..ade34e28e9 100755 --- a/scripts/system/worktree/lib.sh +++ b/scripts/system/worktree/lib.sh @@ -37,7 +37,6 @@ worktree_default_base() { printf '%s/.local/share/worktrees/%s/%s' "${HOME}" "${url%%[:/]*}" "${url#*[:/]}" } -# Exception: the branch keeps its slashes; git already forbids a branch `feature` beside `feature/x`, so the nested dirs cannot collide. worktree_path() { printf '%s/%s' "$2" "$1" } From c93f2583ef81b8e83d38e6237a4fbc9ec2c953ba Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 13:42:18 +0200 Subject: [PATCH 21/24] feat(storage): derive STORAGE_CONSTRAINED from the declared min_storage sum The flag was decided by a fixed free-space threshold (100 GiB by default), which fired on roomy hosts and stayed silent on a 145 GiB CI runner that a single deploy round could fill. It now compares free space on Docker's data root against what the deploy declares it needs: the summed min_storage of the selected applications and their transitive dependencies. The need is variant-aware. utils/storage/constrained.py overlays each app's meta/variants.yml entry before walking the service registry, so a round that trims services down is measured at its own size instead of the base configuration's. Both entry paths pass their per-round variant map: cli/administration/deploy/development/init.py decides inside the round loop (compose and host share this via ci/one.sh -> dedicated.sh), and utils/tests/swarm/matrix.py feeds the decision into the swarm host_vars payload. --threshold-gib is gone; --force-storage-constrained still overrides. Co-Authored-By: Claude Opus 5 (1M context) --- cli/administration/deploy/development/init.py | 32 +++--- .../deploy/development/storage.py | 24 ++-- tests/unit/utils/storage/test_constrained.py | 39 +++++++ utils/storage/constrained.py | 104 ++++++++++++++++++ utils/tests/swarm/matrix.py | 8 +- 5 files changed, 180 insertions(+), 27 deletions(-) create mode 100644 tests/unit/utils/storage/test_constrained.py create mode 100644 utils/storage/constrained.py diff --git a/cli/administration/deploy/development/init.py b/cli/administration/deploy/development/init.py index 07615f0dcf..6b69dd919b 100644 --- a/cli/administration/deploy/development/init.py +++ b/cli/administration/deploy/development/init.py @@ -54,12 +54,6 @@ def add_parser(sub: argparse._SubParsersAction) -> None: help="Comma-separated list of application ids to include (no deps resolution).", ) - p.add_argument( - "--threshold-gib", - type=int, - default=100, - help="Free-space threshold (GiB) below which STORAGE_CONSTRAINED is enabled (default: 100).", - ) p.add_argument( "--force-storage-constrained", choices=["true", "false"], @@ -112,12 +106,11 @@ def handler(args: argparse.Namespace) -> int: raise SystemExit("--vars must be a JSON object") extra_vars = parsed - if args.force_storage_constrained is not None: - storage_constrained = args.force_storage_constrained == "true" - else: - storage_constrained = detect_storage_constrained( - compose, threshold_gib=int(args.threshold_gib) - ) + forced_storage_constrained = ( + None + if args.force_storage_constrained is None + else args.force_storage_constrained == "true" + ) plan = plan_dev_inventory_matrix( roles_dir=str(compose.repo_root / "roles"), @@ -133,6 +126,7 @@ def handler(args: argparse.Namespace) -> int: services_disabled = os.environ.get("disable", "") roles_dir = str(compose.repo_root / "roles") built_includes: dict[str, tuple[str, ...]] = {} + round_storage_constrained: dict[str, bool] = {} for _round_index, inv_dir, round_variants, include_R, _purge_set in plan: if disabled_app_ids: round_overrides = build_services_overrides_for_round( @@ -162,6 +156,12 @@ def handler(args: argparse.Namespace) -> int: ) continue built_includes[inv_dir] = round_include + storage_constrained = ( + forced_storage_constrained + if forced_storage_constrained is not None + else detect_storage_constrained(compose, primary_apps, round_variants) + ) + round_storage_constrained[inv_dir] = storage_constrained spec = DevInventorySpec( inventory_dir=inv_dir, include=round_include, @@ -181,20 +181,20 @@ def handler(args: argparse.Namespace) -> int: print( f">>> Inventory initialized at {inv_dir} " f"(include={','.join(shown)} " - f"storage_constrained={storage_constrained}){suffix}" + f"storage_constrained={round_storage_constrained.get(inv_dir)}){suffix}" ) else: print( f">>> Matrix inventory initialized in {len(plan)} folders " - f"(primary_apps={','.join(primary_apps)} " - f"storage_constrained={storage_constrained}):" + f"(primary_apps={','.join(primary_apps)}):" ) for round_index, inv_dir, round_variants, include_R, _purge_set in plan: non_zero = {a: i for a, i in round_variants.items() if i} shown = built_includes.get(inv_dir, include_R) print( f" [round {round_index}] {inv_dir} " - f"include={','.join(shown)}" + f"include={','.join(shown)} " + f"storage_constrained={round_storage_constrained.get(inv_dir)}" + (f" variants={non_zero}" if non_zero else "") ) return 0 diff --git a/cli/administration/deploy/development/storage.py b/cli/administration/deploy/development/storage.py index 675acd2392..0d97bf1e38 100644 --- a/cli/administration/deploy/development/storage.py +++ b/cli/administration/deploy/development/storage.py @@ -2,20 +2,23 @@ from __future__ import annotations from typing import TYPE_CHECKING +from utils.storage.constrained import is_constrained, required_storage_bytes + if TYPE_CHECKING: from .compose import Compose -def detect_storage_constrained(compose: Compose, *, threshold_gib: int = 100) -> bool: +def detect_storage_constrained( + compose: Compose, app_ids: list[str], variants: dict[str, int] | None = None +) -> bool: """ - Return True if the filesystem that contains DockerRootDir has less than - `threshold_gib` GiB free space. + Return True if the declared storage need of app_ids and their transitive + dependencies, at the given variant overlay, exceeds the free space on the + filesystem holding DockerRootDir. We intentionally measure the DockerRootDir filesystem because this is where images/volumes/build cache usually grow (especially in CI / Docker-in-Docker). """ - threshold_bytes = threshold_gib * 1024 * 1024 * 1024 - cmd = [ "bash", "-lc", @@ -26,8 +29,7 @@ if [ -z "${root}" ]; then root="/var/lib/docker" fi -free="$(df -PB1 "${root}" | awk 'NR==2{print $4}')" -printf "%s\n" "${free}" +df -PB1 "${root}" | awk 'NR==2{print $4}' """, ] @@ -35,10 +37,12 @@ printf "%s\n" "${free}" if r.returncode != 0: return False - txt = (r.stdout or "").strip() try: - free_bytes = int(txt) + free_bytes = int((r.stdout or "").strip()) except ValueError: return False - return free_bytes < threshold_bytes + return is_constrained( + free_bytes=free_bytes, + required_bytes=required_storage_bytes(app_ids, variants), + ) diff --git a/tests/unit/utils/storage/test_constrained.py b/tests/unit/utils/storage/test_constrained.py new file mode 100644 index 0000000000..32088da59a --- /dev/null +++ b/tests/unit/utils/storage/test_constrained.py @@ -0,0 +1,39 @@ +import unittest + +from utils.storage.constrained import is_constrained, required_storage_bytes + +_GIB = 1024**3 + + +class TestIsConstrained(unittest.TestCase): + def test_need_above_free_is_constrained(self): + self.assertTrue(is_constrained(free_bytes=122 * _GIB, required_bytes=381 * _GIB)) + + def test_need_equal_to_free_fits(self): + self.assertFalse(is_constrained(free_bytes=100 * _GIB, required_bytes=100 * _GIB)) + + def test_need_below_free_fits(self): + self.assertFalse(is_constrained(free_bytes=300 * _GIB, required_bytes=113 * _GIB)) + + def test_undeclared_need_never_constrains(self): + self.assertFalse(is_constrained(free_bytes=1, required_bytes=0)) + + +class TestRequiredStorageBytes(unittest.TestCase): + def test_unknown_role_declares_nothing(self): + self.assertEqual(required_storage_bytes(["web-app-does-not-exist"]), 0) + + def test_empty_selection_declares_nothing(self): + self.assertEqual(required_storage_bytes([]), 0) + + def test_a_real_role_declares_a_positive_need(self): + self.assertGreater(required_storage_bytes(["svc-db-mariadb"]), 0) + + def test_dependencies_are_counted_once_across_apps(self): + single = required_storage_bytes(["svc-db-mariadb"]) + twice = required_storage_bytes(["svc-db-mariadb", "svc-db-mariadb"]) + self.assertEqual(single, twice) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/storage/constrained.py b/utils/storage/constrained.py new file mode 100644 index 0000000000..76a821f61e --- /dev/null +++ b/utils/storage/constrained.py @@ -0,0 +1,104 @@ +"""Decide whether a deploy should prefer free storage space over cache. + +The decision compares the free space on the filesystem holding Docker's data +root - where images, volumes and build cache grow - against the storage the +deploy actually declares it needs: the sum of ``min_storage`` over the services +of the applications being deployed, transitive dependencies included. No +threshold constant is involved, so the answer follows the declarations rather +than a guess about disk sizes. +""" + +from __future__ import annotations + +import shutil +import subprocess + +from utils import PROJECT_ROOT +from utils.cache.applications import get_variants +from utils.roles.applications.services.registry import ( + build_service_registry_from_applications, + load_applications_from_roles_dir, +) +from utils.roles.applications.services.resources import ( + aggregate, + collect_role_resources, +) + + +def required_storage_bytes( + app_ids: list[str], variants: dict[str, int] | None = None +) -> int: + """Return the summed min_storage of app_ids and their transitive dependencies. + + Args: + app_ids: applications the deploy installs; dependencies are walked. + variants: per-app variant index whose `meta/variants.yml` overlay decides + which services are enabled. Without it the base configuration counts, + which over-states a deploy that a variant trims down. + + Services that declare no min_storage contribute nothing, so an entirely + undeclared deploy yields 0 and is never treated as constrained. + """ + roles_dir = PROJECT_ROOT / "roles" + applications = load_applications_from_roles_dir(roles_dir) + + if variants: + applications = dict(applications) + available = get_variants(roles_dir=str(roles_dir)) + for app_id, index in variants.items(): + app_variants = available.get(app_id) or [] + if 0 <= index < len(app_variants): + applications[app_id] = app_variants[index] or {} + + service_registry = build_service_registry_from_applications(applications) + + rows: list[dict] = [] + visited: set = set() + for app_id in app_ids: + collect_role_resources( + role_name=app_id, + applications=applications, + service_registry=service_registry, + visited=visited, + rows=rows, + warnings=[], + ) + return aggregate(rows, ["min_storage"]).get("min_storage_bytes") or 0 + + +def is_constrained(*, free_bytes: int, required_bytes: int) -> bool: + """Return True when the declared need does not fit into the free space.""" + return required_bytes > free_bytes + + +def docker_data_root() -> str: + """Return Docker's data root as reported by the local daemon. + + Raises: + RuntimeError: the daemon is unreachable or reports no data root. Guessing + a path here would silently measure the wrong filesystem and report a + roomy host, which is the failure this module exists to prevent. + """ + proc = subprocess.run( + ["docker", "info", "-f", "{{.DockerRootDir}}"], + capture_output=True, + text=True, + check=False, + ) + root = (proc.stdout or "").strip() + if proc.returncode != 0 or not root: + raise RuntimeError( + "cannot determine Docker's data root: " + f"rc={proc.returncode} stderr={(proc.stderr or '').strip()!r}" + ) + return root + + +def host_storage_constrained( + app_ids: list[str], variants: dict[str, int] | None = None +) -> bool: + """Return True when the deploy's declared storage need exceeds the free space.""" + return is_constrained( + free_bytes=shutil.disk_usage(docker_data_root()).free, + required_bytes=required_storage_bytes(app_ids, variants), + ) diff --git a/utils/tests/swarm/matrix.py b/utils/tests/swarm/matrix.py index 8ba705fefa..f58ec0e255 100644 --- a/utils/tests/swarm/matrix.py +++ b/utils/tests/swarm/matrix.py @@ -26,6 +26,7 @@ import subprocess import sys from utils import PROJECT_ROOT +from utils.storage.constrained import host_storage_constrained _SWARM_DIR = PROJECT_ROOT / "scripts" / "tests" / "deploy" / "swarm" _SWARM_SCRIPTS = _SWARM_DIR / "routine" @@ -318,7 +319,12 @@ def main(argv: list[str] | None = None) -> int: flush=True, ) vars_payload = _bake_overrides( - base_overrides={"applications": backup_applications_overrides(providers)}, + base_overrides={ + "applications": backup_applications_overrides(providers), + "STORAGE_CONSTRAINED": host_storage_constrained( + [app_id], round_variants + ), + }, variant_payloads=variant_payloads, ) extras_path = f"{inv_root}/swarm-nfs-extras.yml" From f3963dcbdd5d4e25e6a38cc2502f7ea32abe999d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 15:05:59 +0200 Subject: [PATCH 22/24] test(storage): gate min_storage against the image size it is declared for An over-declared min_storage makes a host look too small: it feeds the storage guard, the runner packer and the sufficient-storage filter, so a deploy that would have fit prunes a cache it did not need to lose. Nothing checked the declared number against reality. The new external test measures what a pull of the service's own image:version actually transfers - config blob plus every layer blob, resolved for linux/amd64 - and fails when min_storage exceeds it by more than 1GB. A service whose data volume genuinely dominates keeps its value by carrying '# nocheck: min-storage-headroom' together with a 'Reason:', the same two-marker shape meta/volumes.yml already uses for nfs: false. Sizes come from the repo's own registry client rather than 'docker buildx imagetools': make test-external runs inside the compose container, which has no docker socket. manifest_transfer_size reuses the existing bearer-challenge flow, keeps the tri-state contract of manifest_exists (an indeterminate registry warns instead of failing), and resolves a multi-platform index to its amd64 manifest - the case a raw jq over '.layers' would miss entirely. Distinct image refs are measured concurrently through resolve_max_fetch_workers(). A min_storage that parses out of the YAML but cannot be located in the file text fails the test, so a declaration can never silently lose its marker resolution. Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/testing/suppression.md | 1 + .../docker/test_min_storage_headroom.py | 219 ++++++++++++++++++ tests/unit/utils/docker/test_registry.py | 77 ++++++ utils/docker/registry.py | 77 ++++++ 4 files changed, 374 insertions(+) create mode 100644 tests/external/update/docker/test_min_storage_headroom.py diff --git a/docs/contributing/actions/testing/suppression.md b/docs/contributing/actions/testing/suppression.md index ea891dcd66..8accc37dca 100644 --- a/docs/contributing/actions/testing/suppression.md +++ b/docs/contributing/actions/testing/suppression.md @@ -91,6 +91,7 @@ The placement rule is per check. The catalog "Position" column uses these labels | `seaweedfs-required` | head (first 30 lines) | [test_database_requires_seaweedfs.py](../../../../tests/integration/roles/meta/services/test_database_requires_seaweedfs.py) | Exempts a database-backed `web-app-*` role from the requirement to declare a seaweedfs object-store service. Place in the head of `roles//meta/services.yml`. Use only when the application genuinely needs no object storage. | | `templated-conditional` | same or above | [test_no_templated_conditionals.py](../../../../tests/lint/ansible/test_no_templated_conditionals.py) | Exempts a `when` / `failed_when` / `changed_when` / `until` value that legitimately carries `{{ }}` / `{% %}`. Reserved for genuine edge cases; the default path is a bare expression. Per-item render/apply data guards belong on a `condition:` data field (honored by the sys-svc-compose render helper and `LISTMONK_SETTINGS`), not `when:`. | | `container-address-quote` | same or above | [test_container_address_never_quoted.py](../../../../tests/lint/ansible/shell/test_container_address_never_quoted.py) | Allows `*_CONTAINER_ADDRESS \| quote` in a role task file. Reserved for sites that never see the swarm `$(...)` fragment (`DEPLOYMENT_MODE == 'compose'`-gated tasks, `compose_mode_force` roles) or consumers that re-evaluate the argument themselves. | +| `min-storage-headroom` | same line OR comment block above key | [test_min_storage_headroom.py](../../../../tests/external/update/docker/test_min_storage_headroom.py) | Allows a `roles/*/meta/services.yml` `min_storage` to exceed the measured compressed size of its own `image:`/`version:` by more than 1 GB. MUST be paired with a `Reason:` on the same comment line naming what occupies the space. Reserved for services whose data volume genuinely dominates the footprint; the default path is to lower the value, because an over-declared budget makes a host look too small and prunes a cache the deploy did not need to lose. | ## Tool-Native `noqa` Codes ๐Ÿงน diff --git a/tests/external/update/docker/test_min_storage_headroom.py b/tests/external/update/docker/test_min_storage_headroom.py new file mode 100644 index 0000000000..9159113553 --- /dev/null +++ b/tests/external/update/docker/test_min_storage_headroom.py @@ -0,0 +1,219 @@ +"""Cap how far a service's ``min_storage`` may exceed its image's real size. + +``min_storage`` is what the storage guard and the runner packer budget a deploy +against, so an over-declared value makes a host look too small and triggers a +cache-destroying prune on a deploy that would have fit. Every declaration that +also names a concrete ``image:`` and ``version:`` therefore stays within 1 GB of +what a pull of that tag actually transfers, or carries an explicit justification +for the higher number. + +The measured size is the manifest's config blob plus every layer blob, resolved +for linux/amd64. It is a lower bound on what the service occupies: the extracted +on-disk footprint exceeds the compressed transfer size, and for a ``custom: true`` +service the declared image is only the Dockerfile base, so the deployed image +carries further layers. The gate therefore only ever asks for more justification +than a perfect measurement would, never less. + +Opt-in external test: it reads live manifests from third-party registries and +runs only under the external suite. It fails on a positively measured +over-declaration; a registry that answers indeterminately (network error, auth +wall, rate limit) warns instead, so a private or throttled registry never turns +into a false failure. + +Convention +========== +On the ``min_storage`` line, or on the contiguous comment lines directly above +it, add both markers on one comment line: + + # nocheck: min-storage-headroom Reason: + min_storage: 20GB + +The ``nocheck`` opts into the exemption; the ``Reason:`` states what the value +budgets for. Both are required, in either order. +""" + +from __future__ import annotations + +import concurrent.futures +import re +import unittest +from dataclasses import dataclass + +from utils.annotations.message import warning +from utils.annotations.suppress import line_has_rule +from utils.cache.files import read_text +from utils.docker.image.discovery import load_yaml +from utils.docker.registry import manifest_transfer_size +from utils.roles.applications.services.resources import _parse_mem_bytes +from utils.roles.mapping import ROLE_FILE_META_SERVICES +from utils.update.base import resolve_max_fetch_workers + +from . import PROJECT_ROOT + +_RULE = "min-storage-headroom" +_HEADROOM_BYTES = 1_000_000_000 +_GIB = 1024**3 + +_TOP_LEVEL_KEY = re.compile(r"^([A-Za-z0-9][A-Za-z0-9_.-]*):") +_MIN_STORAGE_KEY = re.compile(r"^\s+min_storage:") +_REASON = re.compile(r"#.*\breason\b\s*:\s*\S", re.IGNORECASE) + + +@dataclass(frozen=True) +class _Declaration: + role: str + service: str + raw: object + line: int + image: str + version: str + + +def _min_storage_lines(lines: list[str]) -> dict[str, int]: + """Map each top-level service key to the 1-based line of its ``min_storage``.""" + found: dict[str, int] = {} + current: str | None = None + for line_no, line in enumerate(lines, start=1): + key = _TOP_LEVEL_KEY.match(line) + if key: + current = key.group(1) + continue + if current and _MIN_STORAGE_KEY.match(line): + found.setdefault(current, line_no) + return found + + +def _declarations(role_name: str) -> tuple[list[_Declaration], list[str]]: + path = PROJECT_ROOT / "roles" / role_name / ROLE_FILE_META_SERVICES + if not path.is_file(): + return [], [] + services = load_yaml(path) + if not isinstance(services, dict): + return [], [] + lines = read_text(str(path)).splitlines() + line_of = _min_storage_lines(lines) + out = [ + _Declaration( + role=role_name, + service=str(service), + raw=cfg["min_storage"], + line=line_of.get(str(service), 0), + image=str(cfg.get("image") or "").strip(), + version=str(cfg.get("version") or "").strip(), + ) + for service, cfg in services.items() + if isinstance(cfg, dict) and "min_storage" in cfg + ] + return out, lines + + +def _justified(lines: list[str], line_no: int) -> bool: + """Whether the declaration at 1-based *line_no* carries marker and reason. + + Scanned on that line and across the contiguous comment lines directly above + it; the order of the two markers within that block does not matter. + """ + idx = line_no - 1 + if idx < 0 or idx >= len(lines): + return False + has_rule = line_has_rule(lines[idx], _RULE) + has_reason = bool(_REASON.search(lines[idx])) + scan = idx - 1 + while scan >= 0 and lines[scan].lstrip().startswith("#"): + has_rule = has_rule or line_has_rule(lines[scan], _RULE) + has_reason = has_reason or bool(_REASON.search(lines[scan])) + scan -= 1 + return has_rule and has_reason + + +def _measure(pairs: set[tuple[str, str]]) -> dict[tuple[str, str], int | None]: + def _one(pair: tuple[str, str]) -> tuple[tuple[str, str], int | None]: + image, version = pair + return pair, manifest_transfer_size(image, version) + + with concurrent.futures.ThreadPoolExecutor( + max_workers=resolve_max_fetch_workers() + ) as pool: + return dict(pool.map(_one, sorted(pairs))) + + +class TestMinStorageHeadroom(unittest.TestCase): + def test_min_storage_stays_within_image_size_plus_headroom(self) -> None: + roles_dir = PROJECT_ROOT / "roles" + declarations: list[_Declaration] = [] + lines_of_role: dict[str, list[str]] = {} + for role_dir in sorted(p for p in roles_dir.iterdir() if p.is_dir()): + role_declarations, lines = _declarations(role_dir.name) + if role_declarations: + declarations.extend(role_declarations) + lines_of_role[role_dir.name] = lines + self.assertTrue(declarations, "No min_storage declarations discovered") + + unlocated = [d for d in declarations if d.line == 0] + self.assertFalse( + unlocated, + "min_storage declared in the parsed YAML but not locatable in the file " + "text, so no suppression marker could be resolved for it:\n" + + "\n".join(f" {d.role}/{d.service}" for d in sorted(unlocated, key=str)), + ) + + candidates = [d for d in declarations if d.image and d.version] + sizes = _measure({(d.image, d.version) for d in candidates}) + + offenders: list[tuple[_Declaration, int, int]] = [] + for d in sorted(candidates, key=lambda c: (c.role, c.service)): + source = f"roles/{d.role}/{ROLE_FILE_META_SERVICES}" + need = _parse_mem_bytes(d.raw) + if need is None: + warning( + f"{d.role}/{d.service}: min_storage {d.raw!r} is not a parsable " + "size, so it counts as nothing in every storage budget", + title="๐Ÿ“ Unparsable min_storage", + file=source, + ) + continue + size = sizes.get((d.image, d.version)) + if size is None: + warning( + f"{d.role}/{d.service}: {d.image}:{d.version} manifest size " + "could not be read (network / auth / rate-limit)", + title="๐Ÿ” Unmeasured image size", + file=source, + ) + continue + justified = _justified(lines_of_role[d.role], d.line) + if need <= size + _HEADROOM_BYTES: + if justified: + warning( + f"{d.role}/{d.service}: min_storage {d.raw} now fits within " + f"{size / _GIB:.3f} GiB + 1 GB, so the " + f"`{_RULE}` exemption is stale and can go", + title="๐Ÿงน Stale min_storage exemption", + file=source, + ) + continue + if not justified: + offenders.append((d, need, size)) + + if offenders: + formatted = "\n".join( + f" roles/{d.role}/{ROLE_FILE_META_SERVICES}:{d.line}: " + f"{d.service} declares {d.raw} but {d.image}:{d.version} transfers " + f"{size / _GIB:.3f} GiB โ€” {(need - size - _HEADROOM_BYTES) / _GIB:.3f} " + "GiB above image + 1 GB" + for d, need, size in offenders + ) + self.fail( + "These services declare more than 1 GB above the measured size of " + "their own image. An over-declared min_storage makes a host look " + "too small and prunes a cache the deploy did not need to lose.\n\n" + "Fix: lower the value, or state what occupies the space by adding " + "both markers on one comment line above the key:\n\n" + f" # nocheck: {_RULE} Reason: \n" + " min_storage: 20GB\n\n" + f"Offenders:\n{formatted}" + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/unit/utils/docker/test_registry.py b/tests/unit/utils/docker/test_registry.py index c6a871305d..743ec7b30c 100644 --- a/tests/unit/utils/docker/test_registry.py +++ b/tests/unit/utils/docker/test_registry.py @@ -137,5 +137,82 @@ class TestFetchRegistryTags(unittest.TestCase): ) +_SINGLE = { + "config": {"size": 100}, + "layers": [{"size": 900}, {"size": 1_000}], +} +_INDEX = { + "manifests": [ + {"digest": "sha256:arm", "platform": {"os": "linux", "architecture": "arm64"}}, + {"digest": "sha256:amd", "platform": {"os": "linux", "architecture": "amd64"}}, + { + "digest": "sha256:att", + "platform": {"os": "unknown", "architecture": "unknown"}, + }, + ] +} + + +class TestManifestTransferSize(unittest.TestCase): + def test_single_manifest_sums_config_and_layers(self) -> None: + with mock.patch.object(registry, "fetch_manifest", return_value=_SINGLE): + self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000) + + def test_index_resolves_the_requested_platform(self) -> None: + with mock.patch.object( + registry, "fetch_manifest", side_effect=[_INDEX, _SINGLE] + ) as fetched: + self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000) + self.assertEqual(fetched.call_args_list[1].args, ("img", "sha256:amd")) + + def test_index_without_the_platform_is_indeterminate(self) -> None: + with mock.patch.object(registry, "fetch_manifest", return_value=_INDEX): + self.assertIsNone( + registry.manifest_transfer_size("img", "1", architecture="riscv64") + ) + + def test_missing_config_size_counts_layers_only(self) -> None: + with mock.patch.object( + registry, "fetch_manifest", return_value={"layers": [{"size": 7}]} + ): + self.assertEqual(registry.manifest_transfer_size("img", "1"), 7) + + def test_layerless_manifest_is_indeterminate(self) -> None: + with mock.patch.object(registry, "fetch_manifest", return_value={"config": {}}): + self.assertIsNone(registry.manifest_transfer_size("img", "1")) + + def test_unreachable_manifest_is_indeterminate(self) -> None: + with mock.patch.object(registry, "fetch_manifest", return_value=None): + self.assertIsNone(registry.manifest_transfer_size("img", "1")) + + def test_nested_index_is_indeterminate(self) -> None: + with mock.patch.object( + registry, "fetch_manifest", side_effect=[_INDEX, _INDEX] + ): + self.assertIsNone(registry.manifest_transfer_size("img", "1")) + + +class TestFetchManifest(unittest.TestCase): + def test_non_dict_body_is_indeterminate(self) -> None: + with mock.patch.object( + registry.urllib.request, "urlopen", return_value=_Resp(body=b"[]") + ): + self.assertIsNone(registry.fetch_manifest("img", "1")) + + def test_rate_limited_is_indeterminate(self) -> None: + with mock.patch.object( + registry.urllib.request, "urlopen", side_effect=_http_error(429) + ): + self.assertIsNone(registry.fetch_manifest("img", "1")) + + def test_present_manifest_is_parsed(self) -> None: + with mock.patch.object( + registry.urllib.request, + "urlopen", + return_value=_Resp(body=b'{"layers": []}'), + ): + self.assertEqual(registry.fetch_manifest("img", "1"), {"layers": []}) + + if __name__ == "__main__": unittest.main() diff --git a/utils/docker/registry.py b/utils/docker/registry.py index 0eac55c358..0c3e5f297c 100644 --- a/utils/docker/registry.py +++ b/utils/docker/registry.py @@ -149,6 +149,83 @@ def fetch_registry_tags( return tags +def fetch_manifest(image: str, reference: str) -> dict | None: + """Return the parsed manifest (or index) for ``image:reference``. + + ``None`` on any indeterminate outcome: unresolvable name, network error, + auth wall, rate limit, non-200 status, or unparsable body. + """ + resolved = _resolve(image) + if resolved is None: + return None + host, repo = resolved + url = f"https://{host}/v2/{quote(repo, safe='/')}/manifests/{quote(reference, safe='')}" + result = _request(url, repo, method="GET", accept=_MANIFEST_ACCEPT) + if result is None: + return None + status, _resp_headers, body = result + if status != 200 or not body: + return None + try: + parsed = json.loads(body.decode()) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return parsed if isinstance(parsed, dict) else None + + +def _platform_digest(manifests: list, os_name: str, architecture: str) -> str | None: + for entry in manifests: + if not isinstance(entry, dict): + continue + platform = entry.get("platform") or {} + if ( + platform.get("os") == os_name + and platform.get("architecture") == architecture + ): + digest = entry.get("digest") + if isinstance(digest, str) and digest: + return digest + return None + + +def manifest_transfer_size( + image: str, + reference: str, + *, + os_name: str = "linux", + architecture: str = "amd64", +) -> int | None: + """Return the compressed transfer size of ``image:reference`` in bytes. + + The size is the config blob plus every layer blob, i.e. what a pull moves + over the wire. A multi-platform index is resolved to the + ``os_name``/``architecture`` manifest first. + + ``None`` on any indeterminate outcome (see :func:`fetch_manifest`), on an + index without a matching platform, and on a manifest that carries no layers. + """ + doc = fetch_manifest(image, reference) + if doc is None: + return None + manifests = doc.get("manifests") + if isinstance(manifests, list) and manifests: + digest = _platform_digest(manifests, os_name, architecture) + if digest is None: + return None + doc = fetch_manifest(image, digest) + if doc is None or doc.get("manifests"): + return None + layers = doc.get("layers") + if not isinstance(layers, list) or not layers: + return None + total = int((doc.get("config") or {}).get("size") or 0) + for layer in layers: + if not isinstance(layer, dict): + return None + total += int(layer.get("size") or 0) + return total + + def manifest_exists(image: str, reference: str) -> bool | None: """Whether ``image:reference`` resolves to a manifest. From 028f4b85f46d2b07b85e70930222a685d724fc3a Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 15:07:23 +0200 Subject: [PATCH 23/24] chore(storage): set every image-backed min_storage to its image size plus 200MB The declared values were guesses that ran up to 60x the size of the image they belong to: seaweedfs asked for 30GB against a 104MB image, nextcloud for 20GB against 353MB. Because min_storage feeds the storage guard, the runner packer and the sufficient-storage filter, those numbers made hosts look too small and pruned caches that deploys did not need to lose. Every service that declares min_storage together with image and version now carries the measured compressed transfer size of that image plus 200MB of headroom. Sizes were read from the registries through utils.docker.registry.manifest_transfer_size, the same path the headroom test uses, so the declarations and the gate agree by construction. Values are written in MB throughout, matching the existing 500MB / 100MB / 50MB style. 119 declarations across 89 roles. Range: 206MB (memcached) to 3473MB (ollama); four exceed 1GB. The 84 min_storage entries that name no image are untouched, since there is nothing to measure them against. Co-Authored-By: Claude Opus 5 (1M context) --- roles/svc-ai-ollama/meta/services.yml | 2 +- roles/svc-db-elasticsearch/meta/services.yml | 2 +- roles/svc-db-mariadb/meta/services.yml | 2 +- roles/svc-db-memcached/meta/services.yml | 2 +- roles/svc-db-openldap/meta/services.yml | 2 +- roles/svc-db-postgres/meta/services.yml | 2 +- roles/svc-db-qdrant/meta/services.yml | 2 +- roles/svc-db-rabbitmq/meta/services.yml | 2 +- roles/svc-db-redis/meta/services.yml | 2 +- roles/svc-db-typesense/meta/services.yml | 2 +- roles/svc-dns-unbound/meta/services.yml | 2 +- roles/svc-prx-openresty/meta/services.yml | 2 +- roles/svc-registry-cache/meta/services.yml | 2 +- roles/svc-registry-docker/meta/services.yml | 2 +- roles/web-app-akaunting/meta/services.yml | 2 +- roles/web-app-baserow/meta/services.yml | 2 +- roles/web-app-bluesky/meta/services.yml | 4 ++-- roles/web-app-bookwyrm/meta/services.yml | 2 +- roles/web-app-bridgy-fed/meta/services.yml | 2 +- roles/web-app-checkmk/meta/services.yml | 2 +- roles/web-app-chess/meta/services.yml | 2 +- roles/web-app-confluence/meta/services.yml | 2 +- roles/web-app-dashboard/meta/services.yml | 2 +- roles/web-app-decidim/meta/services.yml | 2 +- roles/web-app-erpnext/meta/services.yml | 2 +- roles/web-app-espocrm/meta/services.yml | 2 +- roles/web-app-fediwall/meta/services.yml | 2 +- roles/web-app-fider/meta/services.yml | 2 +- roles/web-app-flowise/meta/services.yml | 4 ++-- roles/web-app-friendica/meta/services.yml | 2 +- roles/web-app-funkwhale/meta/services.yml | 6 +++--- roles/web-app-fusiondirectory/meta/services.yml | 2 +- roles/web-app-gitea/meta/services.yml | 2 +- roles/web-app-hugo/meta/services.yml | 2 +- roles/web-app-jellyfin/meta/services.yml | 2 +- roles/web-app-jenkins/meta/services.yml | 2 +- roles/web-app-jira/meta/services.yml | 2 +- roles/web-app-jitsi/meta/services.yml | 2 +- roles/web-app-joomla/meta/services.yml | 2 +- roles/web-app-keycloak/meta/services.yml | 4 ++-- roles/web-app-kix/meta/services.yml | 6 +++--- roles/web-app-lam/meta/services.yml | 2 +- roles/web-app-listmonk/meta/services.yml | 2 +- roles/web-app-littlejs/meta/services.yml | 2 +- roles/web-app-magento/meta/services.yml | 6 +++--- roles/web-app-mastodon/meta/services.yml | 4 ++-- roles/web-app-matomo/meta/services.yml | 4 ++-- roles/web-app-matrix/meta/services.yml | 6 +++--- roles/web-app-mattermost/meta/services.yml | 2 +- roles/web-app-mediawiki/meta/services.yml | 2 +- roles/web-app-mig/meta/services.yml | 2 +- roles/web-app-mini-qr/meta/services.yml | 2 +- roles/web-app-minio/meta/services.yml | 2 +- roles/web-app-mobilizon/meta/services.yml | 2 +- roles/web-app-moodle/meta/services.yml | 4 ++-- roles/web-app-n8n/meta/services.yml | 2 +- roles/web-app-nextcloud/meta/services.yml | 10 +++++----- roles/web-app-odoo/meta/services.yml | 2 +- roles/web-app-opencloud/meta/services.yml | 2 +- roles/web-app-openproject/meta/services.yml | 2 +- roles/web-app-opentalk/meta/services.yml | 10 +++++----- roles/web-app-openwebui/meta/services.yml | 2 +- roles/web-app-peertube/meta/services.yml | 2 +- roles/web-app-pgadmin/meta/services.yml | 2 +- roles/web-app-phpldapadmin/meta/services.yml | 2 +- roles/web-app-phpmyadmin/meta/services.yml | 2 +- roles/web-app-pihole/meta/services.yml | 2 +- roles/web-app-pixelfed/meta/services.yml | 2 +- roles/web-app-postmarks/meta/services.yml | 2 +- roles/web-app-pretix/meta/services.yml | 2 +- roles/web-app-prometheus/meta/services.yml | 10 +++++----- roles/web-app-roulette-wheel/meta/services.yml | 2 +- roles/web-app-seaweedfs/meta/services.yml | 2 +- roles/web-app-semaphore/meta/services.yml | 2 +- roles/web-app-shopware/meta/services.yml | 8 ++++---- roles/web-app-snipe-it/meta/services.yml | 2 +- roles/web-app-sphinx/meta/services.yml | 2 +- roles/web-app-suitecrm/meta/services.yml | 2 +- roles/web-app-wordpress/meta/services.yml | 2 +- roles/web-app-xwiki/meta/services.yml | 2 +- roles/web-app-yourls/meta/services.yml | 2 +- roles/web-app-zammad/meta/services.yml | 4 ++-- roles/web-svc-collabora/meta/services.yml | 2 +- roles/web-svc-coturn/meta/services.yml | 2 +- roles/web-svc-libretranslate/meta/services.yml | 2 +- roles/web-svc-logout/meta/services.yml | 2 +- roles/web-svc-onlyoffice/meta/services.yml | 2 +- roles/web-svc-simpleicons/meta/services.yml | 2 +- roles/web-svc-xmpp/meta/services.yml | 2 +- 89 files changed, 119 insertions(+), 119 deletions(-) diff --git a/roles/svc-ai-ollama/meta/services.yml b/roles/svc-ai-ollama/meta/services.yml index 19471aba42..138b8adaee 100644 --- a/roles/svc-ai-ollama/meta/services.yml +++ b/roles/svc-ai-ollama/meta/services.yml @@ -13,7 +13,7 @@ ollama: image: ollama/ollama version: 0.31.2 name: ollama - min_storage: 20GB + min_storage: 3473MB cpus: "4.0" mem_reservation: 6g mem_limit: 8g diff --git a/roles/svc-db-elasticsearch/meta/services.yml b/roles/svc-db-elasticsearch/meta/services.yml index 43cb7592e9..f2b6ef2303 100644 --- a/roles/svc-db-elasticsearch/meta/services.yml +++ b/roles/svc-db-elasticsearch/meta/services.yml @@ -13,7 +13,7 @@ elasticsearch: image: elasticsearch name: elasticsearch version: "9.4.3" - min_storage: 10GB + min_storage: 1066MB backup: database_routine: true cpus: "2.0" diff --git a/roles/svc-db-mariadb/meta/services.yml b/roles/svc-db-mariadb/meta/services.yml index ef13b9e786..a5024d0d96 100644 --- a/roles/svc-db-mariadb/meta/services.yml +++ b/roles/svc-db-mariadb/meta/services.yml @@ -14,7 +14,7 @@ mariadb: custom: true image: mariadb name: mariadb - min_storage: 10GB + min_storage: 308MB backup: database_routine: true cpus: "2.0" diff --git a/roles/svc-db-memcached/meta/services.yml b/roles/svc-db-memcached/meta/services.yml index 50fed498b5..d9914eb027 100644 --- a/roles/svc-db-memcached/meta/services.yml +++ b/roles/svc-db-memcached/meta/services.yml @@ -13,7 +13,7 @@ memcached: image: memcached version: alpine name: memcached - min_storage: 1GB + min_storage: 206MB backup: disabled: true cpus: "0.25" diff --git a/roles/svc-db-openldap/meta/services.yml b/roles/svc-db-openldap/meta/services.yml index d4640af508..565938e6ef 100644 --- a/roles/svc-db-openldap/meta/services.yml +++ b/roles/svc-db-openldap/meta/services.yml @@ -15,7 +15,7 @@ openldap: version: bookworm-slim custom: true name: openldap - min_storage: 2GB + min_storage: 229MB cpus: 1.25 mem_reservation: 1g mem_limit: 1.5g diff --git a/roles/svc-db-postgres/meta/services.yml b/roles/svc-db-postgres/meta/services.yml index 16b92944c5..1ebd2963c5 100644 --- a/roles/svc-db-postgres/meta/services.yml +++ b/roles/svc-db-postgres/meta/services.yml @@ -14,7 +14,7 @@ postgres: name: postgres version: 17-3.5 custom: true - min_storage: 10GB + min_storage: 419MB backup: database_routine: true cpus: "2.0" diff --git a/roles/svc-db-qdrant/meta/services.yml b/roles/svc-db-qdrant/meta/services.yml index 1c96e62789..51d270a6a8 100644 --- a/roles/svc-db-qdrant/meta/services.yml +++ b/roles/svc-db-qdrant/meta/services.yml @@ -13,7 +13,7 @@ qdrant: image: qdrant/qdrant name: qdrant version: v1.18.2 - min_storage: 5GB + min_storage: 270MB backup: database_routine: true cpus: "2.0" diff --git a/roles/svc-db-rabbitmq/meta/services.yml b/roles/svc-db-rabbitmq/meta/services.yml index 86f72a54f0..7df268a63d 100644 --- a/roles/svc-db-rabbitmq/meta/services.yml +++ b/roles/svc-db-rabbitmq/meta/services.yml @@ -13,7 +13,7 @@ rabbitmq: image: rabbitmq name: rabbitmq version: "4.3-management-alpine" - min_storage: 5GB + min_storage: 289MB backup: database_routine: true cpus: "1.0" diff --git a/roles/svc-db-redis/meta/services.yml b/roles/svc-db-redis/meta/services.yml index 97c3b85ee5..a55ff18f78 100644 --- a/roles/svc-db-redis/meta/services.yml +++ b/roles/svc-db-redis/meta/services.yml @@ -13,7 +13,7 @@ redis: image: redis name: redis-central version: alpine - min_storage: 1GB + min_storage: 238MB backup: disabled: true cpus: "0.5" diff --git a/roles/svc-db-typesense/meta/services.yml b/roles/svc-db-typesense/meta/services.yml index 21e0ee0818..3b567e550a 100644 --- a/roles/svc-db-typesense/meta/services.yml +++ b/roles/svc-db-typesense/meta/services.yml @@ -13,7 +13,7 @@ typesense: image: typesense/typesense name: typesense version: 0.25.2 - min_storage: 5GB + min_storage: 428MB backup: database_routine: true cpus: "1.0" diff --git a/roles/svc-dns-unbound/meta/services.yml b/roles/svc-dns-unbound/meta/services.yml index 774e30521c..a0b122a573 100644 --- a/roles/svc-dns-unbound/meta/services.yml +++ b/roles/svc-dns-unbound/meta/services.yml @@ -13,7 +13,7 @@ unbound: image: ghcr.io/mailu/unbound name: unbound version: "2024.06" - min_storage: 1GB + min_storage: 283MB backup: disabled: true cpus: "0.5" diff --git a/roles/svc-prx-openresty/meta/services.yml b/roles/svc-prx-openresty/meta/services.yml index b6fd0ff2c0..2f89e13f3a 100644 --- a/roles/svc-prx-openresty/meta/services.yml +++ b/roles/svc-prx-openresty/meta/services.yml @@ -11,7 +11,7 @@ openresty: enabled: true shared: true name: openresty - min_storage: 1GB + min_storage: 264MB backup: disabled: true cpus: 0.5 diff --git a/roles/svc-registry-cache/meta/services.yml b/roles/svc-registry-cache/meta/services.yml index 284355ffb4..fcbd2ceceb 100644 --- a/roles/svc-registry-cache/meta/services.yml +++ b/roles/svc-registry-cache/meta/services.yml @@ -18,7 +18,7 @@ cache: image: registry version: "3" name: cache-registry - min_storage: 10GB + min_storage: 221MB backup: disabled: true cpus: "0.5" diff --git a/roles/svc-registry-docker/meta/services.yml b/roles/svc-registry-docker/meta/services.yml index 9ca815ed12..1340c5d7d9 100644 --- a/roles/svc-registry-docker/meta/services.yml +++ b/roles/svc-registry-docker/meta/services.yml @@ -18,7 +18,7 @@ docker: image: registry version: "3" name: registry - min_storage: 5GB + min_storage: 221MB backup: disabled: true cpus: "0.5" diff --git a/roles/web-app-akaunting/meta/services.yml b/roles/web-app-akaunting/meta/services.yml index a86fe128ac..0f4aaf30eb 100644 --- a/roles/web-app-akaunting/meta/services.yml +++ b/roles/web-app-akaunting/meta/services.yml @@ -48,7 +48,7 @@ akaunting: repository: https://github.com/akaunting/docker.git ref: master name: akaunting - min_storage: 8GB + min_storage: 527MB company: name: Akaunting on {{ DOMAIN_PRIMARY | upper }} email: "{{ lookup('users', 'administrator').email }}" diff --git a/roles/web-app-baserow/meta/services.yml b/roles/web-app-baserow/meta/services.yml index 4a679ad05b..419ac0d9a7 100644 --- a/roles/web-app-baserow/meta/services.yml +++ b/roles/web-app-baserow/meta/services.yml @@ -65,7 +65,7 @@ baserow: version: 2.3.1 custom: true name: baserow - min_storage: 8GB + min_storage: 785MB cpus: 1.0 mem_reservation: 0.5g mem_limit: 2g diff --git a/roles/web-app-bluesky/meta/services.yml b/roles/web-app-bluesky/meta/services.yml index 04f7a92a05..5252ea8198 100644 --- a/roles/web-app-bluesky/meta/services.yml +++ b/roles/web-app-bluesky/meta/services.yml @@ -74,7 +74,7 @@ view: pds: image: ghcr.io/bluesky-social/pds version: 0.4.0 - min_storage: 10GB + min_storage: 290MB cpus: "1.0" mem_reservation: 1g mem_limit: 2g @@ -88,7 +88,7 @@ broker: http: 8076 internal: http: 8080 - min_storage: 1GB + min_storage: 263MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-bookwyrm/meta/services.yml b/roles/web-app-bookwyrm/meta/services.yml index 0b397d09c7..164da36762 100644 --- a/roles/web-app-bookwyrm/meta/services.yml +++ b/roles/web-app-bookwyrm/meta/services.yml @@ -60,7 +60,7 @@ bookwyrm: custom: true repository: https://github.com/bookwyrm-social/bookwyrm.git ref: v0.9.1 - min_storage: 15GB + min_storage: 579MB registration_open: false allow_invite_request: false ports: diff --git a/roles/web-app-bridgy-fed/meta/services.yml b/roles/web-app-bridgy-fed/meta/services.yml index 0cba6f7bd5..2d84afa24b 100644 --- a/roles/web-app-bridgy-fed/meta/services.yml +++ b/roles/web-app-bridgy-fed/meta/services.yml @@ -55,7 +55,7 @@ bridgy-fed: internal: http: 8080 lifecycle: beta - min_storage: 1GB + min_storage: 585MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-checkmk/meta/services.yml b/roles/web-app-checkmk/meta/services.yml index 7c1123ee08..c921826624 100644 --- a/roles/web-app-checkmk/meta/services.yml +++ b/roles/web-app-checkmk/meta/services.yml @@ -61,7 +61,7 @@ checkmk: mem_reservation: 1g mem_limit: 2g pids_limit: 2048 - min_storage: 10GB + min_storage: 1053MB image: checkmk/check-mk-raw version: "2.4.0p32" backup: diff --git a/roles/web-app-chess/meta/services.yml b/roles/web-app-chess/meta/services.yml index 3fbbea5cab..3ef174b35e 100644 --- a/roles/web-app-chess/meta/services.yml +++ b/roles/web-app-chess/meta/services.yml @@ -31,7 +31,7 @@ chess: image: ghcr.io/kevinveenbirkenbach/fedi-chess version: v1.0.1 name: web-app-chess - min_storage: 5GB + min_storage: 633MB backup: no_stop_required: true cpus: 0.5 diff --git a/roles/web-app-confluence/meta/services.yml b/roles/web-app-confluence/meta/services.yml index 2f083efd37..dd154315b5 100644 --- a/roles/web-app-confluence/meta/services.yml +++ b/roles/web-app-confluence/meta/services.yml @@ -37,7 +37,7 @@ confluence: version: 10.2.14 custom: true name: confluence - min_storage: 20GB + min_storage: 1390MB cpus: "3.0" mem_reservation: 6g mem_limit: 8g diff --git a/roles/web-app-dashboard/meta/services.yml b/roles/web-app-dashboard/meta/services.yml index 713368b299..39a6907db2 100644 --- a/roles/web-app-dashboard/meta/services.yml +++ b/roles/web-app-dashboard/meta/services.yml @@ -38,7 +38,7 @@ dashboard: name: dashboard image: ghcr.io/kevinveenbirkenbach/port-ui version: 2.0.0 - min_storage: 1GB + min_storage: 549MB cpus: "0.3" mem_reservation: 256m mem_limit: 512m diff --git a/roles/web-app-decidim/meta/services.yml b/roles/web-app-decidim/meta/services.yml index 832f708da2..f6324c8743 100644 --- a/roles/web-app-decidim/meta/services.yml +++ b/roles/web-app-decidim/meta/services.yml @@ -34,7 +34,7 @@ decidim: enabled: true image: ghcr.io/decidim/decidim version: 0.31.6 - min_storage: 10GB + min_storage: 1206MB name: decidim cpus: "1.0" mem_reservation: 512m diff --git a/roles/web-app-erpnext/meta/services.yml b/roles/web-app-erpnext/meta/services.yml index 5cf81c5767..d1a9dc641f 100644 --- a/roles/web-app-erpnext/meta/services.yml +++ b/roles/web-app-erpnext/meta/services.yml @@ -61,7 +61,7 @@ erpnext: image: frappe/erpnext version: "v16.26.2" name: erpnext - min_storage: 15GB + min_storage: 1017MB ports: local: http: 8079 diff --git a/roles/web-app-espocrm/meta/services.yml b/roles/web-app-espocrm/meta/services.yml index 427b41768c..de55ff798d 100644 --- a/roles/web-app-espocrm/meta/services.yml +++ b/roles/web-app-espocrm/meta/services.yml @@ -39,7 +39,7 @@ espocrm: version: "10.0.2" custom: true name: espocrm - min_storage: 8GB + min_storage: 470MB cpus: 1.5 mem_reservation: 1.2g mem_limit: 2g diff --git a/roles/web-app-fediwall/meta/services.yml b/roles/web-app-fediwall/meta/services.yml index f54f4fcd69..b56f6bb266 100644 --- a/roles/web-app-fediwall/meta/services.yml +++ b/roles/web-app-fediwall/meta/services.yml @@ -90,7 +90,7 @@ fediwall: proxy: service_key: fediwall lifecycle: beta - min_storage: 1GB + min_storage: 227MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-fider/meta/services.yml b/roles/web-app-fider/meta/services.yml index 9afe993a11..842b992182 100644 --- a/roles/web-app-fider/meta/services.yml +++ b/roles/web-app-fider/meta/services.yml @@ -34,7 +34,7 @@ fider: image: getfider/fider version: stable name: fider - min_storage: 1GB + min_storage: 400MB backup: no_stop_required: true cpus: "0.5" diff --git a/roles/web-app-flowise/meta/services.yml b/roles/web-app-flowise/meta/services.yml index 0b40718bc2..a642f317f7 100644 --- a/roles/web-app-flowise/meta/services.yml +++ b/roles/web-app-flowise/meta/services.yml @@ -40,7 +40,7 @@ litellm: mem_reservation: 0.5g mem_limit: 1g pids_limit: 1024 - min_storage: 1GB + min_storage: 1022MB # nocheck: playwright-service-flag - DB engine, covered by role-local integration tests qdrant: bond: 1 @@ -66,7 +66,7 @@ flowise: image: flowiseai/flowise version: 3.1.3 name: flowise - min_storage: 10GB + min_storage: 1195MB cpus: "1.0" mem_reservation: 1g mem_limit: 2g diff --git a/roles/web-app-friendica/meta/services.yml b/roles/web-app-friendica/meta/services.yml index 777031af3b..2fd2ddd7c5 100644 --- a/roles/web-app-friendica/meta/services.yml +++ b/roles/web-app-friendica/meta/services.yml @@ -14,7 +14,7 @@ friendica: image: friendica version: "2026.05" custom: true - min_storage: 10GB + min_storage: 506MB ports: local: sso: 16501 diff --git a/roles/web-app-funkwhale/meta/services.yml b/roles/web-app-funkwhale/meta/services.yml index a6c2fb617b..bffdb91570 100644 --- a/roles/web-app-funkwhale/meta/services.yml +++ b/roles/web-app-funkwhale/meta/services.yml @@ -9,7 +9,7 @@ funkwhale: placement: manager image: funkwhale/front version: 2.0.6 - min_storage: 20GB + min_storage: 224MB ports: internal: http: 80 @@ -30,7 +30,7 @@ funkwhale: api: image: funkwhale/api version: 2.0.6 - min_storage: 5GB + min_storage: 588MB ports: internal: http: 5000 @@ -44,7 +44,7 @@ typesense: shared: "{{ 'svc-db-typesense' in group_names }}" image: typesense/typesense version: "30.2" - min_storage: 1GB + min_storage: 571MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-fusiondirectory/meta/services.yml b/roles/web-app-fusiondirectory/meta/services.yml index 4de96fd9a5..b3f8de80ad 100644 --- a/roles/web-app-fusiondirectory/meta/services.yml +++ b/roles/web-app-fusiondirectory/meta/services.yml @@ -57,7 +57,7 @@ fusiondirectory: - web-app-mailu - svc-db-openldap lifecycle: beta - min_storage: 5GB + min_storage: 379MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g diff --git a/roles/web-app-gitea/meta/services.yml b/roles/web-app-gitea/meta/services.yml index 406d86f69f..30c0c07a91 100644 --- a/roles/web-app-gitea/meta/services.yml +++ b/roles/web-app-gitea/meta/services.yml @@ -51,7 +51,7 @@ gitea: image: gitea/gitea version: 1.26.4 replicas: 1 # nocheck: single-replica single-writer app state on swarm-shared NFS; concurrent multi-node writers corrupt app.ini - min_storage: 10GB + min_storage: 270MB backup: no_stop_required: true name: gitea diff --git a/roles/web-app-hugo/meta/services.yml b/roles/web-app-hugo/meta/services.yml index 57dbcce596..327e06e099 100644 --- a/roles/web-app-hugo/meta/services.yml +++ b/roles/web-app-hugo/meta/services.yml @@ -46,7 +46,7 @@ hugo: proxy: service_key: hugo lifecycle: beta - min_storage: 1GB + min_storage: 227MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-jellyfin/meta/services.yml b/roles/web-app-jellyfin/meta/services.yml index e3c1b3b0d6..acce729f6c 100644 --- a/roles/web-app-jellyfin/meta/services.yml +++ b/roles/web-app-jellyfin/meta/services.yml @@ -39,7 +39,7 @@ jellyfin: mem_reservation: 1g mem_limit: 3g pids_limit: 4096 - min_storage: 10GB + min_storage: 890MB image: jellyfin/jellyfin version: "10.11.11" backup: diff --git a/roles/web-app-jenkins/meta/services.yml b/roles/web-app-jenkins/meta/services.yml index 02b9248b56..bb661e7a84 100644 --- a/roles/web-app-jenkins/meta/services.yml +++ b/roles/web-app-jenkins/meta/services.yml @@ -45,7 +45,7 @@ jenkins: - web-app-keycloak - web-app-matomo lifecycle: beta - min_storage: 10GB + min_storage: 490MB cpus: "1.0" mem_reservation: 1g mem_limit: 2g diff --git a/roles/web-app-jira/meta/services.yml b/roles/web-app-jira/meta/services.yml index dc063fb412..5796a5a17b 100644 --- a/roles/web-app-jira/meta/services.yml +++ b/roles/web-app-jira/meta/services.yml @@ -37,7 +37,7 @@ jira: version: 11.3.8 custom: true name: jira - min_storage: 20GB + min_storage: 1027MB cpus: "2.0" mem_reservation: 4g mem_limit: 6g diff --git a/roles/web-app-jitsi/meta/services.yml b/roles/web-app-jitsi/meta/services.yml index 8ab49e181d..e82ff0496f 100644 --- a/roles/web-app-jitsi/meta/services.yml +++ b/roles/web-app-jitsi/meta/services.yml @@ -52,7 +52,7 @@ jitsi: enabled: true image: jitsi/web version: "stable-9646" - min_storage: 5GB + min_storage: 315MB # nocheck: default-placement-manager meta/volumes.yml uses type:bind config dirs staged on the stack host; a worker-scheduled task lacks the source paths (unpinning requires migrating them to nfs-backed named volumes) placement: manager ports: diff --git a/roles/web-app-joomla/meta/services.yml b/roles/web-app-joomla/meta/services.yml index b30d761f81..612df020d0 100644 --- a/roles/web-app-joomla/meta/services.yml +++ b/roles/web-app-joomla/meta/services.yml @@ -42,7 +42,7 @@ joomla: version: 6.1.2-php8.3-apache custom: true name: joomla - min_storage: 8GB + min_storage: 478MB backup: no_stop_required: true upload: diff --git a/roles/web-app-keycloak/meta/services.yml b/roles/web-app-keycloak/meta/services.yml index ee5da31b2a..25b17a8f79 100644 --- a/roles/web-app-keycloak/meta/services.yml +++ b/roles/web-app-keycloak/meta/services.yml @@ -41,7 +41,7 @@ keycloak: image: quay.io/keycloak/keycloak version: "26.7.0" name: keycloak - min_storage: 5GB + min_storage: 468MB cpus: "2.0" mem_reservation: 2g mem_limit: 4g @@ -77,7 +77,7 @@ keycloak: sso_proxy: image: quay.io/oauth2-proxy/oauth2-proxy version: "v7.15.3" - min_storage: 1GB + min_storage: 220MB configuration_file: oauth2-proxy-keycloak.cfg ports: internal: diff --git a/roles/web-app-kix/meta/services.yml b/roles/web-app-kix/meta/services.yml index f52c8e2bac..fde188a0b8 100644 --- a/roles/web-app-kix/meta/services.yml +++ b/roles/web-app-kix/meta/services.yml @@ -65,7 +65,7 @@ kix: enabled: true image: docker-registry.kixdesk.com/public/proxy version: stable - min_storage: 10GB + min_storage: 227MB proxy: service_key: proxy ports: @@ -93,7 +93,7 @@ backend: name: kix-backend image: docker-registry.kixdesk.com/public/backend version: stable - min_storage: 5GB + min_storage: 409MB ports: internal: http: 8080 @@ -107,7 +107,7 @@ frontend: name: kix-frontend image: docker-registry.kixdesk.com/public/frontend version: stable - min_storage: 2GB + min_storage: 362MB ports: internal: http: 3000 diff --git a/roles/web-app-lam/meta/services.yml b/roles/web-app-lam/meta/services.yml index 0bcf4ba52b..fa96183636 100644 --- a/roles/web-app-lam/meta/services.yml +++ b/roles/web-app-lam/meta/services.yml @@ -28,7 +28,7 @@ lam: provides: lam image: ghcr.io/ldapaccountmanager/lam version: "9.6" - min_storage: 1GB + min_storage: 345MB ports: local: sso: 16482 diff --git a/roles/web-app-listmonk/meta/services.yml b/roles/web-app-listmonk/meta/services.yml index 4153b49c40..e1245ae364 100644 --- a/roles/web-app-listmonk/meta/services.yml +++ b/roles/web-app-listmonk/meta/services.yml @@ -40,7 +40,7 @@ listmonk: mem_reservation: 256m mem_limit: 1g pids_limit: 1024 - min_storage: 5GB + min_storage: 215MB image: listmonk/listmonk version: "v6.2.0" backup: diff --git a/roles/web-app-littlejs/meta/services.yml b/roles/web-app-littlejs/meta/services.yml index 1897a7bf16..83a9db8857 100644 --- a/roles/web-app-littlejs/meta/services.yml +++ b/roles/web-app-littlejs/meta/services.yml @@ -46,7 +46,7 @@ littlejs: run_after: - web-app-matomo lifecycle: alpha - min_storage: 5GB + min_storage: 227MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g diff --git a/roles/web-app-magento/meta/services.yml b/roles/web-app-magento/meta/services.yml index 92c890ef65..39fc7c4b48 100644 --- a/roles/web-app-magento/meta/services.yml +++ b/roles/web-app-magento/meta/services.yml @@ -36,7 +36,7 @@ php: fpm: 9000 backup: no_stop_required: true - min_storage: 5GB + min_storage: 660MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g @@ -47,7 +47,7 @@ nginx: name: magento-nginx backup: no_stop_required: true - min_storage: 5GB + min_storage: 235MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g @@ -79,7 +79,7 @@ search: image: opensearchproject/opensearch version: 3.7.0 name: magento-opensearch - min_storage: 10GB + min_storage: 1332MB cpus: "1.0" mem_reservation: 1g mem_limit: 2g diff --git a/roles/web-app-mastodon/meta/services.yml b/roles/web-app-mastodon/meta/services.yml index 3bf38b661b..5136d12541 100644 --- a/roles/web-app-mastodon/meta/services.yml +++ b/roles/web-app-mastodon/meta/services.yml @@ -47,7 +47,7 @@ mastodon: enabled: true # nocheck: dynamic-flag image: ghcr.io/mastodon/mastodon version: v4.6.2 - min_storage: 15GB + min_storage: 535MB backup: no_stop_required: true name: mastodon @@ -76,7 +76,7 @@ streaming: mem_reservation: 256m mem_limit: 512m pids_limit: 256 - min_storage: 5GB + min_storage: 309MB sidekiq: name: mastodon-sidekiq cpus: "0.8" diff --git a/roles/web-app-matomo/meta/services.yml b/roles/web-app-matomo/meta/services.yml index d93f697c59..a42fa21fd6 100644 --- a/roles/web-app-matomo/meta/services.yml +++ b/roles/web-app-matomo/meta/services.yml @@ -28,7 +28,7 @@ matomo: version: "5.11.2" custom: true name: matomo - min_storage: 5GB + min_storage: 404MB backup: no_stop_required: true cpus: 1.0 @@ -61,7 +61,7 @@ bootstrap: mem_reservation: 256m mem_limit: 1g pids_limit: 512 - min_storage: 1GB + min_storage: 1037MB # nocheck: playwright-service-flag โ€” DB engine, covered by role-local integration tests mariadb: bond: 1 diff --git a/roles/web-app-matrix/meta/services.yml b/roles/web-app-matrix/meta/services.yml index 3fa9549b67..bf0a7f1cb8 100644 --- a/roles/web-app-matrix/meta/services.yml +++ b/roles/web-app-matrix/meta/services.yml @@ -48,7 +48,7 @@ synapse: http: 8021 public: federation: 8448 - min_storage: 10GB + min_storage: 311MB cpus: "1.5" mem_reservation: "1g" mem_limit: "2g" @@ -62,7 +62,7 @@ element: http: 8022 internal: http: 80 - min_storage: 1GB + min_storage: 263MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m @@ -70,7 +70,7 @@ element: matrix-chatgpt-bot: version: 3.1.5 image: ghcr.io/matrixgpt/matrix-chatgpt-bot - min_storage: 1GB + min_storage: 307MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-mattermost/meta/services.yml b/roles/web-app-mattermost/meta/services.yml index 75d893ccfa..c23617f255 100644 --- a/roles/web-app-mattermost/meta/services.yml +++ b/roles/web-app-mattermost/meta/services.yml @@ -41,7 +41,7 @@ mattermost: user_access_token_enabled: "{{ 'web-app-nextcloud' in group_names }}" image: mattermost/mattermost-team-edition version: 11.9.0 - min_storage: 10GB + min_storage: 659MB backup: no_stop_required: true name: mattermost diff --git a/roles/web-app-mediawiki/meta/services.yml b/roles/web-app-mediawiki/meta/services.yml index fde10bb99a..f69c7122e6 100644 --- a/roles/web-app-mediawiki/meta/services.yml +++ b/roles/web-app-mediawiki/meta/services.yml @@ -35,7 +35,7 @@ mediawiki: image: mediawiki version: "1.46" custom: true - min_storage: 8GB + min_storage: 546MB backup: no_stop_required: true name: mediawiki diff --git a/roles/web-app-mig/meta/services.yml b/roles/web-app-mig/meta/services.yml index 41d3f9c40d..312c8ec81f 100644 --- a/roles/web-app-mig/meta/services.yml +++ b/roles/web-app-mig/meta/services.yml @@ -34,7 +34,7 @@ mig: image: ghcr.io/kevinveenbirkenbach/infinito-mig version: latest name: mig - min_storage: 1GB + min_storage: 1224MB ports: internal: http: 80 diff --git a/roles/web-app-mini-qr/meta/services.yml b/roles/web-app-mini-qr/meta/services.yml index dbc2a5f38a..3ed9240ba8 100644 --- a/roles/web-app-mini-qr/meta/services.yml +++ b/roles/web-app-mini-qr/meta/services.yml @@ -13,7 +13,7 @@ mini-qr: local: http: 8059 lifecycle: beta - min_storage: 1GB + min_storage: 304MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-minio/meta/services.yml b/roles/web-app-minio/meta/services.yml index 541b4d77ff..e6a01ef0db 100644 --- a/roles/web-app-minio/meta/services.yml +++ b/roles/web-app-minio/meta/services.yml @@ -39,7 +39,7 @@ minio: name: minio api_port: 9000 region: us-east-1 - min_storage: 20GB + min_storage: 263MB cpus: "2.0" mem_reservation: 2g mem_limit: 4g diff --git a/roles/web-app-mobilizon/meta/services.yml b/roles/web-app-mobilizon/meta/services.yml index b8f6e32cf5..50c90121ae 100644 --- a/roles/web-app-mobilizon/meta/services.yml +++ b/roles/web-app-mobilizon/meta/services.yml @@ -38,7 +38,7 @@ mobilizon: image: docker.io/kaihuri/mobilizon name: mobilizon version: 5.2.4 - min_storage: 5GB + min_storage: 825MB titel: Mobilizon on {{ DOMAIN_PRIMARY | upper }} ports: local: diff --git a/roles/web-app-moodle/meta/services.yml b/roles/web-app-moodle/meta/services.yml index a10fa15d15..5649947e80 100644 --- a/roles/web-app-moodle/meta/services.yml +++ b/roles/web-app-moodle/meta/services.yml @@ -47,7 +47,7 @@ moodle: version: "8.3-fpm" custom: true name: moodle - min_storage: 10GB + min_storage: 373MB cpus: 1.0 mem_reservation: 1g mem_limit: 2g @@ -73,7 +73,7 @@ nginx: image: nginx version: 1.31-alpine name: moodle-nginx - min_storage: 1GB + min_storage: 227MB cpus: "0.2" mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index 44bc0bbc50..b90cd50605 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -52,7 +52,7 @@ n8n: image: docker.n8n.io/n8nio/n8n version: "1.95.3" name: n8n - min_storage: 5GB + min_storage: 422MB cpus: "1.0" mem_reservation: 512m mem_limit: 1g diff --git a/roles/web-app-nextcloud/meta/services.yml b/roles/web-app-nextcloud/meta/services.yml index 506e1427ab..4b6cfdc8ec 100644 --- a/roles/web-app-nextcloud/meta/services.yml +++ b/roles/web-app-nextcloud/meta/services.yml @@ -64,7 +64,7 @@ nextcloud: image: nextcloud version: 33-fpm-alpine custom: true - min_storage: 20GB + min_storage: 553MB backup: no_stop_required: true cpus: "2.0" @@ -107,7 +107,7 @@ proxy: mem_reservation: 64m mem_limit: 256m pids_limit: 512 - min_storage: 1GB + min_storage: 226MB cron: # nocheck: single-replica singleton cron.sh; replicas double-run background jobs on the shared data volume replicas: 1 @@ -136,7 +136,7 @@ talk: mem_reservation: 1g mem_limit: 2g pids_limit: 1024 - min_storage: 1GB + min_storage: 274MB ports: internal: signaling: 8081 @@ -151,7 +151,7 @@ whiteboard: mem_reservation: 128m mem_limit: 1g pids_limit: 1024 - min_storage: 1GB + min_storage: 683MB # nocheck: playwright-service-flag: infrastructural sub-component (collaborative editing) onlyoffice: bond: 0.5 @@ -190,7 +190,7 @@ talk_recording: mem_reservation: 1g mem_limit: 3g pids_limit: 1024 - min_storage: 1GB + min_storage: 507MB ports: internal: recording: 1234 diff --git a/roles/web-app-odoo/meta/services.yml b/roles/web-app-odoo/meta/services.yml index 4507324712..8daa973084 100644 --- a/roles/web-app-odoo/meta/services.yml +++ b/roles/web-app-odoo/meta/services.yml @@ -40,7 +40,7 @@ odoo: version: "19.0" custom: true name: odoo - min_storage: 20GB + min_storage: 897MB cpus: "2.0" mem_reservation: 2g mem_limit: 4g diff --git a/roles/web-app-opencloud/meta/services.yml b/roles/web-app-opencloud/meta/services.yml index 19b8fb7f3d..354e3b77e9 100644 --- a/roles/web-app-opencloud/meta/services.yml +++ b/roles/web-app-opencloud/meta/services.yml @@ -37,7 +37,7 @@ opencloud: name: opencloud image: opencloudeu/opencloud version: 7.2.1 - min_storage: 20GB + min_storage: 276MB # nocheck: default-placement-manager opencloud_data holds NFS-hostile engine state: NATS JetStream store (events, micro registry, posixfs id cache), bleve search index, idm.boltdb, posixfs xattr metadata; upstream says search cannot scale, so the volume must stay node-local placement: manager backup: diff --git a/roles/web-app-openproject/meta/services.yml b/roles/web-app-openproject/meta/services.yml index 4c0c206c8a..e1c89101a9 100644 --- a/roles/web-app-openproject/meta/services.yml +++ b/roles/web-app-openproject/meta/services.yml @@ -49,7 +49,7 @@ web: mem_reservation: 3g mem_limit: 6g pids_limit: 2048 - min_storage: 5GB + min_storage: 1076MB seeder: name: openproject-seeder cpus: "0.3" diff --git a/roles/web-app-opentalk/meta/services.yml b/roles/web-app-opentalk/meta/services.yml index 1c16206112..315a54cf14 100644 --- a/roles/web-app-opentalk/meta/services.yml +++ b/roles/web-app-opentalk/meta/services.yml @@ -62,7 +62,7 @@ opentalk: name: opentalk image: registry.opencode.de/opentalk/web-frontend version: v2.9.0 - min_storage: 4GB + min_storage: 234MB backup: no_stop_required: true cpus: "0.5" @@ -81,7 +81,7 @@ controller: name: opentalk-controller image: registry.opencode.de/opentalk/controller version: v0.34.1 - min_storage: 8GB + min_storage: 291MB backup: no_stop_required: true cpus: "1.5" @@ -97,7 +97,7 @@ livekit: name: opentalk-livekit image: livekit/livekit-server version: v1.13.3 - min_storage: 1GB + min_storage: 238MB cpus: "2.0" mem_reservation: 512m mem_limit: 2g @@ -130,7 +130,7 @@ rabbit: name: opentalk-rabbit image: rabbitmq version: 4.3.2 - min_storage: 2GB + min_storage: 314MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g @@ -139,7 +139,7 @@ recorder: name: opentalk-recorder image: registry.opencode.de/opentalk/recorder version: v0.16.2 - min_storage: 2GB + min_storage: 775MB cpus: "1.5" mem_reservation: 512m mem_limit: 2g diff --git a/roles/web-app-openwebui/meta/services.yml b/roles/web-app-openwebui/meta/services.yml index d00a429f00..db1c6db92e 100644 --- a/roles/web-app-openwebui/meta/services.yml +++ b/roles/web-app-openwebui/meta/services.yml @@ -37,7 +37,7 @@ openwebui: image: ghcr.io/open-webui/open-webui version: main name: open-webui - min_storage: 5GB + min_storage: 2026MB offline_mode: false hf_hub_offline: false enable_api_keys: true diff --git a/roles/web-app-peertube/meta/services.yml b/roles/web-app-peertube/meta/services.yml index 4c7025f077..714722d1ab 100644 --- a/roles/web-app-peertube/meta/services.yml +++ b/roles/web-app-peertube/meta/services.yml @@ -61,7 +61,7 @@ peertube: name: peertube version: v8.2.2 image: chocobozzz/peertube - min_storage: 30GB + min_storage: 841MB live: enabled: true backup: diff --git a/roles/web-app-pgadmin/meta/services.yml b/roles/web-app-pgadmin/meta/services.yml index 148f0ef6e6..9e954f89ff 100644 --- a/roles/web-app-pgadmin/meta/services.yml +++ b/roles/web-app-pgadmin/meta/services.yml @@ -10,7 +10,7 @@ pgadmin: placement: manager image: dpage/pgadmin4 version: "9.16" - min_storage: 1GB + min_storage: 378MB server_mode: false master_password_required: true ports: diff --git a/roles/web-app-phpldapadmin/meta/services.yml b/roles/web-app-phpldapadmin/meta/services.yml index cac7bd6d1b..de242be6bd 100644 --- a/roles/web-app-phpldapadmin/meta/services.yml +++ b/roles/web-app-phpldapadmin/meta/services.yml @@ -23,7 +23,7 @@ phpldapadmin: enabled: true image: phpldapadmin/phpldapadmin version: 2.3.11 - min_storage: 1GB + min_storage: 276MB ports: local: sso: 16486 diff --git a/roles/web-app-phpmyadmin/meta/services.yml b/roles/web-app-phpmyadmin/meta/services.yml index 896aa78987..2d2c99654b 100644 --- a/roles/web-app-phpmyadmin/meta/services.yml +++ b/roles/web-app-phpmyadmin/meta/services.yml @@ -25,7 +25,7 @@ phpmyadmin: version: 5.2.3 name: phpmyadmin image: phpmyadmin/phpmyadmin - min_storage: 1GB + min_storage: 422MB autologin: false ports: local: diff --git a/roles/web-app-pihole/meta/services.yml b/roles/web-app-pihole/meta/services.yml index ff18c181ad..cccd7bd0ea 100644 --- a/roles/web-app-pihole/meta/services.yml +++ b/roles/web-app-pihole/meta/services.yml @@ -65,7 +65,7 @@ pihole: cpus: "0.5" mem_reservation: "128m" mem_limit: "256m" - min_storage: "1GB" + min_storage: "243MB" pids_limit: 512 ports: internal: diff --git a/roles/web-app-pixelfed/meta/services.yml b/roles/web-app-pixelfed/meta/services.yml index 6c49cbfca3..8d0e9a2566 100644 --- a/roles/web-app-pixelfed/meta/services.yml +++ b/roles/web-app-pixelfed/meta/services.yml @@ -51,7 +51,7 @@ pixelfed: image: zknt/pixelfed version: latest name: pixelfed - min_storage: 15GB + min_storage: 588MB backup: no_stop_required: true cpus: 0.75 diff --git a/roles/web-app-postmarks/meta/services.yml b/roles/web-app-postmarks/meta/services.yml index 7c49b2abe0..99be75c5de 100644 --- a/roles/web-app-postmarks/meta/services.yml +++ b/roles/web-app-postmarks/meta/services.yml @@ -56,7 +56,7 @@ postmarks: internal: http: 3000 lifecycle: beta - min_storage: 1GB + min_storage: 612MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g diff --git a/roles/web-app-pretix/meta/services.yml b/roles/web-app-pretix/meta/services.yml index a5add9517d..915ad4f9ff 100644 --- a/roles/web-app-pretix/meta/services.yml +++ b/roles/web-app-pretix/meta/services.yml @@ -49,7 +49,7 @@ pretix: version: stable custom: true name: pretix - min_storage: 10GB + min_storage: 1003MB backup: no_stop_required: true cpus: "2.0" diff --git a/roles/web-app-prometheus/meta/services.yml b/roles/web-app-prometheus/meta/services.yml index d57609cce1..43e3b7d073 100644 --- a/roles/web-app-prometheus/meta/services.yml +++ b/roles/web-app-prometheus/meta/services.yml @@ -70,7 +70,7 @@ prometheus: run_after: - web-app-keycloak lifecycle: beta - min_storage: 5GB + min_storage: 307MB alertmanager: image: prom/alertmanager version: v0.33.1 @@ -79,7 +79,7 @@ alertmanager: mem_reservation: 64m mem_limit: 128m pids_limit: 64 - min_storage: 1GB + min_storage: 240MB ports: internal: http: 9093 @@ -91,7 +91,7 @@ blackbox-exporter: mem_reservation: 32m mem_limit: 64m pids_limit: 64 - min_storage: 1GB + min_storage: 218MB ports: internal: http: 9115 @@ -103,7 +103,7 @@ cadvisor: mem_reservation: 64m mem_limit: 512m pids_limit: 64 - min_storage: 1GB + min_storage: 234MB ports: internal: http: 8080 @@ -115,7 +115,7 @@ node-exporter: mem_reservation: 32m mem_limit: 128m pids_limit: 64 - min_storage: 1GB + min_storage: 214MB ports: internal: http: 9100 diff --git a/roles/web-app-roulette-wheel/meta/services.yml b/roles/web-app-roulette-wheel/meta/services.yml index f8e6d67198..fa8025dadd 100644 --- a/roles/web-app-roulette-wheel/meta/services.yml +++ b/roles/web-app-roulette-wheel/meta/services.yml @@ -33,7 +33,7 @@ roulette-wheel: custom: true repository: https://github.com/kevinveenbirkenbach/roulette-wheel.git ref: master - min_storage: 1GB + min_storage: 612MB cpus: 0.5 mem_reservation: 126m mem_limit: 265m diff --git a/roles/web-app-seaweedfs/meta/services.yml b/roles/web-app-seaweedfs/meta/services.yml index 0577137fa4..861aea69f0 100644 --- a/roles/web-app-seaweedfs/meta/services.yml +++ b/roles/web-app-seaweedfs/meta/services.yml @@ -45,7 +45,7 @@ seaweedfs: name: seaweedfs api_port: 8333 region: us-east-1 - min_storage: 30GB + min_storage: 304MB cpus: "1.0" mem_reservation: 1g mem_limit: 2g diff --git a/roles/web-app-semaphore/meta/services.yml b/roles/web-app-semaphore/meta/services.yml index 76f9ccc4da..8693c84302 100644 --- a/roles/web-app-semaphore/meta/services.yml +++ b/roles/web-app-semaphore/meta/services.yml @@ -48,7 +48,7 @@ semaphore: mem_reservation: 256m mem_limit: 1g pids_limit: 1024 - min_storage: 5GB + min_storage: 530MB image: semaphoreui/semaphore version: "v2.18.25" backup: diff --git a/roles/web-app-shopware/meta/services.yml b/roles/web-app-shopware/meta/services.yml index 9575d9ac6f..ed5a818a92 100644 --- a/roles/web-app-shopware/meta/services.yml +++ b/roles/web-app-shopware/meta/services.yml @@ -53,7 +53,7 @@ shopware: image: composer version: "2.10" custom: true - min_storage: 20GB + min_storage: 278MB package: name: shopware/production version: 6.7.8.2 @@ -77,7 +77,7 @@ shopware: php: image: ghcr.io/shopware/docker-base version: "8.3" - min_storage: 5GB + min_storage: 295MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g @@ -133,7 +133,7 @@ redis: cpus: 0.25 mem_reservation: 256m mem_limit: 512m - min_storage: 1GB + min_storage: 238MB pids_limit: 256 # nocheck: shared # nocheck: dynamic-flag @@ -150,7 +150,7 @@ opensearch: cpus: 1.0 mem_reservation: 2g mem_limit: 4g - min_storage: 10GB + min_storage: 1332MB pids_limit: 1024 seaweedfs: bond: 1 diff --git a/roles/web-app-snipe-it/meta/services.yml b/roles/web-app-snipe-it/meta/services.yml index 4fa07d9ddf..b56bb7e19a 100644 --- a/roles/web-app-snipe-it/meta/services.yml +++ b/roles/web-app-snipe-it/meta/services.yml @@ -55,7 +55,7 @@ snipe-it: custom: true name: snipe-it image: snipe/snipe-it - min_storage: 8GB + min_storage: 349MB cpus: "1.0" mem_reservation: 256m mem_limit: 2g diff --git a/roles/web-app-sphinx/meta/services.yml b/roles/web-app-sphinx/meta/services.yml index 908e4a13b9..86565ace1a 100644 --- a/roles/web-app-sphinx/meta/services.yml +++ b/roles/web-app-sphinx/meta/services.yml @@ -26,7 +26,7 @@ sphinx: enabled: true image: ghcr.io/kevinveenbirkenbach/infinito-sphinx version: v1.0.70 - min_storage: 1GB + min_storage: 1931MB backup: no_stop_required: true name: sphinx diff --git a/roles/web-app-suitecrm/meta/services.yml b/roles/web-app-suitecrm/meta/services.yml index d84c3352bb..769d7830e3 100644 --- a/roles/web-app-suitecrm/meta/services.yml +++ b/roles/web-app-suitecrm/meta/services.yml @@ -49,7 +49,7 @@ suitecrm: custom: true app_version: 8.9.1 name: suitecrm - min_storage: 10GB + min_storage: 376MB cpus: 1.5 mem_reservation: 1.2g mem_limit: 2g diff --git a/roles/web-app-wordpress/meta/services.yml b/roles/web-app-wordpress/meta/services.yml index ce7c954dae..0e0dc1aad6 100644 --- a/roles/web-app-wordpress/meta/services.yml +++ b/roles/web-app-wordpress/meta/services.yml @@ -36,7 +36,7 @@ wordpress: image: wordpress custom: true name: wordpress - min_storage: 8GB + min_storage: 469MB backup: no_stop_required: true cpus: 1 diff --git a/roles/web-app-xwiki/meta/services.yml b/roles/web-app-xwiki/meta/services.yml index fe4abf9f2b..3456073003 100644 --- a/roles/web-app-xwiki/meta/services.yml +++ b/roles/web-app-xwiki/meta/services.yml @@ -52,7 +52,7 @@ xwiki: version: lts-postgres-tomcat custom: true name: xwiki - min_storage: 10GB + min_storage: 965MB backup: no_stop_required: true cpus: 1.0 diff --git a/roles/web-app-yourls/meta/services.yml b/roles/web-app-yourls/meta/services.yml index bdccdfb1da..b5e993ec7e 100644 --- a/roles/web-app-yourls/meta/services.yml +++ b/roles/web-app-yourls/meta/services.yml @@ -27,7 +27,7 @@ yourls: custom: true name: yourls image: yourls - min_storage: 1GB + min_storage: 388MB ports: local: sso: 16484 diff --git a/roles/web-app-zammad/meta/services.yml b/roles/web-app-zammad/meta/services.yml index b69bc11659..28e4518cde 100644 --- a/roles/web-app-zammad/meta/services.yml +++ b/roles/web-app-zammad/meta/services.yml @@ -70,7 +70,7 @@ zammad: image: ghcr.io/zammad/zammad version: "6.5.0" name: zammad-railsserver - min_storage: 15GB + min_storage: 520MB proxy: service_key: nginx ports: @@ -108,7 +108,7 @@ websocket: search: image: docker.elastic.co/elasticsearch/elasticsearch version: "8.13.4" - min_storage: 5GB + min_storage: 839MB cpus: "1.0" mem_reservation: 1g mem_limit: 2g diff --git a/roles/web-svc-collabora/meta/services.yml b/roles/web-svc-collabora/meta/services.yml index c909c04458..b3f779c734 100644 --- a/roles/web-svc-collabora/meta/services.yml +++ b/roles/web-svc-collabora/meta/services.yml @@ -29,7 +29,7 @@ collabora: # configuration or upstream restores the tooling. version: "26.04.2.1.1" name: collabora - min_storage: 10GB + min_storage: 734MB cpus: 2 mem_reservation: 1g mem_limit: 2g diff --git a/roles/web-svc-coturn/meta/services.yml b/roles/web-svc-coturn/meta/services.yml index 788c046237..6ebef43b31 100644 --- a/roles/web-svc-coturn/meta/services.yml +++ b/roles/web-svc-coturn/meta/services.yml @@ -10,7 +10,7 @@ coturn: shared: true image: coturn/coturn version: 4.14.0 - min_storage: 1GB + min_storage: 246MB user_auth_enabled: false cpus: 1.0 mem_reservation: 512m diff --git a/roles/web-svc-libretranslate/meta/services.yml b/roles/web-svc-libretranslate/meta/services.yml index 6a8952934b..f6d4c34389 100644 --- a/roles/web-svc-libretranslate/meta/services.yml +++ b/roles/web-svc-libretranslate/meta/services.yml @@ -12,7 +12,7 @@ libretranslate: image: libretranslate/libretranslate version: v1.9.6 custom: true - min_storage: 5GB + min_storage: 402MB load_only: - en - de diff --git a/roles/web-svc-logout/meta/services.yml b/roles/web-svc-logout/meta/services.yml index 23abfec371..d4cbfb1711 100644 --- a/roles/web-svc-logout/meta/services.yml +++ b/roles/web-svc-logout/meta/services.yml @@ -12,7 +12,7 @@ logout: image: ghcr.io/kevinveenbirkenbach/universal-logout version: latest name: logout - min_storage: 1GB + min_storage: 250MB cpus: 0.2 mem_reservation: 256m mem_limit: 512m diff --git a/roles/web-svc-onlyoffice/meta/services.yml b/roles/web-svc-onlyoffice/meta/services.yml index 61d4d58fe7..1a75decbfc 100644 --- a/roles/web-svc-onlyoffice/meta/services.yml +++ b/roles/web-svc-onlyoffice/meta/services.yml @@ -33,7 +33,7 @@ onlyoffice: image: onlyoffice/documentserver version: 9.4.0 name: onlyoffice - min_storage: 20GB + min_storage: 1468MB cpus: 2 mem_reservation: 1g mem_limit: 4g diff --git a/roles/web-svc-simpleicons/meta/services.yml b/roles/web-svc-simpleicons/meta/services.yml index b4632f2f9d..5ca61f1132 100644 --- a/roles/web-svc-simpleicons/meta/services.yml +++ b/roles/web-svc-simpleicons/meta/services.yml @@ -43,7 +43,7 @@ simpleicons: version: 26-bookworm local_image: simpleicons-server local_version: latest - min_storage: 1GB + min_storage: 612MB cpus: 0.5 mem_reservation: 128m mem_limit: 256m diff --git a/roles/web-svc-xmpp/meta/services.yml b/roles/web-svc-xmpp/meta/services.yml index 6f027b4c43..42022a7275 100644 --- a/roles/web-svc-xmpp/meta/services.yml +++ b/roles/web-svc-xmpp/meta/services.yml @@ -43,7 +43,7 @@ xmpp: - web-app-keycloak - svc-db-openldap lifecycle: beta - min_storage: 5GB + min_storage: 241MB cpus: "0.5" mem_reservation: 256m mem_limit: 1g From 5fee47085fa7f3ec0cf871356da9a547e19df2e3 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 30 Jul 2026 15:08:04 +0200 Subject: [PATCH 24/24] style(tests): wrap the storage-constrained assertions to the formatter width Formatter-only change; make autoformat rewrote the three is_constrained calls that exceeded the line limit when the file landed. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/utils/storage/test_constrained.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/unit/utils/storage/test_constrained.py b/tests/unit/utils/storage/test_constrained.py index 32088da59a..f811ad8cee 100644 --- a/tests/unit/utils/storage/test_constrained.py +++ b/tests/unit/utils/storage/test_constrained.py @@ -7,13 +7,19 @@ _GIB = 1024**3 class TestIsConstrained(unittest.TestCase): def test_need_above_free_is_constrained(self): - self.assertTrue(is_constrained(free_bytes=122 * _GIB, required_bytes=381 * _GIB)) + self.assertTrue( + is_constrained(free_bytes=122 * _GIB, required_bytes=381 * _GIB) + ) def test_need_equal_to_free_fits(self): - self.assertFalse(is_constrained(free_bytes=100 * _GIB, required_bytes=100 * _GIB)) + self.assertFalse( + is_constrained(free_bytes=100 * _GIB, required_bytes=100 * _GIB) + ) def test_need_below_free_fits(self): - self.assertFalse(is_constrained(free_bytes=300 * _GIB, required_bytes=113 * _GIB)) + self.assertFalse( + is_constrained(free_bytes=300 * _GIB, required_bytes=113 * _GIB) + ) def test_undeclared_need_never_constrains(self): self.assertFalse(is_constrained(free_bytes=1, required_bytes=0))