Príprava Git runnera

This commit is contained in:
Ján Pták 2026-11-06 00:18:38 +01:00
parent af61a8a273
commit 1cebdc1203
7 changed files with 458 additions and 0 deletions

View File

@ -0,0 +1,16 @@
name: zp-git-runner
services:
runner:
image: docker.io/gitea/act_runner:latest@sha256:b5c35d6bdbb9bb25e531230bfc7cc663cb751406cbec90a2a891b85fea54de86
container_name: zpwiki-actions-runner
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://git.kemt.fei.tuke.sk
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_REGISTRATION_TOKEN:?Vypln registracny token v scripts/git_runner/.env}
GITEA_RUNNER_NAME: zpwiki-sync-runner
GITEA_RUNNER_LABELS: "zpwiki-sync:docker://alpine:3.22"
volumes:
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
stop_grace_period: 2m

View File

@ -0,0 +1,3 @@
# Registration token from dp-zp-agent -> Settings -> Actions -> Runners.
# This is NOT the ZPWIKI_SYNC_SSH_KEY used by the synchronization workflow.
RUNNER_REGISTRATION_TOKEN=

View File

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Manage this runner without changing the API/Neo4j/MCP Compose project.
set -euo pipefail
runner_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
env_file="$runner_root/.env"
usage() {
echo "Pouzitie: bash scripts/git_runner/runner.sh {init|up|status|logs|stop}"
}
if [[ $# -ne 1 ]]; then
usage
exit 64
fi
if [[ "$1" == "init" ]]; then
umask 077
mkdir -p "$runner_root/data"
chmod 700 "$runner_root/data"
if [[ ! -e "$env_file" ]]; then
cp "$runner_root/runner.env.example" "$env_file"
fi
chmod 600 "$env_file"
echo "Vypln RUNNER_REGISTRATION_TOKEN v $env_file a potom spusti prikaz up."
exit 0
fi
case "$1" in
up|status|logs|stop) ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 64 ;;
esac
if [[ ! -f "$env_file" ]]; then
echo "Najprv spusti: bash scripts/git_runner/runner.sh init" >&2
exit 1
fi
compose=(docker --context "${ZP_DOCKER_CONTEXT:-default}" compose
--project-name zp-git-runner --project-directory "$runner_root"
--env-file "$env_file" -f "$runner_root/compose.yml")
case "$1" in
up)
chmod 600 "$env_file"
"${compose[@]}" config --quiet
"${compose[@]}" up -d
"${compose[@]}" ps
;;
status) "${compose[@]}" ps ;;
logs) "${compose[@]}" logs --tail 50 runner ;;
stop) "${compose[@]}" stop runner ;;
esac

View File

@ -0,0 +1,2 @@
# Public ED25519 host key from the existing trusted KEMT known_hosts entry.
git.kemt.fei.tuke.sk ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFIld4P1+7qu4pDpzxDcZYYktt34jxgvWgyiiRsqPogB

View File

@ -0,0 +1,44 @@
# Template: install as .gitea/workflows/sync-zpwiki.yml on dp-zp-agent/master.
# Also copy sync_fork.sh and ssh_known_hosts to scripts/git_runner/ on that branch.
name: Denna aktualizacia zpwiki forku
"on":
push:
branches: [master]
paths:
- .gitea/workflows/sync-zpwiki.yml
- scripts/git_runner/sync_fork.sh
- scripts/git_runner/ssh_known_hosts
schedule:
- cron: "0 3 * * *"
jobs:
sync-wiki:
runs-on: zpwiki-sync
timeout-minutes: 10
steps:
- name: Pripravit Git a Bash
shell: sh
run: apk add --no-cache bash git openssh-client ca-certificates curl
- name: Synchronizovat fork s hlavnou wiki
shell: bash
env:
ZPWIKI_SYNC_SSH_KEY: ${{ secrets.ZPWIKI_SYNC_SSH_KEY }}
WORKFLOW_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
set +x
if [[ ! "$WORKFLOW_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Chyba platna Git revizia workflow." >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
for file in sync_fork.sh ssh_known_hosts; do
curl --fail --silent --show-error --proto '=https' \
--connect-timeout 15 --max-time 60 --retry 2 \
"https://git.kemt.fei.tuke.sk/jp170na/dp-zp-agent/raw/commit/$WORKFLOW_SHA/scripts/git_runner/$file" \
--output "$work/$file"
done
bash "$work/sync_fork.sh"

View File

@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Preserve original upstream commits; never force-push or write to KEMT/zpwiki.
set -euo pipefail
set +x
umask 077
fork_url='git@git.kemt.fei.tuke.sk:jp170na/zpwiki.git'
upstream_url='git@git.kemt.fei.tuke.sk:KEMT/zpwiki.git'
script_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
check_only=false
case "${1:-}" in
--check) check_only=true ;;
'') ;;
-h|--help)
echo "Pouzitie: bash scripts/git_runner/sync_fork.sh [--check]"
echo "--check iba porovna vetvy bez zapisu; pouzije dostupny lokalny SSH pristup."
exit 0
;;
*) echo "Neznamy parameter." >&2; exit 64 ;;
esac
if [[ $# -gt 1 ]]; then
echo "Prilis vela parametrov." >&2
exit 64
fi
if ! "$check_only"; then
: "${ZPWIKI_SYNC_SSH_KEY:?Chyba Actions secret ZPWIKI_SYNC_SSH_KEY}"
fi
export GIT_TERMINAL_PROMPT=0 LC_ALL=C
export ZPWIKI_KNOWN_HOSTS_FILE="$script_root/ssh_known_hosts"
if [[ ! -s "$ZPWIKI_KNOWN_HOSTS_FILE" ]]; then
echo "Chyba doveryhodny subor ssh_known_hosts vedla skriptu." >&2
exit 1
fi
work="$(mktemp -d /tmp/zpwiki-fork-sync.XXXXXXXX)"
trap 'rm -rf "$work"' EXIT
if [[ -n "${ZPWIKI_SYNC_SSH_KEY:-}" ]]; then
key_text="${ZPWIKI_SYNC_SSH_KEY//$'\r'/}"
printf '%s\n' "${key_text%$'\n'}" > "$work/id_ed25519"
unset key_text
chmod 600 "$work/id_ed25519"
export ZPWIKI_SSH_KEY_FILE="$work/id_ed25519"
fi
unset ZPWIKI_SYNC_SSH_KEY
cat > "$work/ssh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
options=(-F /dev/null -o BatchMode=yes -o StrictHostKeyChecking=yes
-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=2
-o "UserKnownHostsFile=$ZPWIKI_KNOWN_HOSTS_FILE")
if [[ -n "${ZPWIKI_SSH_KEY_FILE:-}" ]]; then
options+=(-o IdentitiesOnly=yes -i "$ZPWIKI_SSH_KEY_FILE")
fi
exec ssh "${options[@]}" "$@"
SH
chmod 700 "$work/ssh"
# mktemp's fixed /tmp template contains no shell metacharacters.
export GIT_SSH_COMMAND="$work/ssh" GIT_SSH_VARIANT=ssh
git_cmd() {
git -c core.hooksPath=/dev/null "$@"
}
git_cmd clone --quiet --bare --single-branch --branch master --no-tags \
"$fork_url" "$work/wiki.git"
cd "$work/wiki.git"
git_cmd fetch --quiet --no-tags "$upstream_url" \
refs/heads/master:refs/remotes/upstream/master
fork="$(git_cmd rev-parse refs/heads/master)"
upstream="$(git_cmd rev-parse refs/remotes/upstream/master)"
echo "Fork master: $fork"
echo "KEMT master: $upstream"
if git_cmd merge-base --is-ancestor "$upstream" "$fork"; then
echo "Fork uz obsahuje vsetky zmeny hlavnej wiki. Bez zapisu."
exit 0
fi
if ! git_cmd merge-base --is-ancestor "$fork" "$upstream"; then
echo "CHYBA: Vetvy sa rozisli. Fork nebol prepisany." >&2
exit 1
fi
if "$check_only"; then
echo "Fork potrebuje fast-forward aktualizaciu. Kontrola nic nezmenila."
exit 0
fi
# Normal push also rejects a concurrent divergent update on the server.
git_cmd push origin "$upstream:refs/heads/master"
echo "Fork bol aktualizovany na $upstream."

244
test/test_git_runner.py Normal file
View File

@ -0,0 +1,244 @@
"""Exercise real local Git remotes; never register a runner or contact Gitea."""
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[1]
RUNNER = ROOT / 'scripts/git_runner'
FORK = 'git@git.kemt.fei.tuke.sk:jp170na/zpwiki.git'
UPSTREAM = 'git@git.kemt.fei.tuke.sk:KEMT/zpwiki.git'
def git(repo, *args):
return subprocess.run(['git', '-C', str(repo), *args], check=True,
capture_output=True, text=True).stdout.strip()
def commit(repo, name):
(repo / name).write_text(name)
git(repo, 'add', name)
git(repo, '-c', 'user.name=Sync Test', '-c', 'user.email=sync@example.invalid',
'commit', '-m', name)
return git(repo, 'rev-parse', 'HEAD')
@pytest.fixture
def repositories(tmp_path):
source = tmp_path / 'upstream'
subprocess.run(['git', 'init', '-b', 'master', str(source)], check=True, capture_output=True)
commit(source, 'initial')
fork = tmp_path / 'fork.git'
subprocess.run(['git', 'clone', '--bare', str(source), str(fork)], check=True, capture_output=True)
script = tmp_path / 'sync_fork.sh'
# Only transport endpoints differ; execute the unmodified production algorithm.
script.write_text((RUNNER / 'sync_fork.sh').read_text()
.replace(FORK, str(fork)).replace(UPSTREAM, str(source)))
shutil.copy2(RUNNER / 'ssh_known_hosts', tmp_path / 'ssh_known_hosts')
return source, fork, script
def sync(repositories, *args, token='test-token-never-print'):
source, fork, script = repositories
env = {**os.environ, 'ZPWIKI_SYNC_SSH_KEY': token}
result = subprocess.run(['bash', str(script), *args], env=env, capture_output=True, text=True)
assert not token or token not in result.stdout + result.stderr
return result
def test_fast_forward_preserves_upstream_commit_and_other_fork_branches(repositories):
source, fork, _ = repositories
before = git(fork, 'rev-parse', 'master')
git(fork, 'branch', 'keep-me', before)
after = commit(source, 'upstream-update')
result = sync(repositories)
assert result.returncode == 0, result.stderr
assert git(fork, 'rev-parse', 'master') == after
assert git(fork, 'rev-parse', 'keep-me') == before
assert git(source, 'rev-parse', 'HEAD') == after
assert git(fork, 'rev-list', '--count', 'master') == '2'
def test_repeated_sync_is_a_noop(repositories):
_, fork, _ = repositories
before = git(fork, 'rev-parse', 'master')
for _ in range(2):
result = sync(repositories)
assert result.returncode == 0 and 'Bez zapisu' in result.stdout
assert git(fork, 'rev-parse', 'master') == before
def test_check_mode_needs_no_token_and_does_not_push(repositories):
source, fork, _ = repositories
before = git(fork, 'rev-parse', 'master')
commit(source, 'pending-update')
result = sync(repositories, '--check', token='')
assert result.returncode == 0 and 'potrebuje fast-forward' in result.stdout
assert git(fork, 'rev-parse', 'master') == before
def test_missing_token_stops_before_any_write(repositories):
source, fork, _ = repositories
before = git(fork, 'rev-parse', 'master')
commit(source, 'pending-update')
result = sync(repositories, token='')
assert result.returncode != 0 and 'ZPWIKI_SYNC_SSH_KEY' in result.stderr
assert git(fork, 'rev-parse', 'master') == before
def test_ssh_transport_keeps_key_private_and_pushes_only_the_fork(repositories, tmp_path):
source, fork, _ = repositories
wanted = commit(source, 'ssh-update')
script = tmp_path / 'original-sync.sh'
shutil.copy2(RUNNER / 'sync_fork.sh', script)
executable = tmp_path / 'bin/ssh'
executable.parent.mkdir()
executable.write_text('''#!/usr/bin/env python3
import json,os,shlex,stat,sys
from pathlib import Path
args=sys.argv[1:]
for value in ['BatchMode=yes','StrictHostKeyChecking=yes','IdentitiesOnly=yes']:
assert value in args
assert args[args.index('-F')+1]=='/dev/null'
assert 'ZPWIKI_SYNC_SSH_KEY' not in os.environ
key=Path(args[args.index('-i')+1])
assert stat.S_IMODE(key.stat().st_mode)==0o600
assert key.read_text()=='fixture-private-key\\n'
assert any(a.startswith('UserKnownHostsFile=') for a in args)
command,repo=shlex.split(args[-1])
assert command in ['git-upload-pack','git-receive-pack']
assert command!='git-receive-pack' or repo=='jp170na/zpwiki.git'
target={'jp170na/zpwiki.git':os.environ['FORK_TEST_PATH'],
'KEMT/zpwiki.git':os.environ['UPSTREAM_TEST_PATH']}[repo]
with open(os.environ['SSH_TEST_RECORD'],'a') as f:
f.write(json.dumps({'command':command,'repo':repo,'key':str(key)})+'\\n')
os.execvp(command,[command,target])
''')
executable.chmod(0o700)
record = tmp_path / 'ssh-calls.jsonl'
env = {**os.environ,'PATH':str(executable.parent)+os.pathsep+os.environ['PATH'],
'FORK_TEST_PATH':str(fork),'UPSTREAM_TEST_PATH':str(source / '.git'),
'SSH_TEST_RECORD':str(record),'ZPWIKI_SYNC_SSH_KEY':'fixture-private-key\r\n'}
result = subprocess.run(['bash',str(script)],env=env,capture_output=True,text=True)
assert result.returncode == 0, result.stderr
assert git(fork,'rev-parse','master') == wanted
calls = [json.loads(row) for row in record.read_text().splitlines()]
assert len(calls) == 3
assert calls[-1]['command'] == 'git-receive-pack' and calls[-1]['repo']=='jp170na/zpwiki.git'
assert all(not Path(row['key']).exists() for row in calls)
assert 'fixture-private-key' not in result.stdout + result.stderr
def fork_edit(fork, tmp_path):
edit = tmp_path / 'fork-edit'
subprocess.run(['git', 'clone', str(fork), str(edit)], check=True, capture_output=True)
own = commit(edit, 'own-fork-change')
git(edit, 'push', 'origin', 'master')
return own
def test_fork_own_commits_are_preserved_when_upstream_is_already_contained(repositories, tmp_path):
source, fork, _ = repositories
own = fork_edit(fork, tmp_path)
result = sync(repositories)
assert result.returncode == 0 and git(fork, 'rev-parse', 'master') == own
@pytest.mark.parametrize('args', [(), ('--check',)])
def test_divergence_is_rejected_without_rewriting_either_repository(repositories, tmp_path, args):
source, fork, _ = repositories
own = fork_edit(fork, tmp_path)
upstream = commit(source, 'different-upstream-change')
result = sync(repositories, *args)
assert result.returncode != 0 and 'Vetvy sa rozisli' in result.stderr
assert git(fork, 'rev-parse', 'master') == own
assert git(source, 'rev-parse', 'HEAD') == upstream
def test_upstream_transport_failure_does_not_update_fork(repositories):
source, fork, _ = repositories
before = git(fork, 'rev-parse', 'master')
shutil.rmtree(source)
assert sync(repositories).returncode != 0
assert git(fork, 'rev-parse', 'master') == before
@pytest.mark.parametrize('args', [('--force',), ('--check', 'unexpected')])
def test_unknown_options_never_change_fork(repositories, args):
_, fork, _ = repositories
before = git(fork, 'rev-parse', 'master')
assert sync(repositories, *args).returncode == 64
assert git(fork, 'rev-parse', 'master') == before
def test_runner_init_preserves_existing_private_token(tmp_path):
for name in ['runner.sh', 'runner.env.example']:
shutil.copy2(RUNNER / name, tmp_path / name)
command = ['bash', str(tmp_path / 'runner.sh'), 'init']
subprocess.run(command, check=True, capture_output=True)
env = tmp_path / '.env'
env.write_text('RUNNER_REGISTRATION_TOKEN=private-test-value\n')
env.chmod(0o644)
result = subprocess.run(command, check=True, capture_output=True, text=True)
assert env.read_text() == 'RUNNER_REGISTRATION_TOKEN=private-test-value\n'
assert stat.S_IMODE(env.stat().st_mode) == 0o600
assert stat.S_IMODE((tmp_path / 'data').stat().st_mode) == 0o700
assert 'private-test-value' not in result.stdout + result.stderr
def test_runner_helper_targets_only_its_own_compose_project(tmp_path):
for name in ['runner.sh', 'runner.env.example', 'compose.yml']:
shutil.copy2(RUNNER / name, tmp_path / name)
subprocess.run(['bash', str(tmp_path / 'runner.sh'), 'init'], check=True, capture_output=True)
(tmp_path / '.env').write_text('RUNNER_REGISTRATION_TOKEN=private-test-value\n')
executable = tmp_path / 'bin/docker'
executable.parent.mkdir()
executable.write_text('#!/usr/bin/env python3\nimport json,os,sys\n'
'with open(os.environ["RECORDED_COMMANDS"],"a") as f: '
'f.write(json.dumps(sys.argv[1:])+"\\n")\n')
executable.chmod(0o700)
record = tmp_path / 'commands.jsonl'
env = {**os.environ, 'PATH':str(executable.parent)+os.pathsep+os.environ['PATH'],
'ZP_DOCKER_CONTEXT':'fixture-context', 'RECORDED_COMMANDS':str(record)}
subprocess.run(['bash', str(tmp_path / 'runner.sh'), 'up'], env=env, check=True, capture_output=True)
commands = [json.loads(row) for row in record.read_text().splitlines()]
assert len(commands) == 3
assert commands[0][-2:] == ['config','--quiet']
assert commands[1][-2:] == ['up','-d'] and commands[2][-1] == 'ps'
for command in commands:
assert command[:2] == ['--context','fixture-context']
assert command[command.index('--project-name')+1] == 'zp-git-runner'
assert command[command.index('-f')+1] == str(tmp_path / 'compose.yml')
assert 'private-test-value' not in ' '.join(command)
def test_daily_workflow_and_runner_configuration_match():
workflow = yaml.safe_load((RUNNER / 'sync-zpwiki.yml').read_text())
assert workflow['on']['schedule'] == [{'cron':'0 3 * * *'}]
assert workflow['on']['push']['branches'] == ['master']
assert not {'pull_request','pull_request_target','workflow_dispatch'} & workflow['on'].keys()
job = workflow['jobs']['sync-wiki']
compose = yaml.safe_load((RUNNER / 'compose.yml').read_text())
runner = compose['services']['runner']
assert runner['environment']['GITEA_RUNNER_LABELS'].startswith(job['runs-on']+':docker://')
assert 'RUNNER_REGISTRATION_TOKEN' in runner['environment']['GITEA_RUNNER_REGISTRATION_TOKEN']
step = job['steps'][-1]
assert step['env']['ZPWIKI_SYNC_SSH_KEY'] == '${{ secrets.ZPWIKI_SYNC_SSH_KEY }}'
assert 'raw/commit/$WORKFLOW_SHA/scripts/git_runner/$file' in step['run']
assert 'for file in sync_fork.sh ssh_known_hosts' in step['run']
assert 'set +x' in step['run']
for step in job['steps']:
subprocess.run([step['shell'], '-n'], input=step['run'], text=True, check=True)
@pytest.mark.parametrize('private_path', ['scripts/git_runner/.env','scripts/git_runner/data/.runner'])
def test_runner_credentials_and_state_are_ignored_by_git(private_path):
result = subprocess.run(['git','check-ignore','--no-index',private_path], cwd=ROOT,
capture_output=True, text=True)
assert result.returncode == 0