Some checks failed
CICD / Build and Publish CICD Base Image (push) Successful in 14s
CICD / Build and Push CICD Image (push) Successful in 53m41s
CICD / Build CICD Image Failure Postmortem (push) Has been skipped
CICD / Frontend Tests (push) Successful in 1m3s
CICD / Backend Doctests (push) Successful in 32s
CICD / Backend Tests (push) Successful in 13m58s
CICD / Pre-commit Checks (push) Successful in 14m6s
CICD / CICD Tests Complete (push) Successful in 9s
CICD / Source Lanes Failure Postmortem (push) Has been skipped
CICD / Build Backend Base Image (push) Successful in 34s
CICD / Build E2E Tester Image (push) Successful in 32s
CICD / Build Integration Tester Image (push) Successful in 1m8s
CICD / Build Backend Main Image (push) Successful in 1m48s
CICD / Build Frontend Base Image (push) Successful in 8m19s
CICD / Build and Publish Runtime Images (Legacy Disabled) (push) Has been skipped
CICD / Build Frontend Main Image (push) Successful in 26s
CICD / Runtime Images Failure Postmortem (push) Has been skipped
CICD / Production Images Complete (push) Successful in 8s
CICD / Runtime Black-Box Integration Tests (push) Successful in 1m56s
CICD / Integration Tests Failure Postmortem (push) Has been skipped
CICD / End-to-End Tests (push) Failing after 27m40s
CICD / E2E Tests Failure Postmortem (push) Successful in 13s
782 lines
26 KiB
Plaintext
Executable File
782 lines
26 KiB
Plaintext
Executable File
#!/usr/bin/env xonsh
|
|
|
|
"""Deploy shared runner config.yaml to all hosts and wire compose to use CONFIG_FILE."""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime
|
|
|
|
TEMPLATE_HOST = "kankali.darkhelm.lan"
|
|
TEMPLATE_PATH = "/home/darkhelm/Projects/DarkHelm.org/gitea/runner-config.template.yaml"
|
|
|
|
HOSTS = {
|
|
"kankali.darkhelm.lan": "/home/darkhelm/Projects/DarkHelm.org/gitea",
|
|
"zhokq.darkhelm.lan": "/home/darkhelm/Projects/DarkHelm.org/gitea-runner",
|
|
"urtzul.darkhelm.lan": "/home/darkhelm/Projects/DarkHelm.org/gitea-runner",
|
|
"pi-desktop.darkhelm.lan": "/home/darkhelm/Projects/DarkHelm.org/gitea-runner",
|
|
}
|
|
|
|
WARMUP_IMAGES = [
|
|
"ubuntu:22.04",
|
|
"python:3.14-slim",
|
|
"node:20-bookworm-slim",
|
|
"ghcr.io/catthehacker/ubuntu:act-latest",
|
|
"kankali.darkhelm.lan:3001/darkhelm.org/act-ubuntu:act-latest",
|
|
"ghcr.io/renovatebot/renovate:41",
|
|
]
|
|
|
|
DEFAULT_MEM_FRACTION = 0.75
|
|
DEFAULT_JOB_MEM_FRACTION = 0.9
|
|
DEFAULT_HOST_MEM_SHARE = 0.25
|
|
DEFAULT_HOST_CPU_SHARE = 0.25
|
|
DEFAULT_CPU_FRACTION = 1.0
|
|
DEFAULT_JOB_CPU_FRACTION = 0.9
|
|
DEFAULT_RUNNER_MEM_SWAP_FACTOR = 1.0
|
|
DEFAULT_JOB_MEM_SWAP_FACTOR = 1.0
|
|
DEFAULT_RUNNER_PIDS_LIMIT = 1024
|
|
DEFAULT_JOB_PIDS_LIMIT = 512
|
|
DEFAULT_JOB_NOFILE_SOFT = 2048
|
|
DEFAULT_JOB_NOFILE_HARD = 4096
|
|
DEFAULT_RUNNER_MAX_PARALLEL_JOBS = 1
|
|
MIN_RUNNER_MEM_MIB = 256
|
|
MIN_JOB_MEM_MIB = 128
|
|
|
|
|
|
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 parse_job_mem_fraction(argv):
|
|
fraction = DEFAULT_JOB_MEM_FRACTION
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--job-mem-fraction="):
|
|
_, value = arg.split("=", 1)
|
|
try:
|
|
fraction = float(value)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --job-mem-fraction value: {value}") from exc
|
|
if fraction <= 0 or fraction > 1:
|
|
raise SystemExit("--job-mem-fraction must be > 0 and <= 1")
|
|
return fraction
|
|
|
|
|
|
def parse_host_mem_share(argv):
|
|
share = DEFAULT_HOST_MEM_SHARE
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--host-mem-share="):
|
|
_, value = arg.split("=", 1)
|
|
try:
|
|
share = float(value)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --host-mem-share value: {value}") from exc
|
|
if share <= 0 or share > 1:
|
|
raise SystemExit("--host-mem-share must be > 0 and <= 1")
|
|
return share
|
|
|
|
|
|
def parse_host_cpu_share(argv):
|
|
share = DEFAULT_HOST_CPU_SHARE
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--host-cpu-share="):
|
|
_, value = arg.split("=", 1)
|
|
try:
|
|
share = float(value)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --host-cpu-share value: {value}") from exc
|
|
if share <= 0 or share > 1:
|
|
raise SystemExit("--host-cpu-share must be > 0 and <= 1")
|
|
return share
|
|
|
|
|
|
def parse_cpu_fraction(argv):
|
|
fraction = DEFAULT_CPU_FRACTION
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--cpu-fraction="):
|
|
_, value = arg.split("=", 1)
|
|
try:
|
|
fraction = float(value)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --cpu-fraction value: {value}") from exc
|
|
if fraction <= 0 or fraction > 1:
|
|
raise SystemExit("--cpu-fraction must be > 0 and <= 1")
|
|
return fraction
|
|
|
|
|
|
def parse_job_cpu_fraction(argv):
|
|
fraction = DEFAULT_JOB_CPU_FRACTION
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--job-cpu-fraction="):
|
|
_, value = arg.split("=", 1)
|
|
try:
|
|
fraction = float(value)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --job-cpu-fraction value: {value}") from exc
|
|
if fraction <= 0 or fraction > 1:
|
|
raise SystemExit("--job-cpu-fraction must be > 0 and <= 1")
|
|
return fraction
|
|
|
|
|
|
def parse_runner_mem_swap_factor(argv):
|
|
factor = DEFAULT_RUNNER_MEM_SWAP_FACTOR
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--runner-memory-swap-factor="):
|
|
_, value = arg.split("=", 1)
|
|
try:
|
|
factor = float(value)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --runner-memory-swap-factor value: {value}") from exc
|
|
if factor < 1:
|
|
raise SystemExit("--runner-memory-swap-factor must be >= 1")
|
|
return factor
|
|
|
|
|
|
def parse_job_mem_swap_factor(argv):
|
|
factor = DEFAULT_JOB_MEM_SWAP_FACTOR
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--job-memory-swap-factor="):
|
|
_, value = arg.split("=", 1)
|
|
try:
|
|
factor = float(value)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --job-memory-swap-factor value: {value}") from exc
|
|
if factor < 1:
|
|
raise SystemExit("--job-memory-swap-factor must be >= 1")
|
|
return factor
|
|
|
|
|
|
def parse_runner_pids_limit(argv):
|
|
value = DEFAULT_RUNNER_PIDS_LIMIT
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--runner-pids-limit="):
|
|
_, raw = arg.split("=", 1)
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --runner-pids-limit value: {raw}") from exc
|
|
if value < 64:
|
|
raise SystemExit("--runner-pids-limit must be >= 64")
|
|
return value
|
|
|
|
|
|
def parse_job_pids_limit(argv):
|
|
value = DEFAULT_JOB_PIDS_LIMIT
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--job-pids-limit="):
|
|
_, raw = arg.split("=", 1)
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --job-pids-limit value: {raw}") from exc
|
|
if value < 32:
|
|
raise SystemExit("--job-pids-limit must be >= 32")
|
|
return value
|
|
|
|
|
|
def parse_job_nofile_soft(argv):
|
|
value = DEFAULT_JOB_NOFILE_SOFT
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--job-ulimit-nofile-soft="):
|
|
_, raw = arg.split("=", 1)
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --job-ulimit-nofile-soft value: {raw}") from exc
|
|
if value < 256:
|
|
raise SystemExit("--job-ulimit-nofile-soft must be >= 256")
|
|
return value
|
|
|
|
|
|
def parse_job_nofile_hard(argv):
|
|
value = DEFAULT_JOB_NOFILE_HARD
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--job-ulimit-nofile-hard="):
|
|
_, raw = arg.split("=", 1)
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --job-ulimit-nofile-hard value: {raw}") from exc
|
|
if value < 256:
|
|
raise SystemExit("--job-ulimit-nofile-hard must be >= 256")
|
|
return value
|
|
|
|
|
|
def parse_runner_max_parallel_jobs(argv):
|
|
value = DEFAULT_RUNNER_MAX_PARALLEL_JOBS
|
|
for arg in argv[1:]:
|
|
if arg.startswith("--runner-max-parallel-jobs="):
|
|
_, raw = arg.split("=", 1)
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"Invalid --runner-max-parallel-jobs value: {raw}") from exc
|
|
if value < 1:
|
|
raise SystemExit("--runner-max-parallel-jobs must be >= 1")
|
|
return value
|
|
|
|
|
|
def parse_dry_run(argv):
|
|
return "--dry-run" in argv[1:]
|
|
|
|
def ssh(host, cmd):
|
|
proc = subprocess.run(
|
|
[
|
|
"ssh",
|
|
"-o",
|
|
"ConnectTimeout=30",
|
|
"-o",
|
|
"ServerAliveInterval=30",
|
|
"-o",
|
|
"ServerAliveCountMax=6",
|
|
host,
|
|
cmd,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return proc.returncode, proc.stdout + proc.stderr
|
|
|
|
|
|
def scp(local_path, host, remote_path):
|
|
proc = subprocess.run(
|
|
[
|
|
"scp",
|
|
"-o",
|
|
"ConnectTimeout=30",
|
|
"-o",
|
|
"ServerAliveInterval=30",
|
|
"-o",
|
|
"ServerAliveCountMax=6",
|
|
local_path,
|
|
f"{host}:{remote_path}",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return proc.returncode, proc.stdout + proc.stderr
|
|
|
|
|
|
def warm_runner_images(host):
|
|
pulls = "\n".join([f"docker pull {image}" for image in WARMUP_IMAGES])
|
|
rc, out = ssh(
|
|
host,
|
|
(
|
|
"set -e\n"
|
|
"echo 'warming_runner_images_start'\n"
|
|
f"{pulls}\n"
|
|
"echo 'warming_runner_images_done'\n"
|
|
),
|
|
)
|
|
return rc, out
|
|
|
|
|
|
def service_block_bounds(lines, start_index):
|
|
service_indent = len(lines[start_index]) - len(lines[start_index].lstrip(" "))
|
|
block_start = start_index + 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
|
|
return service_indent, block_start, block_end
|
|
|
|
|
|
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, block_start, block_end = service_block_bounds(lines, i)
|
|
field_indent = " " * (service_indent + 2)
|
|
|
|
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 remove_service_key(compose_text, service_names, key):
|
|
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
|
|
|
|
_, block_start, block_end = service_block_bounds(lines, i)
|
|
key_prefix = f"{key}:"
|
|
filtered = []
|
|
for idx in range(block_start, block_end):
|
|
if not lines[idx].strip().startswith(key_prefix):
|
|
filtered.append(lines[idx])
|
|
|
|
lines = lines[:block_start] + filtered + lines[block_end:]
|
|
i = block_start + len(filtered)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def upsert_service_deploy_limits(compose_text, service_names, memory_value, cpu_value, pids_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, block_start, service_block_end = service_block_bounds(lines, i)
|
|
deploy_indent = " " * (service_indent + 2)
|
|
resources_indent = " " * (service_indent + 4)
|
|
limits_indent = " " * (service_indent + 6)
|
|
value_indent = " " * (service_indent + 8)
|
|
deploy_prefix = f"{deploy_indent}deploy:"
|
|
|
|
# Remove an existing deploy block under this service so we can rewrite it canonically.
|
|
deploy_idx = None
|
|
deploy_end = None
|
|
for idx in range(block_start, service_block_end):
|
|
if lines[idx].startswith(deploy_prefix):
|
|
deploy_idx = idx
|
|
j = idx + 1
|
|
while j < service_block_end:
|
|
cur = lines[j]
|
|
cur_stripped = cur.strip()
|
|
if cur_stripped:
|
|
cur_indent = len(cur) - len(cur.lstrip(" "))
|
|
if cur_indent <= service_indent + 2:
|
|
break
|
|
j += 1
|
|
deploy_end = j
|
|
break
|
|
|
|
if deploy_idx is not None and deploy_end is not None:
|
|
lines = lines[:deploy_idx] + lines[deploy_end:]
|
|
service_block_end -= deploy_end - deploy_idx
|
|
|
|
insert_lines = [
|
|
f"{deploy_indent}deploy:",
|
|
f"{resources_indent}resources:",
|
|
f"{limits_indent}limits:",
|
|
f"{value_indent}memory: {memory_value}",
|
|
f"{value_indent}cpus: '{cpu_value}'",
|
|
f"{value_indent}pids: {pids_value}",
|
|
]
|
|
lines = lines[:block_start] + insert_lines + lines[block_start:]
|
|
i = service_block_end + len(insert_lines)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def upsert_compose_env_var(compose_text, var_name, var_value, anchor_line):
|
|
lines = compose_text.splitlines()
|
|
new_line = f" - {var_name}={var_value}"
|
|
replaced = False
|
|
|
|
for idx, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if stripped.startswith(f"- {var_name}="):
|
|
lines[idx] = new_line
|
|
replaced = True
|
|
|
|
if not replaced:
|
|
for idx, line in enumerate(lines):
|
|
if line.strip() == anchor_line.strip():
|
|
lines.insert(idx + 1, new_line)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def parse_mem_value_to_mib(raw_value):
|
|
value = raw_value.strip().lower()
|
|
if not value:
|
|
return None
|
|
|
|
# Compose mem_limit values are typically 4096m / 4g; inspect values are bytes.
|
|
units = {
|
|
"k": 1.0 / 1024.0,
|
|
"m": 1.0,
|
|
"g": 1024.0,
|
|
}
|
|
for suffix, factor in units.items():
|
|
if value.endswith(suffix):
|
|
num = value[: -len(suffix)]
|
|
try:
|
|
return int(float(num) * factor)
|
|
except ValueError:
|
|
return None
|
|
|
|
if value.isdigit():
|
|
as_int = int(value)
|
|
# Docker inspect HostConfig.Memory reports bytes.
|
|
if as_int > 0:
|
|
return int(as_int / (1024 * 1024))
|
|
return None
|
|
|
|
|
|
def find_service_key_value(compose_text, service_names, key):
|
|
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(" "))
|
|
j = i + 1
|
|
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:
|
|
break
|
|
if cur_stripped.startswith(f"{key}:"):
|
|
return cur_stripped.split(":", 1)[1].strip()
|
|
j += 1
|
|
i = j
|
|
|
|
return None
|
|
|
|
|
|
def find_service_memory_value(compose_text, service_names):
|
|
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
|
|
|
|
_, block_start, block_end = service_block_bounds(lines, i)
|
|
for idx in range(block_start, block_end):
|
|
cur = lines[idx].strip()
|
|
if cur.startswith("memory:"):
|
|
return cur.split(":", 1)[1].strip()
|
|
i = block_end
|
|
|
|
return None
|
|
|
|
|
|
def build_compose(
|
|
compose_text,
|
|
runner_mem_limit,
|
|
runner_memswap_limit,
|
|
runner_cpu_limit,
|
|
runner_pids_limit,
|
|
runner_max_parallel_jobs,
|
|
job_mem_limit,
|
|
job_memswap_limit,
|
|
job_cpu_limit,
|
|
job_pids_limit,
|
|
job_nofile_soft,
|
|
job_nofile_hard,
|
|
):
|
|
updated = compose_text
|
|
|
|
volume_line = " - ./daemon.json:/etc/docker/daemon.json:ro"
|
|
add_volume = " - ./config.yaml:/config.yaml:ro"
|
|
if add_volume not in updated and volume_line in updated:
|
|
updated = updated.replace(volume_line, volume_line + "\n" + add_volume)
|
|
|
|
force_pull_line = " - GITEA_RUNNER_JOB_CONTAINER_FORCE_PULL=${GITEA_RUNNER_JOB_CONTAINER_FORCE_PULL:-false}"
|
|
add_cfg_env = " - CONFIG_FILE=/config.yaml"
|
|
if force_pull_line in updated:
|
|
updated = upsert_compose_env_var(updated, "CONFIG_FILE", "/config.yaml", force_pull_line)
|
|
updated = upsert_compose_env_var(
|
|
updated,
|
|
"GITEA_RUNNER_MAX_PARALLEL_JOBS",
|
|
str(runner_max_parallel_jobs),
|
|
force_pull_line,
|
|
)
|
|
updated = upsert_compose_env_var(
|
|
updated,
|
|
"GITEA_RUNNER_JOB_CONTAINER_OPTIONS",
|
|
(
|
|
f"--memory={job_mem_limit} --memory-swap={job_memswap_limit} "
|
|
f"--cpus={job_cpu_limit} --pids-limit={job_pids_limit} "
|
|
f"--ulimit=nofile={job_nofile_soft}:{job_nofile_hard}"
|
|
),
|
|
force_pull_line,
|
|
)
|
|
elif add_cfg_env not in updated:
|
|
updated = updated.replace(force_pull_line, force_pull_line + "\n" + add_cfg_env)
|
|
|
|
runner_services = {"act_runner_1", "act_runner_2", "runner1", "runner2"}
|
|
updated = remove_service_key(updated, runner_services, "mem_limit")
|
|
updated = remove_service_key(updated, runner_services, "pids_limit")
|
|
updated = remove_service_key(updated, runner_services, "pids")
|
|
updated = upsert_service_key(updated, runner_services, "memswap_limit", runner_memswap_limit)
|
|
updated = upsert_service_deploy_limits(
|
|
updated,
|
|
runner_services,
|
|
runner_mem_limit,
|
|
runner_cpu_limit,
|
|
str(runner_pids_limit),
|
|
)
|
|
|
|
return updated
|
|
|
|
|
|
def host_total_mem_mib(host):
|
|
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}")
|
|
return int(int(mem_kib.strip()) / 1024.0)
|
|
|
|
|
|
def host_total_cpus(host):
|
|
rc, cpu_count = ssh(host, "nproc")
|
|
if rc != 0:
|
|
raise RuntimeError(f"Could not read CPU count on {host}")
|
|
value = cpu_count.strip()
|
|
try:
|
|
cpus = float(value)
|
|
except ValueError as exc:
|
|
raise RuntimeError(f"Invalid CPU count on {host}: {value}") from exc
|
|
if cpus <= 0:
|
|
raise RuntimeError(f"Non-positive CPU count on {host}: {value}")
|
|
return cpus
|
|
|
|
|
|
def host_mem_limit(host, host_mem_share, fraction):
|
|
total_mib = host_total_mem_mib(host)
|
|
host_share_mib = int(total_mib * host_mem_share)
|
|
target_mib = int(host_share_mib * fraction)
|
|
if target_mib < MIN_RUNNER_MEM_MIB:
|
|
target_mib = MIN_RUNNER_MEM_MIB
|
|
return (
|
|
f"{target_mib}m",
|
|
target_mib,
|
|
f"host-total({total_mib}MiB) * host-share({host_mem_share:.2f}) -> {host_share_mib}MiB * mem-fraction({fraction:.2f})",
|
|
)
|
|
|
|
|
|
def host_cpu_limit(host, host_cpu_share, fraction):
|
|
total_cpus = host_total_cpus(host)
|
|
host_share_cpus = total_cpus * host_cpu_share
|
|
target_cpus = host_share_cpus * fraction
|
|
if target_cpus <= 0.01:
|
|
target_cpus = 0.01
|
|
return (
|
|
f"{target_cpus:.2f}",
|
|
target_cpus,
|
|
f"host-total({total_cpus:.0f}cpu) * host-share({host_cpu_share:.2f}) -> {host_share_cpus:.2f}cpu * cpu-fraction({fraction:.2f})",
|
|
)
|
|
|
|
|
|
mem_fraction = parse_mem_fraction(sys.argv)
|
|
job_mem_fraction = parse_job_mem_fraction(sys.argv)
|
|
host_mem_share = parse_host_mem_share(sys.argv)
|
|
host_cpu_share = parse_host_cpu_share(sys.argv)
|
|
cpu_fraction = parse_cpu_fraction(sys.argv)
|
|
job_cpu_fraction = parse_job_cpu_fraction(sys.argv)
|
|
runner_mem_swap_factor = parse_runner_mem_swap_factor(sys.argv)
|
|
job_mem_swap_factor = parse_job_mem_swap_factor(sys.argv)
|
|
runner_pids_limit = parse_runner_pids_limit(sys.argv)
|
|
job_pids_limit = parse_job_pids_limit(sys.argv)
|
|
job_nofile_soft = parse_job_nofile_soft(sys.argv)
|
|
job_nofile_hard = parse_job_nofile_hard(sys.argv)
|
|
runner_max_parallel_jobs = parse_runner_max_parallel_jobs(sys.argv)
|
|
dry_run = parse_dry_run(sys.argv)
|
|
|
|
if job_nofile_hard < job_nofile_soft:
|
|
raise SystemExit("--job-ulimit-nofile-hard must be >= --job-ulimit-nofile-soft")
|
|
|
|
print(f"Host mem share: {host_mem_share:.2f}")
|
|
print(f"Runner mem fraction: {mem_fraction:.2f}")
|
|
print(f"Job mem fraction: {job_mem_fraction:.2f}")
|
|
print(f"Host cpu share: {host_cpu_share:.2f}")
|
|
print(f"Runner cpu fraction: {cpu_fraction:.2f}")
|
|
print(f"Job cpu fraction: {job_cpu_fraction:.2f}")
|
|
print(f"Runner memory swap factor: {runner_mem_swap_factor:.2f}")
|
|
print(f"Job memory swap factor: {job_mem_swap_factor:.2f}")
|
|
print(f"Runner pids limit: {runner_pids_limit}")
|
|
print(f"Job pids limit: {job_pids_limit}")
|
|
print(f"Job nofile soft/hard: {job_nofile_soft}/{job_nofile_hard}")
|
|
print(f"Runner max parallel jobs: {runner_max_parallel_jobs}")
|
|
print(f"Dry run: {dry_run}")
|
|
print("Fetching canonical runner config template")
|
|
rc, template = ssh(TEMPLATE_HOST, f"cat {TEMPLATE_PATH}")
|
|
if rc != 0 or not template.strip():
|
|
print("Failed to fetch template from kankali")
|
|
raise SystemExit(2)
|
|
|
|
now = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
local_cfg = os.path.join(tmpdir, "config.yaml")
|
|
with open(local_cfg, "w", encoding="utf-8") as f:
|
|
f.write(template)
|
|
|
|
for host, root in HOSTS.items():
|
|
print(f"\n===== {host} =====")
|
|
|
|
rc, compose = ssh(host, f"cat {root}/compose.yml")
|
|
if rc != 0:
|
|
print("Could not read compose.yml")
|
|
continue
|
|
|
|
try:
|
|
runner_mem_limit, runner_mem_mib, baseline_source = host_mem_limit(
|
|
host,
|
|
host_mem_share,
|
|
mem_fraction,
|
|
)
|
|
runner_cpu_limit, runner_cpu_value, cpu_source = host_cpu_limit(
|
|
host,
|
|
host_cpu_share,
|
|
cpu_fraction,
|
|
)
|
|
except RuntimeError as exc:
|
|
print(str(exc))
|
|
continue
|
|
|
|
print(f"runner_mem_baseline={baseline_source}")
|
|
print(f"runner_mem_limit={runner_mem_limit} ({runner_mem_mib} MiB)")
|
|
runner_memswap_mib = int(runner_mem_mib * runner_mem_swap_factor)
|
|
if runner_memswap_mib < runner_mem_mib:
|
|
runner_memswap_mib = runner_mem_mib
|
|
runner_memswap_limit = f"{runner_memswap_mib}m"
|
|
print(f"runner_memswap_limit={runner_memswap_limit} ({runner_memswap_mib} MiB)")
|
|
print(f"runner_cpu_baseline={cpu_source}")
|
|
print(f"runner_cpu_limit={runner_cpu_limit} cpu")
|
|
|
|
job_mem_mib = int(runner_mem_mib * job_mem_fraction)
|
|
if job_mem_mib < MIN_JOB_MEM_MIB:
|
|
job_mem_mib = MIN_JOB_MEM_MIB
|
|
if job_mem_mib >= runner_mem_mib and runner_mem_mib > 1:
|
|
job_mem_mib = runner_mem_mib - 1
|
|
job_mem_limit = f"{job_mem_mib}m"
|
|
print(f"job_mem_limit={job_mem_limit} ({job_mem_mib} MiB)")
|
|
|
|
job_memswap_mib = int(job_mem_mib * job_mem_swap_factor)
|
|
if job_memswap_mib < job_mem_mib:
|
|
job_memswap_mib = job_mem_mib
|
|
job_memswap_limit = f"{job_memswap_mib}m"
|
|
print(f"job_memswap_limit={job_memswap_limit} ({job_memswap_mib} MiB)")
|
|
|
|
job_cpu_value = runner_cpu_value * job_cpu_fraction
|
|
if job_cpu_value <= 0.01:
|
|
job_cpu_value = 0.01
|
|
if job_cpu_value >= runner_cpu_value and runner_cpu_value > 0.02:
|
|
job_cpu_value = runner_cpu_value - 0.01
|
|
job_cpu_limit = f"{job_cpu_value:.2f}"
|
|
print(f"job_cpu_limit={job_cpu_limit} cpu")
|
|
print(f"job_pids_limit={job_pids_limit}")
|
|
print(f"job_nofile_soft/hard={job_nofile_soft}/{job_nofile_hard}")
|
|
|
|
new_compose = build_compose(
|
|
compose,
|
|
runner_mem_limit,
|
|
runner_memswap_limit,
|
|
runner_cpu_limit,
|
|
runner_pids_limit,
|
|
runner_max_parallel_jobs,
|
|
job_mem_limit,
|
|
job_memswap_limit,
|
|
job_cpu_limit,
|
|
job_pids_limit,
|
|
job_nofile_soft,
|
|
job_nofile_hard,
|
|
)
|
|
|
|
if dry_run:
|
|
print("dry-run=true; skipping upload and restart")
|
|
continue
|
|
|
|
rc, _ = ssh(host, f"mkdir -p {root}/backups")
|
|
rc, _ = ssh(host, f"cp -f {root}/compose.yml {root}/backups/compose.yml.{now}")
|
|
rc, _ = ssh(host, f"test -f {root}/config.yaml")
|
|
if rc == 0:
|
|
ssh(host, f"cp -f {root}/config.yaml {root}/backups/config.yaml.{now}")
|
|
|
|
local_compose = os.path.join(tmpdir, f"compose.{host}.yml")
|
|
with open(local_compose, "w", encoding="utf-8") as f:
|
|
f.write(new_compose)
|
|
|
|
rc, out = scp(local_compose, host, f"{root}/compose.yml")
|
|
if rc != 0:
|
|
print("Failed to upload compose.yml")
|
|
print(out)
|
|
continue
|
|
|
|
rc, out = scp(local_cfg, host, f"{root}/config.yaml")
|
|
if rc != 0:
|
|
print("Failed to upload config.yaml")
|
|
print(out)
|
|
continue
|
|
|
|
rc, out = ssh(host, f"grep -n 'config.yaml:/config.yaml:ro' {root}/compose.yml")
|
|
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 'GITEA_RUNNER_JOB_CONTAINER_OPTIONS=' {root}/compose.yml")
|
|
print(out.strip())
|
|
rc, out = ssh(host, f"grep -n 'GITEA_RUNNER_MAX_PARALLEL_JOBS=' {root}/compose.yml")
|
|
print(out.strip())
|
|
rc, out = ssh(host, f"grep -nE 'memory:|cpus:|pids:|memswap_limit:|pids_limit:' {root}/compose.yml")
|
|
print(out.strip())
|
|
|
|
rc, out = ssh(host, f"docker compose -f {root}/compose.yml up -d act_runner_1")
|
|
if rc != 0:
|
|
print(out.strip())
|
|
print("Failed to apply compose changes on host")
|
|
continue
|
|
print(out.strip())
|
|
|
|
rc, out = warm_runner_images(host)
|
|
if rc != 0:
|
|
print(out.strip())
|
|
print("Failed to warm runner images on host")
|
|
continue
|
|
print(out.strip())
|
|
|
|
rc, out = ssh(host, "docker inspect gitea-act-runner-1 --format '{{range .Mounts}}{{.Destination}} {{end}}'")
|
|
print("mounts:", out.strip())
|
|
|
|
print("Done")
|
|
|
|
print("\nDeployment complete")
|