Merge pull request #182 from kevinveenbirkenbach/fix/web-app-peertube/oom/162

Fix peertube OOM during auth-openid-connect plugin install (#162)
This commit is contained in:
kevinveenbirkenbach authored and GitHub committed 2026-04-21 09:05:31 +02:00
commit cc5d4e6d54
32 files changed
+1783 -89

No files matched your search

+26
View File
@@ -88,8 +88,29 @@ jobs:
apps: ${{ fromJson(needs.discover.outputs.apps) }}
steps:
# Reclaim runner space before Docker pulls start. Deploy jobs otherwise
# compete with the default Ubuntu image payload for limited root space.
# Option rationale: docs/contributing/artefact/files/github/workflow.md
- name: Free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- uses: actions/checkout@v6
# Replace the default ~4 GB runner swap with a larger file so transient
# memory spikes (e.g. peertube plugin install, issue #162) don't trip
# the host OOM-killer. Script picks / or /mnt based on live free space.
- name: Enlarge swap space
shell: bash
run: ./scripts/github/enlarge_swap.sh
- name: Install deps (jq + envsubst)
shell: bash
run: ./scripts/install/apt.sh jq gettext-base
@@ -101,3 +122,8 @@ jobs:
DISTROS: ${{ needs.discover.outputs.distros }}
APPS: ${{ matrix.apps }}
run: ./scripts/tests/deploy/ci/all.sh
- name: Dump runner state (on failure)
if: failure()
shell: bash
run: ./scripts/github/dump_runner_state.sh "post-deploy failure (${{ matrix.apps }})"
+16 -4
View File
@@ -82,16 +82,23 @@ jobs:
- name: Free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: false
large-packages: true
docker-images: true
swap-storage: true
- uses: actions/checkout@v6
# Replace the default ~4 GB runner swap with a larger file so transient
# memory spikes (e.g. peertube plugin install, issue #162) don't trip
# the host OOM-killer. Script picks / or /mnt based on live free space.
- name: Enlarge swap space
shell: bash
run: ./scripts/github/enlarge_swap.sh
- name: Resolve repository owner
shell: bash
run: |
@@ -108,6 +115,11 @@ jobs:
APPS: ${{ matrix.apps }}
run: ./scripts/tests/deploy/ci/all.sh
- name: Dump runner state (on failure)
if: failure()
shell: bash
run: ./scripts/github/dump_runner_state.sh "post-deploy failure (${{ matrix.apps }})"
- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v7
+16 -4
View File
@@ -77,16 +77,23 @@ jobs:
- name: Free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: false
large-packages: true
docker-images: true
swap-storage: true
- uses: actions/checkout@v6
# Replace the default ~4 GB runner swap with a larger file so transient
# memory spikes (e.g. peertube plugin install, issue #162) don't trip
# the host OOM-killer. Script picks / or /mnt based on live free space.
- name: Enlarge swap space
shell: bash
run: ./scripts/github/enlarge_swap.sh
- name: Resolve repository owner
shell: bash
run: |
@@ -102,3 +109,8 @@ jobs:
env:
APPS: ${{ matrix.apps }}
run: ./scripts/tests/deploy/ci/all.sh
- name: Dump runner state (on failure)
if: failure()
shell: bash
run: ./scripts/github/dump_runner_state.sh "post-deploy failure (${{ matrix.apps }})"
+16 -4
View File
@@ -77,16 +77,23 @@ jobs:
- name: Free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: false
large-packages: true
docker-images: true
swap-storage: true
- uses: actions/checkout@v6
# Replace the default ~4 GB runner swap with a larger file so transient
# memory spikes (e.g. peertube plugin install, issue #162) don't trip
# the host OOM-killer. Script picks / or /mnt based on live free space.
- name: Enlarge swap space
shell: bash
run: ./scripts/github/enlarge_swap.sh
- name: Resolve repository owner
shell: bash
run: |
@@ -102,3 +109,8 @@ jobs:
env:
APPS: ${{ matrix.apps }}
run: ./scripts/tests/deploy/ci/all.sh
- name: Dump runner state (on failure)
if: failure()
shell: bash
run: ./scripts/github/dump_runner_state.sh "post-deploy failure (${{ matrix.apps }})"
Whitespace-only changes.
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
# cli/meta/applications/ressources/__main__.py
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(REPO_ROOT))
from humanfriendly import format_size, parse_size # noqa: E402
from utils.entity_name_utils import get_entity_name # noqa: E402
from utils.service_registry import ( # noqa: E402
build_service_registry_from_applications,
load_applications_from_roles_dir,
)
ROLES_DIR = REPO_ROOT / "roles"
def _as_mapping(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {}
def _parse_mem_bytes(value: Any) -> Optional[int]:
if value is None:
return None
if isinstance(value, (int, float)):
return int(value)
text = str(value).strip()
if not text:
return None
try:
return int(parse_size(text))
except Exception:
return None
def _parse_cpus(value: Any) -> Optional[float]:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _parse_int(value: Any) -> Optional[int]:
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _is_enabled(service_conf: Dict[str, Any], is_primary: bool) -> bool:
if "enabled" not in service_conf:
return is_primary
raw = service_conf.get("enabled")
if isinstance(raw, bool):
return raw
text = str(raw).strip().lower()
if text in ("false", "0", "no", "off"):
return False
return True
def _is_shared(service_conf: Dict[str, Any]) -> bool:
raw = service_conf.get("shared", False)
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ("true", "1", "yes", "on")
_RESOURCE_KEYS = ("mem_reservation", "mem_limit", "pids_limit", "cpus")
_CONTAINER_KEYS = ("image", "name", "version", "container")
def _looks_like_container(service_conf: Dict[str, Any]) -> bool:
return any(key in service_conf for key in _RESOURCE_KEYS + _CONTAINER_KEYS)
def _row_for_service(
role_name: str,
service_key: str,
service_conf: Dict[str, Any],
) -> Dict[str, Any]:
return {
"role": role_name,
"service": service_key,
"mem_reservation_raw": service_conf.get("mem_reservation"),
"mem_limit_raw": service_conf.get("mem_limit"),
"pids_limit_raw": service_conf.get("pids_limit"),
"cpus_raw": service_conf.get("cpus"),
"mem_reservation_bytes": _parse_mem_bytes(service_conf.get("mem_reservation")),
"mem_limit_bytes": _parse_mem_bytes(service_conf.get("mem_limit")),
"pids_limit_int": _parse_int(service_conf.get("pids_limit")),
"cpus_float": _parse_cpus(service_conf.get("cpus")),
}
def collect_role_resources(
role_name: str,
applications: Dict[str, Dict[str, Any]],
service_registry: Dict[str, Dict[str, Any]],
visited: set,
rows: List[Dict[str, Any]],
warnings: List[str],
) -> None:
if role_name in visited:
return
visited.add(role_name)
if role_name not in applications:
warnings.append(f"role '{role_name}' has no config/main.yml; skipping")
return
config = _as_mapping(applications[role_name])
services = _as_mapping(_as_mapping(config.get("compose")).get("services"))
entity_name = get_entity_name(role_name)
if entity_name and entity_name in services:
primary_conf = _as_mapping(services.get(entity_name))
rows.append(_row_for_service(role_name, entity_name, primary_conf))
else:
warnings.append(
f"role '{role_name}' has no compose.services.{entity_name or '<entity>'} entry"
)
shared_dependencies: List[str] = []
for service_key, service_conf in services.items():
if service_key == entity_name:
continue
service_conf = _as_mapping(service_conf)
if not service_conf:
continue
if not _is_enabled(service_conf, is_primary=False):
continue
if _is_shared(service_conf):
provider = _as_mapping(service_registry.get(service_key))
provider_role = provider.get("role") if provider else None
if provider_role and provider_role != role_name:
shared_dependencies.append(provider_role)
elif not provider_role:
warnings.append(
f"{role_name}: shared service '{service_key}' has no registered provider"
)
else:
if not _looks_like_container(service_conf):
continue
rows.append(_row_for_service(role_name, service_key, service_conf))
for provider_role in shared_dependencies:
collect_role_resources(
provider_role,
applications,
service_registry,
visited,
rows,
warnings,
)
def aggregate(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
total_mem_res = 0
total_mem_lim = 0
total_pids = 0
max_cpus = 0.0
any_mem_res = any_mem_lim = any_pids = any_cpus = False
for row in rows:
if row["mem_reservation_bytes"] is not None:
total_mem_res += row["mem_reservation_bytes"]
any_mem_res = True
if row["mem_limit_bytes"] is not None:
total_mem_lim += row["mem_limit_bytes"]
any_mem_lim = True
if row["pids_limit_int"] is not None:
total_pids += row["pids_limit_int"]
any_pids = True
if row["cpus_float"] is not None:
max_cpus = max(max_cpus, row["cpus_float"])
any_cpus = True
return {
"mem_reservation_bytes": total_mem_res if any_mem_res else None,
"mem_limit_bytes": total_mem_lim if any_mem_lim else None,
"pids_limit_int": total_pids if any_pids else None,
"cpus_float": max_cpus if any_cpus else None,
}
def _fmt_mem(value: Optional[int]) -> str:
if value is None:
return "-"
return format_size(value, binary=False)
def _fmt_int(value: Optional[int]) -> str:
return "-" if value is None else str(value)
def _fmt_float(value: Optional[float]) -> str:
if value is None:
return "-"
if float(value).is_integer():
return str(int(value))
return f"{value:g}"
def render_text(
role_name: str,
rows: List[Dict[str, Any]],
totals: Dict[str, Any],
warnings: List[str],
) -> str:
headers = ["service", "role", "mem_reservation", "mem_limit", "pids_limit", "cpus"]
table_rows: List[Tuple[str, ...]] = []
for row in sorted(rows, key=lambda r: (r["service"], r["role"])):
table_rows.append(
(
row["service"],
row["role"],
_fmt_mem(row["mem_reservation_bytes"]),
_fmt_mem(row["mem_limit_bytes"]),
_fmt_int(row["pids_limit_int"]),
_fmt_float(row["cpus_float"]),
)
)
total_label = "TOTAL (mem=SUM, pids=SUM max-provisioned, cpus=MAX)"
total_row = (
total_label,
"",
_fmt_mem(totals["mem_reservation_bytes"]),
_fmt_mem(totals["mem_limit_bytes"]),
_fmt_int(totals["pids_limit_int"]),
_fmt_float(totals["cpus_float"]),
)
widths = [len(h) for h in headers]
for r in table_rows + [total_row]:
for i, cell in enumerate(r):
widths[i] = max(widths[i], len(cell))
def fmt_row(cells: Tuple[str, ...]) -> str:
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells))
sep = " ".join("-" * w for w in widths)
lines = [
f"# Resources for role: {role_name}",
"",
fmt_row(tuple(headers)),
sep,
]
lines.extend(fmt_row(r) for r in table_rows)
lines.append(sep)
lines.append(fmt_row(total_row))
if warnings:
lines.append("")
lines.append("# Warnings")
for w in warnings:
lines.append(f"! {w}")
return "\n".join(lines)
def render_json(
role_name: str,
rows: List[Dict[str, Any]],
totals: Dict[str, Any],
warnings: List[str],
) -> str:
def _row(row: Dict[str, Any]) -> Dict[str, Any]:
return {
"role": row["role"],
"service": row["service"],
"mem_reservation": {
"raw": row["mem_reservation_raw"],
"bytes": row["mem_reservation_bytes"],
"human": _fmt_mem(row["mem_reservation_bytes"]),
},
"mem_limit": {
"raw": row["mem_limit_raw"],
"bytes": row["mem_limit_bytes"],
"human": _fmt_mem(row["mem_limit_bytes"]),
},
"pids_limit": {
"raw": row["pids_limit_raw"],
"value": row["pids_limit_int"],
},
"cpus": {
"raw": row["cpus_raw"],
"value": row["cpus_float"],
},
}
payload = {
"role": role_name,
"services": [_row(r) for r in rows],
"totals": {
"mem_reservation": {
"bytes": totals["mem_reservation_bytes"],
"human": _fmt_mem(totals["mem_reservation_bytes"]),
},
"mem_limit": {
"bytes": totals["mem_limit_bytes"],
"human": _fmt_mem(totals["mem_limit_bytes"]),
},
"pids_limit": {"value": totals["pids_limit_int"]},
"cpus": {"value": totals["cpus_float"]},
"aggregation": {
"mem_reservation": "sum",
"mem_limit": "sum",
"pids_limit": "sum (max-provisioned; per-container cap, not shared load)",
"cpus": "max",
},
},
"warnings": warnings,
}
return json.dumps(payload, indent=2)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Compute aggregated container resources (mem_reservation, mem_limit, "
"pids_limit, cpus) for an Ansible role. Follows enabled shared services "
"recursively via the service registry. mem_reservation/mem_limit are "
"summed; pids_limit is summed as a max-provisioned host-pid budget "
"(per-container cap, not actual shared load); cpus is max (shared "
"across containers, not additive)."
)
)
parser.add_argument(
"--role",
required=True,
help="Role name (directory under roles/), e.g. web-app-peertube",
)
parser.add_argument(
"-f",
"--format",
choices=("text", "json"),
default="text",
help="Output format (default: text).",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
applications = load_applications_from_roles_dir(ROLES_DIR)
service_registry = build_service_registry_from_applications(applications)
rows: List[Dict[str, Any]] = []
warnings: List[str] = []
collect_role_resources(
role_name=args.role,
applications=applications,
service_registry=service_registry,
visited=set(),
rows=rows,
warnings=warnings,
)
totals = aggregate(rows)
if args.format == "json":
print(render_json(args.role, rows, totals, warnings))
else:
print(render_text(args.role, rows, totals, warnings))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -68,6 +68,13 @@ services:
container_name: infinito_nexus_${INFINITO_DISTRO:-debian}
restart: "no"
profiles: ["ci"]
# Resource caps for reproducing OOM/OOM-kill scenarios locally (e.g. issue #162
# web-app-peertube plugin install rc=137). Defaults of 0 map to "unlimited"
# in the Docker engine, so leaving these env vars unset behaves as if the
# keys were omitted.
mem_limit: ${INFINITO_MEM_LIMIT:-0}
memswap_limit: ${INFINITO_MEMSWAP_LIMIT:-0}
cpus: ${INFINITO_CPUS:-0}
<<: *ci_defaults
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
+40
View File
@@ -27,3 +27,43 @@ Prefer the manual workflow in [entry-manual.yml](../../../../.github/workflows/e
This gives faster feedback and protects shared CI runners.
More information [here](https://hub.infinito.nexus/t/infinito-nexus-ci-cd-debugging-guide/462).
## Reproducing Runner Resource Limits Locally 💾
Some CI failures — most notably OOM-kills such as [#162](https://github.com/infinito-nexus/core/issues/162) (PeerTube plugin install, `rc=137`) — only surface under the memory and CPU ceiling of the GitHub-hosted runner and cannot be reproduced on a workstation with plenty of RAM. To reproduce these failures locally, you MUST cap the resources of the top-level `infinito` service in [compose.yml](../../../../compose.yml) to values that match (or undercut) the runner specs below.
### GitHub-Hosted Runner Specs
All [workflows](../../../../.github/workflows/) target `runs-on: ubuntu-latest`, which maps to the GitHub-hosted standard Linux runner. The effective ceiling depends on the repository's visibility and plan:
| Repository / plan | vCPU | RAM | SSD |
|------------------------------------------|------|--------|-------|
| Public repo (this repo, `ubuntu-latest`) | 4 | 16 GB | 14 GB |
| Private repo on Free / Pro / Team | 2 | 7 GB | 14 GB |
| Private repo on Enterprise | 4 | 16 GB | 14 GB |
The effective budget for the actual workload is lower than the table suggests because the runner itself plus the nested `infinito` container (systemd, nested Docker, Ansible) reserve a non-trivial baseline. Plan for roughly **1–2 GB of overhead** before any role deploys a service.
For the authoritative specs, see the upstream docs for [standard hosted runners](https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories) and [larger runners](https://docs.github.com/en/actions/using-github-hosted-runners/using-larger-runners).
### Env-Var Knobs
[compose.yml](../../../../compose.yml) exposes three env vars on the `infinito` service that cap its memory, swap, and CPU ceiling. For the authoritative variable reference (names, defaults, purpose) see the SPOT in [compose.yml.md](../../artefact/files/compose.yml.md). This page SHOULD NOT duplicate that table and MUST link back to it when a contributor needs the precise definitions.
### Canonical Reproduction Profiles
Pick the profile that matches the runner you want to mimic:
```sh
# Public repo runner (ubuntu-latest, 4 vCPU / 16 GB) — the default CI target
INFINITO_MEM_LIMIT=16g INFINITO_MEMSWAP_LIMIT=16g INFINITO_CPUS=4 make <target>
# Private repo runner on Free/Pro/Team (2 vCPU / 7 GB)
INFINITO_MEM_LIMIT=7g INFINITO_MEMSWAP_LIMIT=7g INFINITO_CPUS=2 make <target>
# Aggressive OOM reproducer — forces the memory spike that surfaces #162
INFINITO_MEM_LIMIT=4g INFINITO_MEMSWAP_LIMIT=4g INFINITO_CPUS=2 make <target>
```
Verify the caps applied with `docker compose -f compose.yml --profile ci config | grep -E 'mem_limit|memswap_limit|cpus'` before starting the deploy.
@@ -0,0 +1,78 @@
# `compose.yml` 🐳
This page is the SPOT for rules that govern the top-level [compose.yml](../../../../compose.yml) and for the environment variables it consumes.
For general documentation rules (links, writing, RFC 2119 keywords), see [documentation.md](../../documentation.md).
For CI debugging workflows that use these variables, see [ci.md](../../actions/debugging/ci.md).
## Purpose 🎯
The top-level `compose.yml` is scoped to **local development and CI testing only**.
It MUST NOT be used to deploy or run a live Infinito.Nexus stack.
Production deployments MUST go through the Ansible roles and their own rendered compose files under `roles/<role>/templates/`.
Within its scope, `compose.yml` MUST stay a thin orchestration surface for the `infinito` runner container and its CoreDNS sidecar.
It MUST NOT host application roles, fixtures, or test harnesses.
## Structure 📐
- You MUST keep every service in `compose.yml` behind a `profiles:` entry so that `docker compose up` without a profile stays a no-op.
- You MUST expose tunable runtime parameters as env vars with safe defaults inline in the compose key (e.g. `${VAR:-default}`).
- You MUST NOT hardcode values that differ between operators (paths, image tags, CPU or memory caps).
- You SHOULD annotate non-obvious keys with a short inline comment that explains why they exist, not what they do.
- You MUST document every new env var in the table below when you add it to `compose.yml`.
## Environment Variables 📋
All variables consumed by [compose.yml](../../../../compose.yml). Variables without a default are REQUIRED when the `ci` profile is active.
### Image and Runtime
| Variable | Default | Purpose |
|--------------------------|-----------------|-------------------------------------------------------------------------|
| `INFINITO_IMAGE` | none (required) | Image reference used by the `infinito` service. |
| `INFINITO_PULL_POLICY` | `never` | Compose `pull_policy`. Keep `never` for local builds, `always` for CI. |
| `INFINITO_DISTRO` | `debian` | Distro suffix appended to the `container_name`. |
| `INFINITO_COMPILE` | `1` | Passed into the container; toggles in-container compilation steps. |
| `NIX_CONFIG` | none | Build-arg forwarded to the Dockerfile for Nix configuration. |
### Resource Caps (OOM Reproduction)
These variables cap the `infinito` container so local runs can reproduce GitHub-hosted runner pressure.
Each default is `0`, which Docker interprets as "unlimited" and is identical to omitting the key.
See [ci.md](../../actions/debugging/ci.md) for runner specs and reproduction profiles.
| Variable | Compose key | Default | Example |
|--------------------------|------------------|---------|---------|
| `INFINITO_MEM_LIMIT` | `mem_limit` | `0` | `16g` |
| `INFINITO_MEMSWAP_LIMIT` | `memswap_limit` | `0` | `16g` |
| `INFINITO_CPUS` | `cpus` | `0` | `4` |
You SHOULD set `INFINITO_MEMSWAP_LIMIT` to the same value as `INFINITO_MEM_LIMIT` to disable swap inflation.
Allowing swap masks OOM conditions that the real runner would hit.
### Storage
| Variable | Default | Purpose |
|---------------------------|--------------------|------------------------------------------------------------------|
| `INFINITO_DOCKER_VOLUME` | `docker` | Named volume (or host path) backing the nested Docker directory. |
| `INFINITO_DOCKER_MOUNT` | `/var/lib/docker` | Mount point inside the container for the nested Docker data. |
### Networking
| Variable | Default | Purpose |
|-------------------------------|------------------|------------------------------------------------------------------------|
| `DNS_IP` | none (required) | Static IP assigned to the CoreDNS sidecar and used as the DNS server. |
| `IP4` | none (required) | Static IPv4 address assigned to the `infinito` container. |
| `DOMAIN` | none (required) | Base domain exported into the container environment. |
| `BIND_IP` | `127.0.0.1` | Host address that all published ports bind to. |
| `SUBNET` | `172.30.0.0/24` | IPAM subnet for the default bridge network. |
| `GATEWAY` | `172.30.0.1` | IPAM gateway for the default bridge network. |
| `INFINITO_OUTER_NETWORK_MTU` | `1500` | MTU for the bridge network. Lower when the host network is tunneled. |
## Adding a Variable ➕
When you introduce a new env var in `compose.yml`, you MUST:
1. Use the `${VAR:-default}` form if a sensible default exists. If no default is safe, use `${VAR}` and treat the variable as REQUIRED.
2. Add a row to the matching table above with default and purpose.
3. Cross-link the variable from the relevant workflow page when the variable only exists to drive a specific workflow (e.g. resource caps link to [ci.md](../../actions/debugging/ci.md)).
@@ -50,20 +50,47 @@ name: "🚫 Cancel: PR Runs on Close"
## Disk space 💾
Deploy test workflows use the `jlumbroso/free-disk-space` action to reclaim runner space before Docker pulls start.
Deploy test workflows use the `jlumbroso/free-disk-space` action to reclaim runner space before Docker pulls start. Because the actual deploy runs **inside** the `infinito` container, the host's language toolchains, build packages, and default Docker layer cache are all dead weight and MUST be reclaimed aggressively.
| Option | Value | Reason |
|---|---|---|
| `tool-cache` | `true` | Deploy runs inside the `infinito` container; host Node/Python/Go/Ruby toolchains go unused |
| `android` | `true` | Android SDK is never needed; safe to remove |
| `dotnet` | `true` | .NET SDK is never needed; safe to remove |
| `haskell` | `true` | Haskell toolchain is never needed; safe to remove |
| `large-packages` | `false` | Can remove build tools (gcc, Python headers) required by pip/Ansible |
| `docker-images` | `false` | Can remove cached layers reused between deploy steps |
| `swap-storage` | `false` | Disabling swap can cause OOM for memory-intensive services (e.g. Keycloak) |
| `tool-cache` | `false` | Runner tool cache may be needed by subsequent steps |
| `large-packages` | `true` | Ansible, pip and gcc run **inside** the container, not on the host |
| `docker-images` | `true` | Matrix jobs don't share a Docker layer cache; each runner pulls its own `infinito` image |
| `swap-storage` | `true` | Default swap file is replaced by `pierotofy/set-swap-space` (see below) |
Only options that are **guaranteed unused** MUST be set to `true`.
When in doubt, keep the option at `false`.
Set an option back to `false` only when a new host-side step in the same workflow genuinely needs the removed payload.
## Swap 💾
Deploy test workflows enlarge host swap via [enlarge_swap.sh](../../../../../scripts/github/enlarge_swap.sh) to absorb transient memory spikes (e.g. PeerTube plugin install [#162](https://github.com/infinito-nexus/core/issues/162)) that would otherwise trip the host OOM-killer on the 16 GB GitHub-hosted runner.
The script **prefers `/mnt`** when it is a separate partition (classic `ubuntu-latest` layout with `/dev/sdb` mounted at `/mnt`) and falls back to `/` otherwise. Current public runners no longer expose a separate `/mnt`, so the swapfile lands on `/` and must not starve the root filesystem.
Swap size is a **fixed constant**, not a "free space minus buffer" calculation. An earlier version took ~99 GB on a 145 GB root partition, leaving so little headroom that later Docker layer writes (Playwright test image pull) failed with `no space left on device`. A fixed cap is large enough to absorb the peertube spike and small enough that Docker layer writes keep succeeding throughout the 30+ min deploy.
| Argument | Default | Reason |
|---|---|---|
| `size-gb` (positional) | `16` | Empirically sufficient for the peertube memory spike ([#162](https://github.com/infinito-nexus/core/issues/162)); raise only when a workload is proven to need more |
Workflow step invocation:
```yaml
- name: Enlarge swap space
shell: bash
run: ./scripts/github/enlarge_swap.sh
```
Ordering:
- Swap step MUST run **after** `actions/checkout` because the script lives inside the repo.
- Swap step SHOULD run **after** `jlumbroso/free-disk-space` so the reclaimed disk on `/` is also a candidate target when `/mnt` is crowded.
- Swap step MUST run **before** any `make up` / container build step so the expanded swap is active when heavy-allocation work begins.
Swap is a host-kernel resource; see [svc-opt-swapfile](../../../../../roles/svc-opt-swapfile/) for why the in-stack swap role is intentionally skipped inside containers.
## Separation of concerns 🧩
+10
View File
@@ -35,6 +35,16 @@ Use this rule when a role pins container image versions in `roles/web-*/config/m
- You MUST use `# nocheck: docker-version` directly above a `version:` key only for intentional exceptions that should not be flagged by the external image-version check.
- You SHOULD prefer explicit version pinning over drifting tags when the role depends on stable, reproducible deploys.
### Extracted Code 📤
Use this rule when a task body contains non-trivial shell, SQL, Python, JavaScript, or any other non-YAML code.
- You MUST extract non-trivial non-YAML code out of task files into a dedicated role asset instead of inlining it:
- Static scripts (shell, Python, binaries) belong in `roles/<role>/files/<name>.<ext>` and are invoked via `ansible.builtin.script` or `command`.
- Rendered code (SQL, JSON, JS, config snippets, env files) belongs in `roles/<role>/templates/<name>.<ext>.j2` and is consumed via `lookup('template', '<name>.<ext>.j2')` (for inline values) or `ansible.builtin.template` (for file deployment).
- The reason is reviewability and tooling. Extracted assets get proper syntax highlighting, linting, and diffing. Inline heredocs and multi-line `query:` or `command:` blocks hide all three and make the task file harder to read.
- One-liners such as a single `command:` or a short `shell:` pipeline MAY stay inline. The rule targets bodies that are themselves a program, not every shell call.
For documentation, comments, semantics, and writing guidance, see [documentation.md](../documentation.md).
For test commands and testing standards, see [testing.md](../actions/testing.md).
+4
View File
@@ -52,6 +52,10 @@ compose:
image: "ghcr.io/kevinveenbirkenbach/port-ui"
version: "1.1.0"
min_storage: "1GB"
cpus: "0.3"
mem_reservation: "256m"
mem_limit: "512m"
pids_limit: 256
css:
enabled: true
shared: true
+22
View File
@@ -61,56 +61,78 @@ compose:
mem_limit: "2g"
pids_limit: 512
admin:
enabled: true
shared: false
cpus: "0.3"
mem_reservation: "512m"
mem_limit: "1g"
pids_limit: 512
imap:
enabled: true
shared: false
cpus: "0.5"
mem_reservation: "1g"
mem_limit: "1.5g"
pids_limit: 512
smtp:
enabled: true
shared: false
cpus: "0.5"
mem_reservation: "1g"
mem_limit: "1.5g"
pids_limit: 512
antispam:
enabled: true
shared: false
cpus: "0.6"
mem_reservation: "1g"
mem_limit: "1.5g"
pids_limit: 512
antivirus:
enabled: true
shared: false
cpus: "0.6"
mem_reservation: "2g"
mem_limit: "3g"
pids_limit: 512
oletools:
enabled: true
shared: false
cpus: "0.2"
mem_reservation: "256m"
mem_limit: "512m"
pids_limit: 256
webdav:
enabled: true
shared: false
cpus: "0.2"
mem_reservation: "256m"
mem_limit: "512m"
pids_limit: 256
fetchmail:
enabled: true
shared: false
cpus: "0.2"
mem_reservation: "256m"
mem_limit: "512m"
pids_limit: 256
webmail:
enabled: true
shared: false
cpus: "0.3"
mem_reservation: "512m"
mem_limit: "1g"
pids_limit: 512
resolver:
enabled: true
shared: false
cpus: "0.2"
mem_reservation: "256m"
mem_limit: "512m"
pids_limit: 256
front:
enabled: true
shared: false
cpus: "0.3"
mem_reservation: "512m"
mem_limit: "1g"
+1 -1
View File
@@ -48,7 +48,7 @@ compose:
postgres:
enabled: true
shared: true
peertube:
peertube:
name: "peertube"
version: "production-bookworm"
image: "chocobozzz/peertube"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# roles/web-app-peertube/files/diagnostics.sh
#
# Best-effort diagnostics for the PeerTube container after a plugin-install
# failure. Prints container state, health, resource limits, logs, stats,
# host memory, and OOM-killer evidence. Always exits 0 — the ansible rescue
# step is responsible for re-raising the failure after the dump.
#
# Usage: diagnostics.sh <container-name>
# Env: PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB, PEERTUBE_MAX_OLD_SPACE_SIZE
# (optional — printed into the report for correlation).
set -u
CONTAINER="${1:-peertube}"
INSTALL_HEAP_MB="${PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB:-?}"
MAIN_HEAP_MB="${PEERTUBE_MAX_OLD_SPACE_SIZE:-?}"
echo "============================================================"
echo "=== PEERTUBE DIAGNOSTICS (plugin-install failure)"
echo "============================================================"
echo "container=${CONTAINER}"
echo "install_heap_mb=${INSTALL_HEAP_MB} main_heap_mb=${MAIN_HEAP_MB}"
echo
echo "--- State ---"
container inspect --format \
'Status={{.State.Status}} Running={{.State.Running}} Restarting={{.State.Restarting}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}} RestartCount={{.RestartCount}}' \
"${CONTAINER}" 2>&1 || true
echo
echo "--- Health ---"
container inspect --format \
'{{if .State.Health}}Status={{.State.Health.Status}} FailingStreak={{.State.Health.FailingStreak}}{{range .State.Health.Log}}{{println "log:" .ExitCode .Output}}{{end}}{{else}}no healthcheck configured{{end}}' \
"${CONTAINER}" 2>&1 || true
echo
echo "--- Resource limits ---"
container inspect --format \
'MemLimit={{.HostConfig.Memory}} MemSwap={{.HostConfig.MemorySwap}} NanoCpus={{.HostConfig.NanoCpus}} PidsLimit={{.HostConfig.PidsLimit}}' \
"${CONTAINER}" 2>&1 || true
echo
echo "--- container stats (single snapshot) ---"
container stats --no-stream --no-trunc "${CONTAINER}" 2>&1 || true
echo
echo "--- container logs (last 250 lines) ---"
container logs --tail 250 "${CONTAINER}" 2>&1 || true
echo
echo "--- Host memory + swap ---"
free -h 2>&1 || true
echo
swapon --show 2>&1 || true
echo
echo "--- dmesg tail (OOM-killer evidence, best effort) ---"
dmesg -T 2>/dev/null | tail -n 80 | grep -Ei 'oom|kill|memory' \
|| echo 'no oom/kill evidence in dmesg tail'
echo
echo "============================================================"
echo "=== END PEERTUBE DIAGNOSTICS"
echo "============================================================"
exit 0
@@ -0,0 +1,77 @@
# roles/web-app-peertube/filter_plugins/install_mem_limit.py
# Derive a temporary cgroup memory ceiling for the one-off
# `npm run plugin:install` exec.
#
# Usage (Jinja):
# {{ PEERTUBE_MEM_LIMIT
# | install_mem_limit(PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB) }}
#
# Formula:
# bytes(mem_limit) + install_heap_mb * overhead * 10^6
#
# Why `overhead` (default 4):
# `--max-old-space-size=<MB>` caps only V8 old-generation heap. Actual RSS
# during `npm install` is higher: V8 young-gen + code space, native malloc
# (node-gyp C++ builds, native bindings), and spawned postinstall workers
# with their own RSS. Empirically ~4× the heap cap for peertube plugin
# installs — the overhead is the headroom above the main peertube process
# (which keeps running in the same cgroup via `container exec`).
#
# Output: integer bytes. `container update --memory` / `docker update --memory`
# accepts a raw byte count.
from ansible.errors import AnsibleFilterError
try:
from plugins.filter.node_autosize import _to_bytes
except Exception as e:
raise AnsibleFilterError(
f"Failed to import _to_bytes from plugins.filter.node_autosize: {e}"
)
def install_mem_limit(mem_limit, install_heap_mb, overhead: int = 4) -> int:
"""
Return install-window cgroup memory ceiling in bytes.
Args:
mem_limit: compose mem_limit of the long-running service
(e.g. "8g", "512m", int bytes).
install_heap_mb: V8 old-space cap used for the install exec, in MB
(decimal, matches Node's --max-old-space-size unit).
overhead: multiplier applied to install_heap_mb to cover
native RSS + spawned workers (default 4).
"""
base_bytes = _to_bytes(mem_limit)
if base_bytes is None:
raise AnsibleFilterError(
f"install_mem_limit: mem_limit is empty/None: {mem_limit!r}"
)
try:
heap_mb = int(install_heap_mb)
except Exception:
raise AnsibleFilterError(
f"install_mem_limit: install_heap_mb must be int-like, got: {install_heap_mb!r}"
)
try:
ov = int(overhead)
except Exception:
raise AnsibleFilterError(
f"install_mem_limit: overhead must be int-like, got: {overhead!r}"
)
if heap_mb <= 0:
raise AnsibleFilterError(
f"install_mem_limit: install_heap_mb must be > 0, got: {heap_mb}"
)
if ov <= 0:
raise AnsibleFilterError(f"install_mem_limit: overhead must be > 0, got: {ov}")
return base_bytes + heap_mb * ov * 10**6
class FilterModule(object):
def filters(self):
return {"install_mem_limit": install_mem_limit}
@@ -1,62 +0,0 @@
---
- name: "Load OIDC Settings for Peertube"
include_vars: vars/oidc-settings.yml
changed_when: false
- name: Check if OIDC plugin is already installed
command: >
container exec {{ PEERTUBE_CONTAINER }} test -d /data/plugins/data/peertube-plugin-auth-openid-connect
register: peertube_oidc_plugin_check
failed_when: false
changed_when: false
- name: "Install auth-openid-connect plugin for Peertube"
# The plugin install spawns a fresh node process under the running PeerTube
# container. Without an explicit heap cap, V8 inherits the container-wide
# NODE_OPTIONS (~35% of mem_limit, multi-GB) and can push a constrained CI
# runner over its host RAM, getting SIGKILLed (rc=137) before the install
# completes. Force a tight heap and retry to absorb transient host pressure.
command: >
container exec
-e NODE_OPTIONS=--max-old-space-size={{ PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB }}
{{ PEERTUBE_CONTAINER }} npm run plugin:install -- --npm-name {{ PEERTUBE_OIDC_PLUGIN }}
register: peertube_oidc_install
changed_when: peertube_oidc_install.rc == 0
retries: 3
delay: 15
until: peertube_oidc_install.rc == 0
when: peertube_oidc_plugin_check.rc != 0
notify: compose up
- name: "Update the settings column of the auth-openid-connect plugin"
community.postgresql.postgresql_query:
db: "{{ lookup('database', application_id, 'name') }}"
login_user: "{{ lookup('database', application_id, 'username') }}"
login_password: "{{ lookup('database', application_id, 'password') }}"
login_host: "{{ lookup('database', application_id, 'reach_host') }}"
login_port: "{{ lookup('database', application_id, 'port') }}"
query: |
UPDATE public.plugin
SET settings = '{{ oidc_settings | to_json }}'::jsonb,
enabled = TRUE,
uninstalled = FALSE
WHERE name = 'auth-openid-connect'
AND (
settings IS DISTINCT FROM '{{ oidc_settings | to_json }}'::jsonb
OR enabled IS DISTINCT FROM TRUE
OR uninstalled IS DISTINCT FROM FALSE
);
register: _peertube_oidc_update
retries: 5
delay: 3
until: _peertube_oidc_update is succeeded
notify: compose up
- name: "Ensure plugin is enabled in PeerTube (CLI)"
command: >
container exec {{ PEERTUBE_CONTAINER }} npm run plugin:enable -- --npm-name {{ PEERTUBE_OIDC_PLUGIN }}
register: _peertube_enable
failed_when: false
changed_when: >
_peertube_enable.stdout is defined and
('already enabled' not in _peertube_enable.stdout)
+2 -2
View File
@@ -16,9 +16,9 @@
docker_compose_flush_handlers: true
- name: "Install and activate auth-openid-connect plugin if OIDC is enabled"
include_tasks: 01_enable-oidc.yml
include_tasks: oidc/enable.yml
when: PEERTUBE_OIDC_ENABLED | bool
- name: "Deinstall and disable auth-openid-connect plugin if OIDC is enabled"
include_tasks: 02_disable-oidc.yml
include_tasks: oidc/disable.yml
when: not PEERTUBE_OIDC_ENABLED | bool
@@ -0,0 +1,101 @@
---
- name: "Load OIDC Settings for Peertube"
include_vars: vars/oidc-settings.yml
changed_when: false
- name: Check if OIDC plugin is already installed
command: >
container exec {{ PEERTUBE_CONTAINER }} test -d /data/plugins/data/peertube-plugin-auth-openid-connect
register: peertube_oidc_plugin_check
failed_when: false
changed_when: false
- name: "Install auth-openid-connect plugin for Peertube (with temp mem_limit bump + diagnostics on failure)"
when: peertube_oidc_plugin_check.rc != 0
block:
# Raise the container cgroup ceiling for the duration of the install.
# The install node process and the long-running main peertube node
# process share this cgroup (install runs via `container exec`), and
# their combined RSS plus native allocations (node-gyp, npm postinstall)
# was tripping the runtime mem_limit and SIGKILLing peertube, surfacing
# as "Container is restarting" on subsequent exec attempts.
# --memory-swap -1 allows unlimited swap for the install window (host
# has a 16G swapfile from issue #162).
- name: "PEERTUBE | Raise mem_limit for plugin install"
ansible.builtin.command: >-
container update
--memory {{ PEERTUBE_OIDC_PLUGIN_INSTALL_MEM_LIMIT }}
--memory-swap -1
{{ PEERTUBE_CONTAINER }}
changed_when: true
- name: "Install auth-openid-connect plugin for Peertube"
# V8 heap is still capped tight — the cgroup bump above covers native
# memory and spawned workers, while --max-old-space-size keeps the
# install's V8 old-generation predictable.
ansible.builtin.command: >
container exec
-e NODE_OPTIONS=--max-old-space-size={{ PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB }}
{{ PEERTUBE_CONTAINER }} npm run plugin:install -- --npm-name {{ PEERTUBE_OIDC_PLUGIN }}
register: peertube_oidc_install
changed_when: peertube_oidc_install.rc == 0
retries: 3
delay: 15
until: peertube_oidc_install.rc == 0
notify: compose up
# Restore the compose-defined mem_limit on the happy path only.
# --memory-swap is pinned to the same value (no extra swap for the
# container) — matches the bump contract (extra swap granted solely
# during the install window). On failure the rescue block leaves the
# bumped limits in place so diagnostics reflect real install-time
# state; the next `compose up` recreates the container and reinstates
# docker's default memory-swap behavior from compose.yml.
- name: "PEERTUBE | Restore compose-defined mem_limit"
ansible.builtin.command: >-
container update
--memory {{ PEERTUBE_MEM_LIMIT }}
--memory-swap {{ PEERTUBE_MEM_LIMIT }}
{{ PEERTUBE_CONTAINER }}
changed_when: true
rescue:
- name: "PEERTUBE | Dump diagnostics after install failure"
ansible.builtin.script: "{{ role_path }}/files/diagnostics.sh {{ PEERTUBE_CONTAINER }}"
environment:
PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB: "{{ PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB }}"
PEERTUBE_MAX_OLD_SPACE_SIZE: "{{ PEERTUBE_MAX_OLD_SPACE_SIZE }}"
changed_when: false
failed_when: false
- name: "PEERTUBE | Fail explicitly after diagnostics"
ansible.builtin.fail:
msg: >-
Installing peertube-plugin-auth-openid-connect failed.
See PEERTUBE DIAGNOSTICS block above for state, OOMKilled, exit
code, logs, stats, host memory, and dmesg OOM-killer evidence.
- name: "Update the settings column of the auth-openid-connect plugin"
community.postgresql.postgresql_query:
db: "{{ lookup('database', application_id, 'name') }}"
login_user: "{{ lookup('database', application_id, 'username') }}"
login_password: "{{ lookup('database', application_id, 'password') }}"
login_host: "{{ lookup('database', application_id, 'reach_host') }}"
login_port: "{{ lookup('database', application_id, 'port') }}"
query: "{{ lookup('template', 'upsert-oidc-plugin.sql.j2') }}"
register: _peertube_oidc_update
retries: 5
delay: 3
until: _peertube_oidc_update is succeeded
notify: compose up
- name: "Ensure plugin is enabled in PeerTube (CLI)"
command: >
container exec {{ PEERTUBE_CONTAINER }} npm run plugin:enable -- --npm-name {{ PEERTUBE_OIDC_PLUGIN }}
register: _peertube_enable
failed_when: false
changed_when: >
_peertube_enable.stdout is defined and
('already enabled' not in _peertube_enable.stdout)
@@ -0,0 +1,10 @@
UPDATE public.plugin
SET settings = '{{ oidc_settings | to_json }}'::jsonb,
enabled = TRUE,
uninstalled = FALSE
WHERE name = 'auth-openid-connect'
AND (
settings IS DISTINCT FROM '{{ oidc_settings | to_json }}'::jsonb
OR enabled IS DISTINCT FROM TRUE
OR uninstalled IS DISTINCT FROM FALSE
);
+23 -5
View File
@@ -15,12 +15,30 @@ PEERTUBE_OIDC_ENABLED: "{{ lookup('config', application_id, 'compose.
# Performance
PEERTUBE_CPUS: "{{ lookup('config', application_id, 'compose.services.peertube.cpus') | float }}"
PEERTUBE_MEM_LIMIT: "{{ lookup('config', application_id, 'compose.services.peertube.mem_limit') }}"
PEERTUBE_MAX_OLD_SPACE_SIZE: "{{ applications | node_max_old_space_size(application_id, entity_name) }}"
_peertube_concurrency_candidate: "{{ ((PEERTUBE_CPUS | float) * 0.5) | round(0, 'floor') | int }}"
PEERTUBE_TRANSCODING_CONCURRENCY: "{{ [ ( [ (_peertube_concurrency_candidate | int), 1 ] | max ), 8 ] | min }}"
# Heap cap for the one-off `npm run plugin:install` exec. Kept tight so the
# plugin install does not blow past the host RAM budget on constrained CI
# runners (where the container-wide NODE_OPTIONS would otherwise grant several
# GB of V8 heap and trip the host OOM killer).
PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB: 1024
# Heap cap for the one-off `npm run plugin:install` exec. Derived relative to
# the peertube container's mem_limit (the installer runs inside that cgroup
# via `container exec`, so its budget is a share of the parent, not a
# separate service claim). pct=0.125 keeps the install heap well below the
# runtime heap (PEERTUBE_MAX_OLD_SPACE_SIZE uses 35%) because npm forks
# additional workers (node-gyp, postinstall) that each consume memory; hard-
# capped at 1024 MB to avoid tripping the host OOM-killer on constrained CI
# runners (see issue #162).
PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB: "{{ applications | node_max_old_space_size(application_id, entity_name, pct=0.125, min_mb=256, hardcap_mb=1024) }}"
# Temporary cgroup memory ceiling (bytes) during the one-off `npm run
# plugin:install`. Raised above compose.services.peertube.mem_limit so the
# install process and the long-running main peertube node process (both
# sharing the same cgroup via `container exec`) do not collectively trip the
# runtime mem_limit and get SIGKILLed — which was manifesting as "Container
# is restarting" on the plugin install exec. Restored to the compose-defined
# value by the happy-path tail of the block in tasks/oidc/enable.yml.
#
# Formula: mem_limit_bytes + install_heap_mb * overhead * 10^6
# overhead=4 matches the empirical heap-to-RSS ratio for `npm install` with
# native deps (node-gyp, postinstall workers each carry their own RSS).
PEERTUBE_OIDC_PLUGIN_INSTALL_MEM_LIMIT: "{{ PEERTUBE_MEM_LIMIT | install_mem_limit(PEERTUBE_OIDC_PLUGIN_INSTALL_MAX_OLD_SPACE_MB) }}"
+4
View File
@@ -10,6 +10,10 @@ compose:
asset:
enabled: false
shared: true
cpus: "0.2"
mem_reservation: "128m"
mem_limit: "256m"
pids_limit: 128
# Assets are published through the shared file host under /assets.
# Keep the file dependency here so consumers only depend on web-svc-asset.
file:
+4
View File
@@ -10,6 +10,10 @@ compose:
cdn:
enabled: false
shared: true
cpus: "0.2"
mem_reservation: "128m"
mem_limit: "256m"
pids_limit: 128
matomo:
enabled: true
shared: true
+4
View File
@@ -8,6 +8,10 @@ compose:
file:
enabled: false
shared: true
cpus: "0.2"
mem_reservation: "128m"
mem_limit: "256m"
pids_limit: 128
dashboard:
enabled: true
shared: true
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Dump disk, memory, swap and docker state of a GitHub-hosted runner.
# Intended as a diagnostic helper that can be called from anywhere
# inside a workflow step (pre/post swap resize, post-deploy failure).
#
# Usage: dump_runner_state.sh [label]
# The label is rendered into the log group title and the step summary.
set -euo pipefail
LABEL="${1:-runner state}"
echo "::group::Runner state — ${LABEL}"
echo "--- date ---"
date -u +'%Y-%m-%dT%H:%M:%SZ'
echo "--- disk (bytes) ---"
df -h / /mnt 2>/dev/null || df -h /
echo "--- disk (inodes) ---"
df -hi / /mnt 2>/dev/null || df -hi /
echo "--- block devices ---"
lsblk -f || true
echo "--- mounts (/, /mnt, /var/lib/docker, /mnt/docker) ---"
mount | grep -E ' / | /mnt | /var/lib/docker | /mnt/docker ' || true
echo "--- docker ---"
docker info 2>/dev/null |
grep -E 'Docker Root Dir|Storage Driver|Containers|Images' ||
echo "docker daemon not reachable"
echo "--- memory ---"
free -h
echo "--- swap ---"
swapon --show || echo "no swap active"
echo "--- largest dirs on / (top 15) ---"
sudo du -xh --max-depth=2 / 2>/dev/null | sort -h | tail -15 || true
if [ -d /mnt ]; then
echo "--- largest dirs on /mnt (top 15) ---"
sudo du -xh --max-depth=2 /mnt 2>/dev/null | sort -h | tail -15 || true
fi
# Nested view — hlth-disc-space runs INSIDE the infinito container and
# evaluates df there, so host-side df alone can be misleading. These
# blocks silently no-op when the container is not (yet) running.
echo "--- infinito container (nested view) ---"
INFINITO_CONTAINER="$(docker ps --filter 'name=infinito_nexus' --format '{{.Names}}' 2>/dev/null | head -1 || true)"
if [ -n "${INFINITO_CONTAINER}" ]; then
echo "container: ${INFINITO_CONTAINER}"
echo "--- df inside container ---"
docker exec "${INFINITO_CONTAINER}" df -h 2>&1 || echo "docker exec df failed"
echo "--- df --output=pcent inside container (what hlth-disc-space sees) ---"
docker exec "${INFINITO_CONTAINER}" df --output=pcent 2>&1 || true
echo "--- failed systemd units inside container ---"
docker exec "${INFINITO_CONTAINER}" systemctl --failed --no-pager 2>&1 ||
echo "systemctl --failed not reachable"
echo "--- hlth-disc-space status ---"
docker exec "${INFINITO_CONTAINER}" \
systemctl status 'hlth-disc-space*' --no-pager -l 2>&1 ||
echo "no hlth-disc-space unit (not deployed yet)"
echo "--- hlth-disc-space journal (last 80 lines) ---"
docker exec "${INFINITO_CONTAINER}" \
journalctl -u 'hlth-disc-space*' -n 80 --no-pager 2>&1 ||
echo "journal for hlth-disc-space not accessible"
else
echo "no infinito container running (expected before 'make up')"
fi
echo "::endgroup::"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
root_free=$(df --output=avail -B1G / 2>/dev/null | tail -n1 | tr -d ' ' || echo '?')
mnt_free=$(df --output=avail -B1G /mnt 2>/dev/null | tail -n1 | tr -d ' ' || echo '?')
mem_free=$(free -g | awk '/^Mem:/ {print $4"G"}')
swap_total=$(free -g | awk '/^Swap:/ {print $2"G"}')
{
echo "### Runner state — ${LABEL}"
echo "- / free: **${root_free}G** · /mnt free: **${mnt_free}G**"
echo "- mem free: **${mem_free}** · swap total: **${swap_total}**"
} >>"$GITHUB_STEP_SUMMARY"
fi
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Enlarge host swap on a GitHub-hosted runner. Replaces the runner's
# default ~4 GB swap with a fixed-size swapfile so transient memory
# spikes (e.g. peertube plugin install, issue #162) don't trip the host
# OOM-killer, WITHOUT starving the rest of the job of disk space.
#
# Prefers /mnt when it is a separate partition (classic ubuntu-latest
# layout with /dev/sdb mounted at /mnt). On current public runners
# there is no separate /mnt partition — everything lives on /, so we
# place the swapfile there. The swap size is a hard constant, never a
# "free minus buffer" computation: a previous version consumed ~99 GB
# on a 145 GB root, leaving so little headroom that later Docker layer
# writes (Playwright test image pull) failed with ENOSPC. See #162.
#
# Usage: enlarge_swap.sh [size-gb]
# Default: 16 GB (empirically sufficient for the peertube spike).
#
# Debug toggle: set SKIP_SWAP=1 to leave the runner's default swap
# untouched (A/B probe for issues caused by this script itself).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DUMP="${HERE}/dump_runner_state.sh"
if [ "${SKIP_SWAP:-0}" = "1" ]; then
echo "SKIP_SWAP=1 — leaving default runner swap in place."
"$DUMP" "skip-swap (SKIP_SWAP=1)"
exit 0
fi
SWAP_GB="${1:-16}"
MIN_FREE_GB=$((SWAP_GB + 4)) # need the target size plus a little slack
"$DUMP" "before swap resize"
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
# Only treat /mnt as a candidate if it is actually a separate mount.
# On current ubuntu-latest public runners /mnt is an empty directory
# on the root fs, which would double-count free space.
if mountpoint -q /mnt 2>/dev/null; then
mnt_free=$(df --output=avail -B1G /mnt | tail -n1 | tr -d ' ')
else
mnt_free=0
fi
root_free=$(df --output=avail -B1G / | tail -n1 | tr -d ' ')
echo "Target swap: ${SWAP_GB}G. Free: / ${root_free}G, /mnt ${mnt_free}G (separate mount: $(mountpoint -q /mnt 2>/dev/null && echo yes || echo no))"
if [ "$mnt_free" -ge "$MIN_FREE_GB" ]; then
target=/mnt/swapfile
chosen="/mnt (separate partition)"
elif [ "$root_free" -ge "$MIN_FREE_GB" ]; then
target=/swapfile
chosen="/ (no separate /mnt)"
else
echo "Neither / nor /mnt has ${MIN_FREE_GB} GB free for a ${SWAP_GB} GB swapfile" >&2
df -h / /mnt >&2
exit 1
fi
echo "Chose ${chosen}: creating ${SWAP_GB}G swapfile at ${target}"
sudo fallocate -l "${SWAP_GB}G" "$target"
sudo chmod 600 "$target"
sudo mkswap "$target"
sudo swapon "$target"
"$DUMP" "after swap resize → ${SWAP_GB}G at ${target}"
Whitespace-only changes.
@@ -0,0 +1,552 @@
from __future__ import annotations
import io
import json
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
class TestParsingHelpers(unittest.TestCase):
def test_parse_mem_bytes_handles_various_units(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertEqual(cli._parse_mem_bytes("1g"), 1_000_000_000)
self.assertEqual(cli._parse_mem_bytes("512m"), 512_000_000)
self.assertEqual(cli._parse_mem_bytes("256M"), 256_000_000)
self.assertEqual(cli._parse_mem_bytes(1024), 1024)
self.assertIsNone(cli._parse_mem_bytes(None))
self.assertIsNone(cli._parse_mem_bytes(""))
self.assertIsNone(cli._parse_mem_bytes("not-a-size"))
def test_parse_cpus(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertEqual(cli._parse_cpus(4), 4.0)
self.assertEqual(cli._parse_cpus("2.0"), 2.0)
self.assertEqual(cli._parse_cpus("0.5"), 0.5)
self.assertIsNone(cli._parse_cpus(None))
self.assertIsNone(cli._parse_cpus("abc"))
def test_parse_int(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertEqual(cli._parse_int(2048), 2048)
self.assertEqual(cli._parse_int("1024"), 1024)
self.assertIsNone(cli._parse_int(None))
self.assertIsNone(cli._parse_int(True))
self.assertIsNone(cli._parse_int("not-int"))
def test_is_enabled_primary_defaults_true_when_key_missing(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertTrue(cli._is_enabled({}, is_primary=True))
self.assertFalse(cli._is_enabled({}, is_primary=False))
def test_is_enabled_respects_explicit_flag(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertTrue(cli._is_enabled({"enabled": True}, is_primary=False))
self.assertFalse(cli._is_enabled({"enabled": False}, is_primary=True))
self.assertFalse(cli._is_enabled({"enabled": "false"}, is_primary=True))
self.assertFalse(cli._is_enabled({"enabled": "0"}, is_primary=True))
def test_is_enabled_treats_template_strings_as_truthy(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertTrue(
cli._is_enabled(
{"enabled": "{{ RECAPTCHA_ENABLED | bool }}"},
is_primary=False,
)
)
def test_is_shared(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertTrue(cli._is_shared({"shared": True}))
self.assertTrue(cli._is_shared({"shared": "true"}))
self.assertFalse(cli._is_shared({}))
self.assertFalse(cli._is_shared({"shared": False}))
def test_looks_like_container(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
self.assertTrue(cli._looks_like_container({"mem_limit": "256m"}))
self.assertTrue(cli._looks_like_container({"cpus": 1}))
self.assertTrue(cli._looks_like_container({"image": "alpine"}))
self.assertTrue(cli._looks_like_container({"name": "foo", "version": "1"}))
self.assertFalse(cli._looks_like_container({}))
self.assertFalse(cli._looks_like_container({"enabled": True}))
class TestAggregate(unittest.TestCase):
def _row(
self,
mem_res: int | None = None,
mem_lim: int | None = None,
pids: int | None = None,
cpus: float | None = None,
) -> dict:
return {
"role": "r",
"service": "s",
"mem_reservation_raw": None,
"mem_limit_raw": None,
"pids_limit_raw": None,
"cpus_raw": None,
"mem_reservation_bytes": mem_res,
"mem_limit_bytes": mem_lim,
"pids_limit_int": pids,
"cpus_float": cpus,
}
def test_sums_mem_and_pids_and_takes_max_cpus(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
rows = [
self._row(mem_res=1_000, mem_lim=2_000, pids=100, cpus=2.0),
self._row(mem_res=500, mem_lim=1_000, pids=50, cpus=4.0),
self._row(mem_res=250, mem_lim=500, pids=25, cpus=1.0),
]
totals = cli.aggregate(rows)
self.assertEqual(totals["mem_reservation_bytes"], 1_750)
self.assertEqual(totals["mem_limit_bytes"], 3_500)
self.assertEqual(totals["pids_limit_int"], 175)
self.assertEqual(totals["cpus_float"], 4.0)
def test_returns_none_when_all_values_missing(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
totals = cli.aggregate([self._row(), self._row()])
self.assertIsNone(totals["mem_reservation_bytes"])
self.assertIsNone(totals["mem_limit_bytes"])
self.assertIsNone(totals["pids_limit_int"])
self.assertIsNone(totals["cpus_float"])
def test_ignores_none_entries_for_individual_columns(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
rows = [
self._row(mem_lim=1_000, cpus=2.0),
self._row(pids=10),
]
totals = cli.aggregate(rows)
self.assertIsNone(totals["mem_reservation_bytes"])
self.assertEqual(totals["mem_limit_bytes"], 1_000)
self.assertEqual(totals["pids_limit_int"], 10)
self.assertEqual(totals["cpus_float"], 2.0)
class TestCollectRoleResources(unittest.TestCase):
def _fake_registry(self) -> dict:
return {
"oidc": {"role": "web-app-keycloak"},
"email": {"role": "web-app-mailu"},
"postgres": {"role": "svc-db-postgres"},
}
def _fake_applications(self) -> dict:
return {
"web-app-peertube": {
"compose": {
"services": {
"peertube": {
"cpus": 4,
"mem_reservation": "4g",
"mem_limit": "8g",
"pids_limit": 2048,
},
"redis": {
"enabled": True,
"cpus": "0.5",
"mem_reservation": "256m",
"mem_limit": "512m",
"pids_limit": 512,
},
"oidc": {"enabled": True, "shared": True},
"postgres": {"enabled": True, "shared": True},
"email": {"enabled": True, "shared": True},
"css": {"enabled": False, "shared": True},
}
}
},
"web-app-keycloak": {
"compose": {
"services": {
"keycloak": {
"cpus": "2.0",
"mem_reservation": "2g",
"mem_limit": "4g",
"pids_limit": 1024,
},
}
}
},
"web-app-mailu": {
"compose": {
"services": {
"mailu": {},
"oidc": {"enabled": True, "shared": True},
}
}
},
"svc-db-postgres": {
"compose": {
"services": {
"postgres": {
"cpus": 2,
"mem_reservation": "4g",
"mem_limit": "6g",
"pids_limit": 1024,
}
}
}
},
}
def test_primary_and_sidecar_grouped_then_shared_deps(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
rows: list = []
warnings: list = []
cli.collect_role_resources(
role_name="web-app-peertube",
applications=self._fake_applications(),
service_registry=self._fake_registry(),
visited=set(),
rows=rows,
warnings=warnings,
)
labels = [(r["role"], r["service"]) for r in rows]
self.assertEqual(labels[0], ("web-app-peertube", "peertube"))
self.assertEqual(labels[1], ("web-app-peertube", "redis"))
self.assertIn(("web-app-keycloak", "keycloak"), labels)
self.assertIn(("svc-db-postgres", "postgres"), labels)
self.assertIn(("web-app-mailu", "mailu"), labels)
peertube_pos = labels.index(("web-app-peertube", "redis"))
keycloak_pos = labels.index(("web-app-keycloak", "keycloak"))
self.assertLess(peertube_pos, keycloak_pos)
def test_disabled_services_are_skipped(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
rows: list = []
cli.collect_role_resources(
role_name="web-app-peertube",
applications=self._fake_applications(),
service_registry=self._fake_registry(),
visited=set(),
rows=rows,
warnings=[],
)
labels = {(r["role"], r["service"]) for r in rows}
self.assertNotIn(("web-app-peertube", "css"), labels)
def test_cycle_protection_visits_each_role_once(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
# mailu declares oidc (-> keycloak). keycloak could declare email in
# theory. Build a cycle keycloak <-> mailu and ensure no infinite loop.
apps = self._fake_applications()
apps["web-app-keycloak"]["compose"]["services"]["email"] = {
"enabled": True,
"shared": True,
}
rows: list = []
cli.collect_role_resources(
role_name="web-app-peertube",
applications=apps,
service_registry=self._fake_registry(),
visited=set(),
rows=rows,
warnings=[],
)
roles_in_rows = [r["role"] for r in rows]
self.assertEqual(roles_in_rows.count("web-app-keycloak"), 1)
self.assertEqual(roles_in_rows.count("web-app-mailu"), 1)
def test_warns_when_shared_service_has_no_provider(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
apps = {
"web-app-x": {
"compose": {
"services": {
"x": {"cpus": 1},
"unknown": {"enabled": True, "shared": True},
}
}
}
}
warnings: list = []
cli.collect_role_resources(
role_name="web-app-x",
applications=apps,
service_registry={},
visited=set(),
rows=[],
warnings=warnings,
)
self.assertTrue(any("unknown" in w for w in warnings))
def test_toggle_only_local_entries_are_skipped(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
apps = {
"web-app-x": {
"compose": {
"services": {
"x": {"cpus": 1, "mem_limit": "100m"},
"feature_flag": {"enabled": True},
}
}
}
}
rows: list = []
cli.collect_role_resources(
role_name="web-app-x",
applications=apps,
service_registry={},
visited=set(),
rows=rows,
warnings=[],
)
services_in_rows = {r["service"] for r in rows}
self.assertIn("x", services_in_rows)
self.assertNotIn("feature_flag", services_in_rows)
def test_warns_when_role_config_missing(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
warnings: list = []
cli.collect_role_resources(
role_name="missing-role",
applications={},
service_registry={},
visited=set(),
rows=[],
warnings=warnings,
)
self.assertTrue(any("missing-role" in w for w in warnings))
def test_shared_entry_without_registered_provider_does_not_recurse(
self,
) -> None:
from cli.meta.applications.ressources import __main__ as cli
apps = {
"web-app-y": {
"compose": {
"services": {
"y": {"cpus": 1, "mem_limit": "100m"},
"ghost": {"enabled": True, "shared": True},
}
}
}
}
rows: list = []
cli.collect_role_resources(
role_name="web-app-y",
applications=apps,
service_registry={},
visited=set(),
rows=rows,
warnings=[],
)
self.assertEqual(
[(r["role"], r["service"]) for r in rows], [("web-app-y", "y")]
)
class TestRenderText(unittest.TestCase):
def test_sorts_by_service_then_role_and_uses_labeled_total(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
rows = [
{
"role": "web-app-peertube",
"service": "redis",
"mem_reservation_raw": "256m",
"mem_limit_raw": "512m",
"pids_limit_raw": 512,
"cpus_raw": "0.5",
"mem_reservation_bytes": 256_000_000,
"mem_limit_bytes": 512_000_000,
"pids_limit_int": 512,
"cpus_float": 0.5,
},
{
"role": "web-app-mailu",
"service": "redis",
"mem_reservation_raw": "256m",
"mem_limit_raw": "512m",
"pids_limit_raw": 256,
"cpus_raw": "0.2",
"mem_reservation_bytes": 256_000_000,
"mem_limit_bytes": 512_000_000,
"pids_limit_int": 256,
"cpus_float": 0.2,
},
{
"role": "web-app-peertube",
"service": "peertube",
"mem_reservation_raw": "4g",
"mem_limit_raw": "8g",
"pids_limit_raw": 2048,
"cpus_raw": 4,
"mem_reservation_bytes": 4_000_000_000,
"mem_limit_bytes": 8_000_000_000,
"pids_limit_int": 2048,
"cpus_float": 4.0,
},
]
totals = cli.aggregate(rows)
text = cli.render_text("web-app-peertube", rows, totals, warnings=[])
lines = text.splitlines()
data_lines = [ln for ln in lines if ln and not ln.startswith(("#", "-"))]
# First body line is header, then sorted data rows.
self.assertTrue(data_lines[0].startswith("service"))
self.assertTrue(data_lines[1].startswith("peertube"))
# The two redis rows sort after peertube, mailu before peertube by role.
self.assertIn("redis", data_lines[2])
self.assertIn("web-app-mailu", data_lines[2])
self.assertIn("redis", data_lines[3])
self.assertIn("web-app-peertube", data_lines[3])
# Total row uses the labeled form.
self.assertTrue(
any(
"TOTAL (mem=SUM, pids=SUM max-provisioned, cpus=MAX)" in ln
for ln in lines
)
)
def test_appends_warnings_section(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
text = cli.render_text(
role_name="web-app-x",
rows=[],
totals=cli.aggregate([]),
warnings=["shared service 'foo' has no registered provider"],
)
self.assertIn("# Warnings", text)
self.assertIn("! shared service 'foo' has no registered provider", text)
class TestRenderJson(unittest.TestCase):
def test_emits_services_totals_warnings_and_aggregation_metadata(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
rows = [
{
"role": "web-app-peertube",
"service": "peertube",
"mem_reservation_raw": "4g",
"mem_limit_raw": "8g",
"pids_limit_raw": 2048,
"cpus_raw": 4,
"mem_reservation_bytes": 4_000_000_000,
"mem_limit_bytes": 8_000_000_000,
"pids_limit_int": 2048,
"cpus_float": 4.0,
}
]
totals = cli.aggregate(rows)
payload = json.loads(
cli.render_json("web-app-peertube", rows, totals, warnings=["w"])
)
self.assertEqual(payload["role"], "web-app-peertube")
self.assertEqual(len(payload["services"]), 1)
self.assertEqual(payload["totals"]["mem_limit"]["bytes"], 8_000_000_000)
self.assertEqual(payload["totals"]["cpus"]["value"], 4.0)
self.assertEqual(payload["totals"]["aggregation"]["cpus"], "max")
self.assertTrue(
payload["totals"]["aggregation"]["pids_limit"].startswith("sum")
)
self.assertEqual(payload["warnings"], ["w"])
class TestCliMain(unittest.TestCase):
def test_text_format_runs_end_to_end(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
fake_apps = {
"web-app-x": {
"compose": {
"services": {
"x": {
"cpus": 1,
"mem_reservation": "100m",
"mem_limit": "200m",
"pids_limit": 64,
}
}
}
}
}
with (
patch.object(
cli, "load_applications_from_roles_dir", return_value=fake_apps
),
patch.object(
cli, "build_service_registry_from_applications", return_value={}
),
patch.object(cli.sys, "argv", ["prog", "--role", "web-app-x"]),
):
out = io.StringIO()
with redirect_stdout(out):
rc = cli.main()
self.assertEqual(rc, 0)
output = out.getvalue()
self.assertIn("web-app-x", output)
self.assertIn("TOTAL", output)
def test_json_format_runs_end_to_end(self) -> None:
from cli.meta.applications.ressources import __main__ as cli
fake_apps = {
"web-app-x": {
"compose": {
"services": {
"x": {
"cpus": 2,
"mem_reservation": "1g",
"mem_limit": "2g",
"pids_limit": 128,
}
}
}
}
}
with (
patch.object(
cli, "load_applications_from_roles_dir", return_value=fake_apps
),
patch.object(
cli, "build_service_registry_from_applications", return_value={}
),
patch.object(
cli.sys,
"argv",
["prog", "--role", "web-app-x", "--format", "json"],
),
):
out = io.StringIO()
with redirect_stdout(out):
rc = cli.main()
self.assertEqual(rc, 0)
payload = json.loads(out.getvalue())
self.assertEqual(payload["role"], "web-app-x")
self.assertEqual(payload["totals"]["mem_limit"]["bytes"], 2_000_000_000)
self.assertEqual(payload["totals"]["cpus"]["value"], 2.0)
if __name__ == "__main__":
unittest.main()
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,112 @@
import unittest
import pathlib
import importlib.util
from ansible.errors import AnsibleFilterError
def _load_install_mem_limit_module():
"""
Load the install_mem_limit.py filter plugin module from the
roles/web-app-peertube path, without requiring the roles directory to
be a Python package.
"""
# tests/unit/roles/web-app-peertube/filter_plugins/test_install_mem_limit.py
test_file = pathlib.Path(__file__).resolve()
repo_root = test_file.parents[5]
plugin_path = (
repo_root
/ "roles"
/ "web-app-peertube"
/ "filter_plugins"
/ "install_mem_limit.py"
)
if not plugin_path.is_file():
raise RuntimeError(f"Could not find install_mem_limit.py at {plugin_path}")
spec = importlib.util.spec_from_file_location("install_mem_limit", plugin_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
_mod = _load_install_mem_limit_module()
install_mem_limit = _mod.install_mem_limit
FilterModule = _mod.FilterModule
class TestInstallMemLimitFilter(unittest.TestCase):
def test_registry_contains_filter(self):
self.assertIn("install_mem_limit", FilterModule().filters())
def test_default_overhead_with_string_mem_limit(self):
# "8g" = 8_000_000_000 bytes (decimal, matches _to_bytes semantics)
# install_heap_mb=1024, overhead=4 -> 1024 * 4 * 10^6 = 4_096_000_000
# total = 12_096_000_000
self.assertEqual(install_mem_limit("8g", 1024), 12_096_000_000)
def test_int_mem_limit_is_treated_as_bytes(self):
self.assertEqual(install_mem_limit(8_000_000_000, 1024), 12_096_000_000)
def test_custom_overhead(self):
# "8g" + 1024 * 2 * 10^6 = 10_048_000_000
self.assertEqual(install_mem_limit("8g", 1024, overhead=2), 10_048_000_000)
def test_small_mem_limit(self):
# "512m" = 512_000_000; 256 MB heap * 4 = 1_024_000_000
# total = 1_536_000_000
self.assertEqual(install_mem_limit("512m", 256), 1_536_000_000)
def test_binary_suffix_gib(self):
# "1Gib" = 1 GiB = 1_073_741_824; 128 * 4 * 10^6 = 512_000_000
self.assertEqual(install_mem_limit("1Gib", 128), 1_073_741_824 + 512_000_000)
def test_string_int_install_heap_mb_accepted(self):
# Ansible Jinja often passes stringly-typed numbers
self.assertEqual(install_mem_limit("8g", "1024"), 12_096_000_000)
def test_none_mem_limit_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit(None, 1024)
def test_empty_mem_limit_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("", 1024)
def test_unparseable_mem_limit_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("not-a-size", 1024)
def test_non_int_install_heap_mb_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("8g", "not-a-number")
def test_non_int_overhead_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("8g", 1024, overhead="lots")
def test_zero_install_heap_mb_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("8g", 0)
def test_negative_install_heap_mb_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("8g", -1)
def test_zero_overhead_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("8g", 1024, overhead=0)
def test_negative_overhead_raises(self):
with self.assertRaises(AnsibleFilterError):
install_mem_limit("8g", 1024, overhead=-1)
def test_result_is_int(self):
self.assertIsInstance(install_mem_limit("8g", 1024), int)
if __name__ == "__main__":
unittest.main()