From 6e2b2f5e3a1bebe4a221dc7d9c0502c51bc6989a Mon Sep 17 00:00:00 2001 From: copilotcoder Date: Sun, 5 Jul 2026 11:40:31 -0400 Subject: [PATCH] feat(runners): cap runner memory by host fraction --- .../gitea-actions/deploy-runner-config.xsh | 99 ++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/scripts/gitea-actions/deploy-runner-config.xsh b/scripts/gitea-actions/deploy-runner-config.xsh index 9ea40fa..adc970c 100755 --- a/scripts/gitea-actions/deploy-runner-config.xsh +++ b/scripts/gitea-actions/deploy-runner-config.xsh @@ -4,6 +4,7 @@ import os import subprocess +import sys import tempfile from datetime import datetime @@ -17,6 +18,23 @@ HOSTS = { "pi-desktop.darkhelm.lan": "/home/darkhelm/Projects/DarkHelm.org/gitea-runner", } +DEFAULT_MEM_FRACTION = 0.75 +MIN_RUNNER_MEM_MIB = 1024 + + +def parse_mem_fraction(argv): + fraction = DEFAULT_MEM_FRACTION + for arg in argv[1:]: + if arg.startswith("--mem-fraction="): + _, value = arg.split("=", 1) + try: + fraction = float(value) + except ValueError as exc: + raise SystemExit(f"Invalid --mem-fraction value: {value}") from exc + if fraction <= 0 or fraction > 1: + raise SystemExit("--mem-fraction must be > 0 and <= 1") + return fraction + def ssh(host, cmd): proc = subprocess.run( [ @@ -55,7 +73,52 @@ def scp(local_path, host, remote_path): return proc.returncode, proc.stdout + proc.stderr -def build_compose(compose_text): +def upsert_service_key(compose_text, service_names, key, value): + lines = compose_text.splitlines() + i = 0 + + while i < len(lines): + stripped = lines[i].strip() + if stripped.rstrip(":") not in service_names or not stripped.endswith(":"): + i += 1 + continue + + service_indent = len(lines[i]) - len(lines[i].lstrip(" ")) + field_indent = " " * (service_indent + 2) + + block_start = i + 1 + block_end = len(lines) + j = block_start + while j < len(lines): + cur = lines[j] + cur_stripped = cur.strip() + if cur_stripped: + cur_indent = len(cur) - len(cur.lstrip(" ")) + if cur_indent <= service_indent: + block_end = j + break + j += 1 + + key_prefix = f"{key}:" + found_idx = None + for k in range(block_start, block_end): + if lines[k].strip().startswith(key_prefix): + found_idx = k + break + + new_line = f"{field_indent}{key}: {value}" + if found_idx is not None: + lines[found_idx] = new_line + i = block_end + continue + + lines.insert(block_start, new_line) + i = block_end + 1 + + return "\n".join(lines) + "\n" + + +def build_compose(compose_text, runner_mem_limit): updated = compose_text volume_line = " - ./daemon.json:/etc/docker/daemon.json:ro" @@ -68,9 +131,31 @@ def build_compose(compose_text): if add_cfg_env not in updated and force_pull_line in updated: updated = updated.replace(force_pull_line, force_pull_line + "\n" + add_cfg_env) + updated = upsert_service_key( + updated, + {"act_runner_1", "act_runner_2", "runner1", "runner2"}, + "mem_limit", + runner_mem_limit, + ) + return updated +def host_mem_limit(host, fraction): + rc, mem_kib = ssh(host, "awk '/MemTotal:/ {print $2}' /proc/meminfo") + if rc != 0 or not mem_kib.strip().isdigit(): + raise RuntimeError(f"Could not read MemTotal on {host}") + + total_kib = int(mem_kib.strip()) + target_mib = int((total_kib / 1024.0) * fraction) + if target_mib < MIN_RUNNER_MEM_MIB: + target_mib = MIN_RUNNER_MEM_MIB + return f"{target_mib}m", target_mib + + +mem_fraction = parse_mem_fraction(sys.argv) + +print(f"Runner mem fraction: {mem_fraction:.2f}") print("Fetching canonical runner config template") rc, template = ssh(TEMPLATE_HOST, f"cat {TEMPLATE_PATH}") if rc != 0 or not template.strip(): @@ -87,12 +172,20 @@ with tempfile.TemporaryDirectory() as tmpdir: for host, root in HOSTS.items(): print(f"\n===== {host} =====") + try: + runner_mem_limit, runner_mem_mib = host_mem_limit(host, mem_fraction) + except RuntimeError as exc: + print(str(exc)) + continue + + print(f"runner_mem_limit={runner_mem_limit} ({runner_mem_mib} MiB)") + rc, compose = ssh(host, f"cat {root}/compose.yml") if rc != 0: print("Could not read compose.yml") continue - new_compose = build_compose(compose) + new_compose = build_compose(compose, runner_mem_limit) rc, _ = ssh(host, f"mkdir -p {root}/backups") rc, _ = ssh(host, f"cp -f {root}/compose.yml {root}/backups/compose.yml.{now}") @@ -120,6 +213,8 @@ with tempfile.TemporaryDirectory() as tmpdir: print(out.strip()) rc, out = ssh(host, f"grep -n 'CONFIG_FILE=/config.yaml' {root}/compose.yml") print(out.strip()) + rc, out = ssh(host, f"grep -n 'mem_limit:' {root}/compose.yml") + print(out.strip()) rc, out = ssh(host, f"docker compose -f {root}/compose.yml up -d act_runner_1") print(out.strip())