Compare commits

..
Author SHA1 Message Date
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 30cc68bfe3 feat: add pre-cache timer, force_pull, and Docker socket options to runner config
CI / validate (pull_request) Successful in 1m22s
CI / auto-merge (pull_request) Failing after 1m49s
CI / molecule-tests (1) (pull_request) Successful in 7m10s
CI / molecule-tests (3) (pull_request) Successful in 8m46s
CI / molecule-tests (2) (pull_request) Successful in 9m31s
CI / molecule-tests (4) (pull_request) Successful in 9m32s
- Add force_pull: false (explicit default) to runner config template
- Add options field to mount host rootless Docker socket as /run/host-docker.sock
  so CI containers use the host daemon (full disk) instead of nested DinD
- Add valid_volumes list for the socket mount targets
- Add pre_cache.yml task with systemd timer that pre-pulls CI images every 6h
  to prevent thundering-herd registry timeouts when all runners start jobs
- Add docker-pull-images.service.j2 and docker-pull-images.timer.j2 templates
- Bump devx from v0.50.0 to v0.50.1 (includes start_docker.py host socket fix)

Closes GRM-162

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 13:02:09 +02:00
gitea-actions-bot d6549de2e0 chore: update badge URLs to commit a9578eae [skip ci] 2026-08-09 22:51:32 +00:00
grm-ci-bot dc4bb0936d release: v0.21.0 [skip ci] 2026-08-09 22:50:47 +00:00
emo 52477e558a GRM-161: feat(healthcheck): add two-tier disk prune with critical threshold
Post-merge / detect-and-configure (push) Successful in 2m29s
Post-merge / release-and-maintain (push) Successful in 2m14s
2026-08-09 22:46:48 +00:00
gitea-actions-bot 28214de583 chore: update badge URLs to commit 918ffe7d [skip ci] 2026-08-09 11:16:42 +00:00
grm-ci-bot 7fd023d192 release: v0.20.0 [skip ci] 2026-08-09 11:16:01 +00:00
emil 2ffedaa793 GRM-159: feat(healthcheck): add two-tier disk prune with critical threshold
Post-merge / detect-and-configure (push) Successful in 1m38s
Post-merge / release-and-maintain (push) Successful in 2m26s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-09 11:12:37 +00:00
gitea-actions-bot e7b2d4e6af chore: update badge URLs to commit e0611a67 [skip ci] 2026-08-08 23:39:03 +00:00
grm-ci-bot bda2af91bc release: v0.19.0 [skip ci] 2026-08-08 23:38:33 +00:00
emil c72dc97f63 GRM-158: feat: use Gitea mirror for Ansible collection installs
Post-merge / detect-and-configure (push) Successful in 1m8s
Post-merge / release-and-maintain (push) Successful in 1m9s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-08 23:36:41 +00:00
gitea-actions-bot db9fb6f162 chore: update badge URLs to commit d9e3ee2e [skip ci] 2026-08-06 19:16:49 +00:00
grm-ci-bot d102453960 release: v0.18.8 [skip ci] 2026-08-06 19:16:18 +00:00
kireto 42409d9e47 GRM-160: fix: pin containerd.io to compatible version for Docker 28.x
Post-merge / detect-and-configure (push) Successful in 3m19s
Post-merge / release-and-maintain (push) Successful in 1m19s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-06 19:12:07 +00:00
gitea-actions-bot 91e880f05c chore: update badge URLs to commit 7b6c9f92 [skip ci] 2026-08-06 09:31:04 +00:00
grm-ci-bot 2d2eaa3291 release: v0.18.7 [skip ci] 2026-08-06 09:30:18 +00:00
kireto 8176a62885 GRM-159: fix: move StartLimit to [Unit] and make prune timer reload conditional
Post-merge / detect-and-configure (push) Successful in 3m49s
Post-merge / release-and-maintain (push) Successful in 3m43s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-06 09:23:29 +00:00
gitea-actions-bot 443756a508 chore: update badge URLs to commit 36201d0d [skip ci] 2026-08-06 00:00:05 +00:00
grm-ci-bot 340222e041 release: v0.18.6 [skip ci] 2026-08-05 23:59:22 +00:00
kireto 179e47bbb2 GRM-158: fix: pre-configure daemon.json before rootless setuptool + add DBUS_SESSION_BUS_ADDRESS
Post-merge / detect-and-configure (push) Successful in 1m2s
Post-merge / release-and-maintain (push) Successful in 1m38s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-05 23:57:25 +00:00
gitea-actions-bot 68d16577b3 chore: update badge URLs to commit 169df915 [skip ci] 2026-08-05 20:21:20 +00:00
grm-ci-bot 38607d9f29 release: v0.18.5 [skip ci] 2026-08-05 20:20:43 +00:00
kireto 90139b306b GRM-157: fix: pin Docker 28.x + disable containerd snapshotter + tune prune/disk
Post-merge / detect-and-configure (push) Successful in 1m8s
Post-merge / release-and-maintain (push) Successful in 1m31s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-05 20:18:38 +00:00
gitea-actions-bot dd475bec0d chore: update badge URLs to commit 83a5b577 [skip ci] 2026-08-05 13:53:01 +00:00
grm-ci-bot 0f0ada3576 release: v0.18.4 [skip ci] 2026-08-05 13:52:28 +00:00
emo 185e41c49e GRM-156: fix: harden rootless Docker daemon resilience on CI runners
Post-merge / detect-and-configure (push) Successful in 1m9s
Post-merge / release-and-maintain (push) Successful in 1m15s
2026-08-05 13:50:34 +00:00
gitea-actions-bot 103741b3ab chore: update badge URLs to commit 7136903b [skip ci] 2026-08-04 14:04:34 +00:00
grm-ci-bot b770d1debf release: v0.18.3 [skip ci] 2026-08-04 14:03:59 +00:00
gitea-admin 2f11489be0 GRM-2: fix: switch default network driver to slirp4netns (pasta TCP RST bug)
Post-merge / detect-and-configure (push) Successful in 1m8s
Post-merge / release-and-maintain (push) Successful in 1m22s
Co-authored-by: oblachno Admin <admin@oblachno.oblachno.fyi>
2026-08-04 14:01:56 +00:00
26 changed files with 434 additions and 404 deletions
-47
View File
@@ -1,47 +0,0 @@
name: 'Notify on failure'
description: 'Create a Gitea issue when a CI workflow fails (calls devx.ci.notify_failure)'
# Composite action for the common "Notify on failure" step pattern.
# Replaces the repeated inline:
# - name: Notify on failure
# if: failure()
# env:
# CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
# run: |
# . .venv/bin/activate 2>/dev/null || true
# export PATH="$HOME/.local/bin:$PATH"
# python3 -m devx.ci.notify_failure \
# --repo "${{ github.repository }}" \
# --run-id "${{ github.run_id }}" \
# --workflow "ci/validate" \
# --commit "${{ github.sha }}" \
# --auto-login
#
# Gitea 1.27 notes:
# - `if: failure()` is evaluated in the calling workflow's context and
# propagates correctly to composite action steps.
# - `secrets` are not accessible here; the calling workflow's top-level
# `env:` CI_GITEA_API_TOKEN is used via `${{ env.* }}`.
inputs:
workflow:
description: 'Workflow/job name used in the Gitea issue title (e.g., ci/validate)'
required: true
runs:
using: 'composite'
steps:
- name: Notify on failure
if: failure()
shell: bash
env:
CI_GITEA_API_TOKEN: ${{ env.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "${{ inputs.workflow }}" \
--commit "${{ github.sha }}" \
--auto-login
-89
View File
@@ -1,89 +0,0 @@
name: 'Quality checks'
description: 'Run lint, unit tests with coverage, test speed, docs, translations, and security scan'
# Composite action for the 6-step quality check sequence used by the
# devx validate job. Replaces the inline block:
# - Lint all
# - Unit tests with 100% coverage
# - Check unit test speed
# - Documentation gate (coverage + stale refs + lint + version refs + prose)
# - Translation completeness check
# - Dependency security scan
#
# Each step activates the venv defensively (`. .venv/bin/activate 2>/dev/null
# || true`) so the action works whether or not the setup step created a
# venv at the repo root (pre-built CI images symlink /opt/venv to .venv).
#
# Gitea 1.27 notes:
# - Every `run` step needs explicit `shell:`.
# - Inputs are string-typed; numeric thresholds are passed through as
# strings to `devx.tools.check_test_speed`.
inputs:
package:
description: 'Package name for doc version checks (e.g., devx, grm). Empty = no DEVX_DOC_VERSIONS_PKG override.'
required: false
default: ''
test-speed-max:
description: 'Max total test seconds (passed to check_test_speed --max-seconds)'
required: false
default: '15'
test-speed-max-single:
description: 'Max single test seconds (passed to check_test_speed --max-single-seconds)'
required: false
default: '0.5'
translations-file:
description: 'Path to translations.json (empty = default location src/devx/translations.json)'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Lint all
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed \
--max-seconds "${{ inputs.test-speed-max }}" \
--max-single-seconds "${{ inputs.test-speed-max-single }}"
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
shell: bash
env:
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
if [ -n "${{ inputs.package }}" ]; then
export DEVX_DOC_VERSIONS_PKG="${{ inputs.package }}"
fi
make devx-docs-check
- name: Translation completeness check
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
if [ -n "${{ inputs.translations-file }}" ]; then
python3 -m devx.ci.check_translations --translations "${{ inputs.translations-file }}"
else
python3 -m devx.ci.check_translations
fi
- name: Dependency security scan
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
-37
View File
@@ -1,37 +0,0 @@
name: 'Set up environment'
description: 'Set up CI environment with venv and PATH (calls make setup-image)'
# Composite action for the common "Set up environment" step pattern.
# Replaces the repeated inline:
# - name: Set up environment
# env:
# CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
# run: make setup-image
#
# Gitea 1.27 notes:
# - Every `run` step needs explicit `shell:`.
# - Composite actions cannot access `secrets` directly; they read from
# the `env:` context which the calling workflow must populate.
# - The calling workflow's top-level `env:` block (CI_GITEA_API_TOKEN,
# CI_GITEA_USERNAME) is visible here via `${{ env.* }}`.
inputs:
extras:
description: 'Extra pip install groups passed to make setup-image (e.g., ci,lint,release)'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Set up environment
shell: bash
env:
CI_GITEA_API_TOKEN: ${{ env.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ env.CI_GITEA_USERNAME }}
run: |
if [ -n "${{ inputs.extras }}" ]; then
make setup-image EXTRAS="${{ inputs.extras }}"
else
make setup-image
fi
+59 -29
View File
@@ -18,11 +18,7 @@ jobs:
# Saves ~5x checkout+setup overhead vs 6 separate jobs.
validate:
runs-on: docker
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
defaults:
run:
@@ -36,14 +32,45 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ./.gitea/actions/setup-env
with:
extras: "ci,lint"
- uses: ./.gitea/actions/quality-checks
with:
package: grm
test-speed-max: "4"
translations-file: src/grm/translations.json
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
# --- quality steps ---
- name: Lint all
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env:
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_DOC_VERSIONS_PKG: grm
DEVX_VALE_LEVEL: warning
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make devx-docs-check
- name: Translation completeness check
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations --translations src/grm/translations.json
- name: Check unit test speed
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Dependency security scan
run: |
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation
run: |
. .venv/bin/activate 2>/dev/null || true
@@ -113,19 +140,24 @@ jobs:
--owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \
--github-output
- uses: ./.gitea/actions/notify-failure
with:
workflow: "ci/validate"
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "ci/validate" \
--commit "${{ github.sha }}"
molecule-tests:
needs: [validate]
if: needs.validate.outputs.ansible-changed == 'true'
runs-on: docker
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
strategy:
fail-fast: false
@@ -234,11 +266,7 @@ jobs:
github.event_name == 'pull_request' &&
needs.validate.result == 'success'
runs-on: docker
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
@@ -248,9 +276,11 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- uses: ./.gitea/actions/setup-env
with:
extras: "ci"
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Post approval review
env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
+35 -25
View File
@@ -36,11 +36,7 @@ env:
jobs:
detect-and-configure:
runs-on: docker
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
@@ -53,9 +49,11 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ./.gitea/actions/setup-env
with:
extras: "ci"
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Ensure branch protection and labels
env:
DEVX_REPO_NAME: grm
@@ -89,19 +87,24 @@ jobs:
--base "HEAD~1" \
--head "HEAD" \
--github-output
- uses: ./.gitea/actions/notify-failure
with:
workflow: "post-merge/detect-and-configure"
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/detect-and-configure" \
--commit "${{ github.sha }}"
release-and-maintain:
needs: [detect-and-configure]
if: always() && needs.detect-and-configure.result == 'success'
runs-on: docker
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
@@ -114,22 +117,20 @@ jobs:
fetch-depth: 0
ref: master
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- uses: ./.gitea/actions/setup-env
with:
extras: "ci,lint"
- name: Configure git
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Configure git
run: |
git config user.name "grm-ci-bot"
git config user.email "grm-ci-bot@oblachno.fyi"
git remote set-url origin "https://grm-ci-bot:${CI_GITEA_API_TOKEN}@git.oblachno.oblachno.fyi/oblachno-oss/grm.git"
# --- release + publish (only if not a release commit) ---
- name: Run release
id: release-tag
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
DEVX_VERSION_FILE: src/grm/__init__.py
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
@@ -176,6 +177,15 @@ jobs:
git fetch origin master
git reset --hard origin/master
python3 -m devx.ci.push_badges
- uses: ./.gitea/actions/notify-failure
with:
workflow: "post-merge/release-and-maintain"
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release-and-maintain" \
--commit "${{ github.sha }}"
+3 -21
View File
@@ -2,29 +2,11 @@
All notable changes to this project will be documented in this file.
## [Unreleased]
## [0.21.0] - 2026-08-09
### CI
### Features
- Convert `ci.yml` and `post-merge.yml` to use composite actions
(`setup-env`, `quality-checks`, `notify-failure`) copied from devx,
reducing workflow duplication
### Bug Fixes
- Fix `register.yml` premature service start: removed step that ran
`systemctl --user start gitea-runner` before the systemd unit file was
created by `service.yml` (included after `register.yml`). First-time
installs were failing with "Unit gitea-runner.service not found".
- Fix `ci.yml` auto-merge IndentationError: replaced broken inline Python
polling script with `devx.ci.wait_for_checks` (the inline Python had
YAML run-block indentation leaking into `python3 -c` string).
### Dependencies
- Bump devx from v0.50.0 to v0.50.5 (adds `wait_for_checks` tool,
consolidated Ansible checks, tenacity retry in `install_tools`,
increased download retry attempts/backoff)
- *(healthcheck)* Add two-tier disk prune with critical threshold
## [0.20.0] - 2026-08-09
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/python.svg)](https://www.python.org/downloads/)
## Why GRM?
+27 -12
View File
@@ -36,10 +36,9 @@ gitea_runner_prune_label: "gitea-runner=true"
gitea_runner_service_restart_sec: "5"
# Health check configuration
# 2min interval — catches hung daemons before multiple CI jobs fail between
# checks. The 1min interval caused excessive pruning which removed cached
# images, forcing all 6 parallel slots to re-pull simultaneously and
# actually increasing disk pressure.
# 2min interval — catches hung daemons before multiple CI jobs fail between checks.
# The previous 5min interval was too coarse: a stuck daemon could fail 3+ molecule
# jobs in the window between healthcheck runs.
gitea_runner_healthcheck_interval: "2min"
gitea_runner_healthcheck_boot_delay: "2min"
gitea_runner_healthcheck_disk_threshold: 70
@@ -48,10 +47,6 @@ gitea_runner_healthcheck_disk_threshold: 70
# This removes all stopped containers and unused images regardless of age.
# At 75%+, molecule containers fail with "container is not running" because
# overlay2 runs out of space under parallel DinD load.
# IMPORTANT: keep at 75 (not lower) — the host disk normally sits at ~74%.
# Lowering to 70 triggers full prune every cycle, wiping cached images and
# forcing all parallel slots to re-pull simultaneously, which increases
# disk pressure rather than reducing it.
gitea_runner_healthcheck_disk_critical: 75
gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
@@ -81,10 +76,6 @@ gitea_runner_docker_shutdown_timeout: 30
gitea_runner_docker_max_concurrent_downloads: 3
gitea_runner_docker_max_concurrent_uploads: 3
gitea_runner_docker_default_nofile: 65536
# Log file size limits — under parallel DinD load, container logs can fill
# disk and cause the daemon to become unresponsive. Limit log size per container.
gitea_runner_docker_max_log_size: "10m"
gitea_runner_docker_max_log_files: 3
# Admin token for runner deregistration via Gitea API.
# If not set, falls back to registration_token (which likely lacks admin scope).
@@ -99,6 +90,22 @@ gitea_runner_remove_user: true
gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner"
# force_pull: when false (default), the runner reuses locally cached images
# instead of pulling on every job. Pre-cached images (via the pre-cache timer
# or pre_pull_images task) eliminate registry thundering-herd when all runners
# start jobs simultaneously.
gitea_runner_force_pull: false
# Container options passed to `docker run` for CI job containers.
# Mounts the host rootless Docker socket as /run/host-docker.sock so
# start_docker.py inside the container can detect and use the host daemon
# (full disk, no nested DinD) instead of starting an inner dockerd.
gitea_runner_container_options: "-v /run/user/{{ gitea_runner_uid }}/docker.sock:/run/host-docker.sock"
# Volumes allowed in CI job containers (validated by the runner against
# container.options and job-level volumes). Must include the host Docker
# socket mount target.
gitea_runner_valid_volumes:
- "/run/host-docker.sock"
- "/run/user/{{ gitea_runner_uid }}/docker.sock"
# Containerd version pinning — Docker 28.x vendors containerd v2.1.x internally.
# containerd.io >= 2.3 ships a shim that returns a protobuf BootstrapResult which
@@ -144,3 +151,11 @@ gitea_runner_docker_ipv6_cidr: "fd00:dead:beef::/48"
# disk-space prune only removes dangling images, so pre-pulled tagged images persist.
# Set to [] to skip pre-pulling. Images are pulled as the runner user via rootless Docker.
gitea_runner_pre_pull_images: []
# Pre-cache timer: periodically pulls the runner container image so it stays
# fresh in the local Docker cache. This prevents thundering-herd registry
# timeouts when all runners start CI jobs simultaneously with empty caches.
# Runs every 6 hours (aligned with prune schedule). Set to empty string to
# disable the timer.
gitea_runner_pre_cache_schedule: "*-*-* 00/6:30:00"
gitea_runner_pre_cache_images: "{{ gitea_runner_pre_pull_images }}"
@@ -10,7 +10,3 @@
- ansible_facts is defined
- ansible_facts['service_mgr'] | default('') == 'systemd'
- gitea_runner_docker_rootless_setup
- name: Reload systemd user daemon
ansible.builtin.systemd:
daemon_reload: true
@@ -112,4 +112,5 @@
- "'status=removing' in healthcheck_script.content | b64decode"
- "'status=stopping' in healthcheck_script.content | b64decode"
- "gitea_runner_healthcheck_disk_threshold | string in healthcheck_script.content | b64decode"
- "gitea_runner_healthcheck_disk_critical | string in healthcheck_script.content | b64decode"
fail_msg: "Healthcheck script template is missing expected content"
@@ -40,22 +40,24 @@
mode: "0644"
- name: Reload systemd user daemon for healthcheck timer
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user daemon-reload
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Enable and start healthcheck user timer
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user enable --now runner-healthcheck.timer
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
@@ -13,9 +13,9 @@
- name: Include validation
ansible.builtin.include_tasks: validate.yml
- name: Include service setup
ansible.builtin.include_tasks: service.yml
- name: Include registration
ansible.builtin.include_tasks: register.yml
when: not gitea_runner_skip_registration
- name: Include service setup
ansible.builtin.include_tasks: service.yml
@@ -20,6 +20,9 @@
- name: Include pre-pull images
ansible.builtin.include_tasks: pre_pull_images.yml
- name: Include pre-cache timer
ansible.builtin.include_tasks: pre_cache.yml
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not gitea_runner_skip_registration
@@ -0,0 +1,67 @@
---
# Periodic timer that pre-pulls CI runner images into the local Docker cache.
# Prevents thundering-herd registry timeouts when all runners start jobs
# simultaneously with empty/stale caches. Runs every 6 hours (configurable).
# The prune timer removes dangling images but NOT tagged ones, so pre-pulled
# images persist between runs.
- name: Create docker-pull-images user service file
ansible.builtin.template:
src: docker-pull-images.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-pull-images.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
register: gitea_runner_pre_cache_service
- name: Create docker-pull-images user timer file
ansible.builtin.template:
src: docker-pull-images.timer.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-pull-images.timer"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
register: gitea_runner_pre_cache_timer
- name: Reload systemd user daemon for pre-cache timer
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_pre_cache_service is changed or gitea_runner_pre_cache_timer is changed
- gitea_runner_pre_cache_schedule | length > 0
- gitea_runner_pre_cache_images | length > 0
- name: Enable and start docker-pull-images user timer
ansible.builtin.command: systemctl --user enable --now docker-pull-images.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_pre_cache_schedule | length > 0
- gitea_runner_pre_cache_images | length > 0
- name: Disable and stop docker-pull-images timer (no images or schedule)
ansible.builtin.command: systemctl --user disable --now docker-pull-images.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
failed_when: false
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_pre_cache_schedule | length == 0 or gitea_runner_pre_cache_images | length == 0
+12 -10
View File
@@ -18,11 +18,12 @@
register: gitea_runner_prune_timer
- name: Reload systemd user daemon for prune timer
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user daemon-reload
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
@@ -30,11 +31,12 @@
- gitea_runner_prune_service is changed or gitea_runner_prune_timer is changed
- name: Enable and start docker-prune user timer
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user enable --now docker-prune.timer
ansible.builtin.command: systemctl --user enable --now docker-prune.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
@@ -44,12 +44,13 @@
('already exists' not in gitea_runner_register_output.stdout | default(''))
timeout: 60
- name: Restart runner service after (re-)registration
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user restart gitea-runner
- name: Ensure runner service is running after registration
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
@@ -143,10 +143,6 @@
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
{% endif %}
Restart=always
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=10
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
@@ -184,10 +180,6 @@
"features": {
"containerd-snapshotter": false
},
"log-opts": {
"max-size": "{{ gitea_runner_docker_max_log_size }}",
"max-file": "{{ gitea_runner_docker_max_log_files }}"
},
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
"ipv6": true,
"ip6tables": true,
@@ -220,20 +212,22 @@
- not gitea_runner_rootless_docker_check.stat.exists
- name: Start rootless Docker daemon (systemd user service)
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user start docker
ansible.builtin.command: systemctl --user start docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when: gitea_runner_docker_rootless_setup
- name: Enable rootless Docker daemon (systemd user service)
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user enable docker
ansible.builtin.command: systemctl --user enable docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when: gitea_runner_docker_rootless_setup
@@ -256,10 +250,6 @@
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
{% endif %}
Restart=always
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=10
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
@@ -267,11 +257,12 @@
when: gitea_runner_docker_rootless_setup
- name: Reload systemd user daemon if network config changed
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user daemon-reload
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when:
- gitea_runner_docker_rootless_setup
@@ -292,10 +283,6 @@
"features": {
"containerd-snapshotter": false
},
"log-opts": {
"max-size": "{{ gitea_runner_docker_max_log_size }}",
"max-file": "{{ gitea_runner_docker_max_log_files }}"
},
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
"ipv6": true,
"ip6tables": true,
@@ -313,11 +300,12 @@
when: gitea_runner_docker_rootless_setup
- name: Restart rootless Docker if config changed
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user restart docker
ansible.builtin.command: systemctl --user restart docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when:
- gitea_runner_docker_rootless_setup
+18 -15
View File
@@ -9,11 +9,12 @@
register: gitea_runner_service_file
- name: Reload systemd user daemon
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user daemon-reload
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
@@ -21,11 +22,12 @@
- gitea_runner_service_file is changed
- name: Restart gitea-runner if service file changed
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user restart gitea-runner
ansible.builtin.command: systemctl --user restart gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
@@ -33,11 +35,12 @@
- gitea_runner_service_file is changed
- name: Enable and start gitea-runner user service
ansible.builtin.command: >
runuser -u {{ gitea_runner_service_user }} --
env XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus
systemctl --user enable --now gitea-runner
ansible.builtin.command: systemctl --user enable --now gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
@@ -69,31 +69,3 @@
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Disable systemd-oomd memory pressure kill for runner user
when: gitea_runner_systemd_available.stat.exists
block:
- name: Ensure user service override directory exists
ansible.builtin.file:
path: "/etc/systemd/system/user@{{ gitea_runner_uid }}.service.d"
state: directory
owner: root
group: root
mode: "0755"
- name: Disable ManagedOOMMemoryPressure for runner user
ansible.builtin.copy:
content: |
[Service]
ManagedOOMMemoryPressure=auto
ManagedOOMMemoryPressureLimit=100%
OOMScoreAdjust=-500
dest: "/etc/systemd/system/user@{{ gitea_runner_uid }}.service.d/oomd-override.conf"
owner: root
group: root
mode: "0644"
notify: Reload systemd user daemon
- name: Reload systemd daemon for oomd override
ansible.builtin.systemd:
daemon_reload: true
@@ -11,7 +11,13 @@ Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
# consuming disk and memory. We stop+rm everything first, then prune the rest.
# Exclude CI job containers (name starts with GITEA-ACTIONS-TASK) — removing
# them kills the active CI job and causes "RWLayer is unexpectedly nil" errors.
ExecStart=/bin/sh -c 'docker ps -a --format "{% raw %}{{.ID}} {{.Names}}{% endraw %}" 2>/dev/null | grep -v "GITEA-ACTIONS-TASK" | awk "{print $1}" | xargs -r docker rm -f 2>/dev/null || true'
# Only remove containers older than 1 hour (grep for "hour/day/week/month/year
# ago" in RunningFor) to avoid killing molecule test containers that CI jobs
# are actively using.
ExecStart=/bin/sh -c 'docker ps -a --format "{% raw %}{{.ID}} {{.Names}} {{.RunningFor}}{% endraw %}" 2>/dev/null | grep -v "GITEA-ACTIONS-TASK" | grep -E "(hour|day|week|month|year)s? ago" | awk "{print $1}" | xargs -r docker rm -f 2>/dev/null || true'
ExecStart=/usr/bin/docker system prune -af --filter "until={{ gitea_runner_prune_until }}" --volumes
ExecStart=/usr/bin/docker network prune -f
# Prune networks older than the prune-until threshold to avoid removing
# networks that molecule tests are actively creating (e.g. 'traefik' network
# created during molecule create phase before containers are attached).
ExecStart=/usr/bin/docker network prune -f --filter "until={{ gitea_runner_prune_until }}"
ExecStart=/usr/bin/docker builder prune -f
@@ -0,0 +1,14 @@
[Unit]
Description=Pre-pull Docker images for CI runner cache
After=docker.service
Wants=docker.service
[Service]
Type=oneshot
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
# Pull each image quietly. docker pull exits 0 if image is already up-to-date,
# so this is idempotent. Errors are non-fatal (image may already be cached).
{% for image in gitea_runner_pre_cache_images %}
ExecStart=/usr/bin/docker pull -q {{ image }}
{% endfor %}
@@ -0,0 +1,10 @@
[Unit]
Description=Periodic Docker image pre-cache for CI runner
[Timer]
OnCalendar={{ gitea_runner_pre_cache_schedule }}
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
@@ -9,3 +9,13 @@ runner:
container:
label: "{{ gitea_runner_container_label }}"
docker_host: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
force_pull: {{ gitea_runner_force_pull | lower }}
{% if gitea_runner_container_options | length > 0 %}
options: "{{ gitea_runner_container_options }}"
{% endif %}
{% if gitea_runner_valid_volumes | length > 0 %}
valid_volumes:
{% for volume in gitea_runner_valid_volumes %}
- "{{ volume }}"
{% endfor %}
{% endif %}
@@ -48,6 +48,14 @@ fi
# runner (e.g., server restore, runner record deleted, Gitea restart with
# token salt change), the runner logs "unregistered runner" every few seconds.
# A service restart will not fix this; re-registration is required.
#
# Detection method: query the Gitea API to verify the runner's UUID still
# exists. This is more reliable than parsing journal logs (which requires
# journal access permissions that runner users may not have — see the
# 2026-08-08 incident where journalctl --user returned "No journal files
# were opened due to insufficient permissions" for all runner users,
# causing the healthcheck to always report "OK: runner healthy" even
# though all runners were unregistered).
{% if gitea_runner_auto_recover_api_token %}
# Auto-recovery is enabled: fetch a new registration token from the Gitea API
# and re-register the runner automatically. A cooldown prevents infinite loops.
@@ -58,10 +66,50 @@ GITEA_URL="{{ gitea_url }}"
RUNNER_NAME="{{ gitea_runner_name }}"
RUNNER_LABELS="{{ gitea_runner_labels }}"
BINARY="{{ gitea_runner_binary_path }}"
RUNNER_FILE="{{ gitea_runner_data_dir }}/.runner"
{% endif %}
recent_errors=$(journalctl --user -u gitea-runner.service --since "5 minutes ago" --no-pager -q 2>/dev/null | grep -c "unregistered runner" || true)
if [[ "$recent_errors" -ge 3 ]]; then
echo "CRITICAL: runner is unregistered in Gitea (re-login failed $recent_errors times in 5 minutes)."
runner_unregistered=0
# Primary detection: query the Gitea API to check if the runner's ID
# still exists in Gitea's runner list. This works regardless of journal
# permissions.
{% if gitea_runner_auto_recover_api_token %}
if [[ -f "$GITEA_API_TOKEN_FILE" && -f "$RUNNER_FILE" ]]; then
API_TOKEN=$(cat "$GITEA_API_TOKEN_FILE" 2>/dev/null || true)
RUNNER_ID=$(python3 -c "import json; print(json.load(open('$RUNNER_FILE')).get('id',''))" 2>/dev/null || true)
if [[ -n "$API_TOKEN" && -n "$RUNNER_ID" ]]; then
# List all runners and check if our ID is present
runner_found=$(curl -sf --connect-timeout 5 --max-time 10 \
-H "Authorization: token $API_TOKEN" \
"${GITEA_URL}/api/v1/admin/actions/runners" 2>/dev/null \
| python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
runners = data if isinstance(data, list) else data.get('runners', [])
ids = [str(r.get('id', '')) for r in runners]
print('1' if '$RUNNER_ID' in ids else '0')
except Exception:
print('0')
" 2>/dev/null || echo "0")
if [[ "$runner_found" != "1" ]]; then
runner_unregistered=1
echo "CRITICAL: runner ID $RUNNER_ID not found in Gitea (unregistered)."
fi
fi
fi
{% endif %}
# Fallback detection: check journal logs (if accessible)
if [[ "$runner_unregistered" -eq 0 ]]; then
recent_errors=$(journalctl --user -u gitea-runner.service --since "5 minutes ago" --no-pager -q 2>/dev/null | grep -c "unregistered runner" || true)
if [[ "$recent_errors" -ge 3 ]]; then
runner_unregistered=1
echo "CRITICAL: runner is unregistered in Gitea (re-login failed $recent_errors times in 5 minutes)."
fi
fi
if [[ "$runner_unregistered" -ge 1 ]]; then
{% if gitea_runner_auto_recover_api_token %}
# Check cooldown — skip if we recently attempted recovery
if [[ -f "$COOLDOWN_FILE" ]]; then
@@ -135,14 +183,32 @@ if [[ "$recent_errors" -ge 3 ]]; then
systemctl --user start gitea-runner.service
sleep 3
# Verify recovery — check if unregistered errors stopped
new_errors=$(journalctl --user -u gitea-runner.service --since "10 seconds ago" --no-pager -q 2>/dev/null | grep -c "unregistered runner" || true)
if [[ "$new_errors" -eq 0 ]]; then
echo "OK: runner recovered and no longer reporting unregistered errors"
# Clear cooldown on success
rm -f "$COOLDOWN_FILE" 2>/dev/null || true
# Verify recovery — query the Gitea API to confirm the new ID is registered
NEW_ID=$(python3 -c "import json; print(json.load(open('$RUNNER_FILE')).get('id',''))" 2>/dev/null || true)
if [[ -n "$NEW_ID" ]]; then
new_found=$(curl -sf --connect-timeout 5 --max-time 10 \
-H "Authorization: token $API_TOKEN" \
"${GITEA_URL}/api/v1/admin/actions/runners" 2>/dev/null \
| python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
runners = data if isinstance(data, list) else data.get('runners', [])
ids = [str(r.get('id', '')) for r in runners]
print('1' if '$NEW_ID' in ids else '0')
except Exception:
print('0')
" 2>/dev/null || echo "0")
if [[ "$new_found" == "1" ]]; then
echo "OK: runner recovered and registered with new ID $NEW_ID"
# Clear cooldown on success
rm -f "$COOLDOWN_FILE" 2>/dev/null || true
else
echo "WARN: runner re-registered but ID not found in Gitea API. Will retry after cooldown."
exit 3
fi
else
echo "WARN: runner still showing unregistered errors after re-registration. Will retry after cooldown."
echo "WARN: could not read new .runner file after re-registration. Will retry after cooldown."
exit 3
fi
{% else %}
@@ -157,18 +223,43 @@ fi
# 3. Check disk space — prune aggressively if below threshold
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then
echo "WARN: Disk usage at ${disk_pct}%, pruning all runner resources"
# Force-remove stale containers (including running ones from failed molecule tests).
# "docker container prune -f" only removes stopped containers, so running
# containers from crashed CI jobs accumulate and consume disk/memory.
# Exclude CI job containers (name starts with GITEA-ACTIONS-TASK).
if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_critical }} ]]; then
echo "CRITICAL: Disk usage at ${disk_pct}% (>= {{ gitea_runner_healthcheck_disk_critical }}%), full prune"
# Critical level: remove ALL stopped containers (no age filter) and ALL
# unused images/volumes. The until=1h gentle prune is insufficient here.
# Stop+rm stale non-CI containers regardless of age (failed molecule tests
# from the last 59 minutes also consume disk).
docker ps -a --format '{% raw %}{{.ID}} {{.Names}}{% endraw %}' 2>/dev/null \
| grep -v 'GITEA-ACTIONS-TASK' \
| awk '{print $1}' \
| xargs -r docker rm -f 2>/dev/null || true
docker system prune -af --filter "until=1h" --volumes || true
docker system prune -af --volumes || true
docker network prune -f || true
docker builder prune -af || true
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
echo "INFO: Disk usage after full prune: ${disk_pct}%"
elif [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then
echo "WARN: Disk usage at ${disk_pct}%, pruning runner resources (until=1h)"
# Force-remove stale containers (including running ones from failed molecule tests)
# that are older than 1 hour. "docker container prune -f" only removes stopped
# containers, so running containers from crashed CI jobs accumulate and consume
# disk/memory. Exclude CI job containers (name starts with GITEA-ACTIONS-TASK).
# Only remove containers older than 1 hour to avoid killing molecule test
# containers that CI jobs are actively using.
docker ps -a --format '{% raw %}{{.ID}} {{.Names}} {{.RunningFor}}{% endraw %}' 2>/dev/null \
| grep -v 'GITEA-ACTIONS-TASK' \
| grep -E '(hour|day|week|month|year)s? ago' \
| awk '{print $1}' \
| xargs -r docker rm -f 2>/dev/null || true
# Prune images and containers older than 1h (until filter is NOT
# supported with --volumes, so prune volumes separately without a filter).
docker image prune -af --filter "until=1h" 2>/dev/null || true
docker container prune -f --filter "until=1h" 2>/dev/null || true
docker volume prune -f 2>/dev/null || true
# Prune networks older than 1 hour to avoid removing networks that
# molecule tests are actively creating (e.g. 'traefik' network created
# during molecule create phase before containers are attached).
docker network prune -f --filter "until=1h" || true
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
echo "INFO: Disk usage after prune: ${disk_pct}%"
fi
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/e96a599d8e0a5186cd72bebaddbd24b2df2e2058/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a9578eaecfc5bba4f295c16f9045be6f094d637d/python.svg)](https://www.python.org/downloads/)
## Overview
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.20.0"
__version__ = "0.21.0"