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/.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/Makefile b/Makefile index 691759a715..909b68a011 100644 --- a/Makefile +++ b/Makefile @@ -688,6 +688,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). @@ -703,11 +705,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 @@ -777,11 +785,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. @@ -794,10 +802,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/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/default.env b/default.env index 89c35921ca..669a8f388c 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/docs/contributing/actions/testing/suppression.md b/docs/contributing/actions/testing/suppression.md index 817da3e341..5917b2bde7 100644 --- a/docs/contributing/actions/testing/suppression.md +++ b/docs/contributing/actions/testing/suppression.md @@ -92,6 +92,7 @@ The placement rule is per check. The catalog "Position" column uses these labels | `tor-contract` | anywhere in file | [test_tor_contract.py](../../../../tests/lint/ansible/roles/meta/test_tor_contract.py) | Exempts a domain-serving role (`web-`/`svc-db-` prefix or explicit `domains.canonical`) from the "must declare a `tor:` block in `meta/services.yml`" contract. Reserved for roles with no onion surface, e.g. pure redirect helpers. | | `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/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 78ed072ae7..cbacd3d28f 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 dbb7ecd324..489d17bd9a 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 a341b78f61..1e8260442e 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 fa2f3a332c..78b7d5e4e4 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 fcf37fba73..d01b77bac6 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 942bd16587..658b308f83 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 62cacd13aa..22a2c4a0be 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 9545fa283d..2ce1a48238 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 b43a2d620e..88deb4ef02 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 b41d3de0e0..8a425411a8 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/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..4b630d0b10 100644 --- a/roles/sys-svc-compose/handlers/swarm.yml +++ b/roles/sys-svc-compose/handlers/swarm.yml @@ -137,3 +137,21 @@ - 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 }}" + retries: 5 + delay: 15 + until: _swarm_force_recreate is succeeded + listen: + - container recreate + run_once: true + delegate_to: "{{ STACK_HOST }}" diff --git a/roles/test-e2e-playwright/tasks/02_run_one.yml b/roles/test-e2e-playwright/tasks/02_run_one.yml index 92244ee2b1..6249f0674a 100644 --- a/roles/test-e2e-playwright/tasks/02_run_one.yml +++ b/roles/test-e2e-playwright/tasks/02_run_one.yml @@ -282,18 +282,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 | default(0) != 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 | default(0) != 0 + when: (_playwright_run.rc | default(1)) != 0 - name: ๐Ÿ—„๏ธ Archive Playwright reports for this variant + pass ansible.builtin.script: >- diff --git a/roles/web-app-akaunting/meta/services.yml b/roles/web-app-akaunting/meta/services.yml index 5ef57f1831..d7a9e86eb6 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 16e19c33ee..eeb09c32f8 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 b7546ff6b4..8d631b22cb 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 78ad45c7b1..f169e60901 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 4706f785b0..0ec941ed3e 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 203fcad439..edf3b3c592 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 6bf18566bb..dce032007b 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 7ce4c4e34c..be83f98d11 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 0af3bb83a0..8ffb045337 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 55cd52d11c..32cd415e77 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 783d4db88d..33ea18660e 100644 --- a/roles/web-app-erpnext/meta/services.yml +++ b/roles/web-app-erpnext/meta/services.yml @@ -66,7 +66,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 d2d07c5d83..273636361a 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 12c190ef92..cfc03c0374 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 92281fb762..e058841e38 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 7336182fca..7f0150da29 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 827ada7f1e..748c096e75 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 e1070c012f..63198df682 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 d66a8f5bb6..25c2771265 100644 --- a/roles/web-app-fusiondirectory/meta/services.yml +++ b/roles/web-app-fusiondirectory/meta/services.yml @@ -62,7 +62,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 dc50ca86b0..d8faabb47c 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 64e83f7b25..7d92e03b4f 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 21953ce9d3..457b7447a0 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 50e2e094fe..9ccec85951 100644 --- a/roles/web-app-jenkins/meta/services.yml +++ b/roles/web-app-jenkins/meta/services.yml @@ -50,7 +50,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 795f88eb61..bef92e0d2e 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 6099cb319e..e790ce8f36 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 30bcca4ca6..47258c60b9 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 a7a85cc0a7..09a8ea6049 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 8aa308046f..40f1b23ba5 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 c0ef03b826..1942f323ee 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 72aa6cd213..2c1fc3a371 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 04a4bb64f6..c0eb168f8e 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 9d7c2ac1f4..7022b0e774 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 361b89c014..d1f54b8678 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 abdde9c135..63332ce0a1 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 67e3354863..c80dd3430d 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 e961f5ffbe..60625734e5 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 7ceabf2ffa..a479b5cdc1 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 47a096f983..b067625117 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 104cf8f150..85132890b2 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 3f019927f5..8f3cebaea3 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 069891bd24..18365a0264 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 a846ebd886..4f66aa6d20 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 1b760d7d97..cab75319e7 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -57,7 +57,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 25983f3df8..a79b63eb85 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 c414fd2ea1..18a4aaad51 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 e516780226..d46980f261 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 2de4e02fe0..55593ce520 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 d03e8ce227..9e2e336dac 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 c6d1a0dcdc..c029109786 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/files/playwright/playwright.spec.js b/roles/web-app-peertube/files/playwright/playwright.spec.js index 791cc395ea..4c68aae7c2 100644 --- a/roles/web-app-peertube/files/playwright/playwright.spec.js +++ b/roles/web-app-peertube/files/playwright/playwright.spec.js @@ -52,6 +52,7 @@ async function signInViaDashboardOidc(page, username, password, personaLabel) { const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`; await gotoOnion(page, `${peertubeBaseUrl}/login`); + await page.waitForLoadState("networkidle").catch(() => {}); const oidcButtonPatterns = [ oidcButtonText ? new RegExp(oidcButtonText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") : null, @@ -61,28 +62,27 @@ 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({ timeout: resolveTimeout(30_000) }); - } 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({ timeout: resolveTimeout(30_000) }); - break; - } - } - } - await expect - .poll(() => page.url(), { - timeout: resolveTimeout(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: resolveTimeout(30_000) }) + .catch(() => {}); + await candidate.click().catch(() => {}); + break; + } + } + return page.url(); + }, + { + timeout: resolveTimeout(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/meta/services.yml b/roles/web-app-peertube/meta/services.yml index 7c2d4db3b4..4e6c9f859f 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-peertube/tasks/oidc/enable.yml b/roles/web-app-peertube/tasks/oidc/enable.yml index ba3d55b192..9953cb8a0b 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,41 @@ 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 + +- 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 }}" diff --git a/roles/web-app-pgadmin/meta/services.yml b/roles/web-app-pgadmin/meta/services.yml index fed4ee0510..3fcb579e2a 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 6c6609a032..d2d6e355c6 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 a79b104814..360368175b 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 092b5d2566..141517875f 100644 --- a/roles/web-app-pihole/meta/services.yml +++ b/roles/web-app-pihole/meta/services.yml @@ -70,7 +70,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 b89ee1e10f..b96fe8b711 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 28f6256ab1..5d7c29b563 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 48f5dc397c..a0b675e804 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 69aa3eb365..83c50abce1 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: 128m mem_limit: 1g 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 1b5d1e7e48..ef607e3060 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 44bb9af0f8..b485a93d15 100644 --- a/roles/web-app-seaweedfs/meta/services.yml +++ b/roles/web-app-seaweedfs/meta/services.yml @@ -58,7 +58,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 a684b13655..89a51924d9 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 dd6167fb29..bad46d846b 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 bacee3f1fe..9f8f1ceb72 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 428bd7a890..b6f2133e35 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 f3c14ff74c..59529fc76a 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 a4f46d20f6..c06daaab9c 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 327edcc686..b3d84b219b 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 c0a434b7e6..16e3f3453b 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 996d3aeb6d..681519c674 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 3cc0491d86..74a74d2e5f 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 bd2b117651..d0636a7df4 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 88f25ac3b0..9e449b2889 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 d53c4ca6a4..7b260dd397 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 337357dccf..91bc4de62d 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 3ffebb85de..37ef7bc24a 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 49009ebe27..7fc88c9554 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 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 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..ade34e28e9 100755 --- a/scripts/system/worktree/lib.sh +++ b/scripts/system/worktree/lib.sh @@ -24,14 +24,21 @@ # # 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#*[:/]}" +} + 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" <&2 render_summary exit "$rc" 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}-" 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/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") 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/tests/unit/utils/storage/test_constrained.py b/tests/unit/utils/storage/test_constrained.py new file mode 100644 index 0000000000..f811ad8cee --- /dev/null +++ b/tests/unit/utils/storage/test_constrained.py @@ -0,0 +1,45 @@ +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/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. 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"