diff --git a/scripts/git_runner/compose.yml b/scripts/git_runner/compose.yml new file mode 100644 index 0000000..766d853 --- /dev/null +++ b/scripts/git_runner/compose.yml @@ -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 diff --git a/scripts/git_runner/runner.env.example b/scripts/git_runner/runner.env.example new file mode 100644 index 0000000..ed55f21 --- /dev/null +++ b/scripts/git_runner/runner.env.example @@ -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= diff --git a/scripts/git_runner/runner.sh b/scripts/git_runner/runner.sh new file mode 100644 index 0000000..1d611af --- /dev/null +++ b/scripts/git_runner/runner.sh @@ -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 diff --git a/scripts/git_runner/ssh_known_hosts b/scripts/git_runner/ssh_known_hosts new file mode 100644 index 0000000..4ea19ae --- /dev/null +++ b/scripts/git_runner/ssh_known_hosts @@ -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 diff --git a/scripts/git_runner/sync-zpwiki.yml b/scripts/git_runner/sync-zpwiki.yml new file mode 100644 index 0000000..fd8b470 --- /dev/null +++ b/scripts/git_runner/sync-zpwiki.yml @@ -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" diff --git a/scripts/git_runner/sync_fork.sh b/scripts/git_runner/sync_fork.sh new file mode 100644 index 0000000..74e29fd --- /dev/null +++ b/scripts/git_runner/sync_fork.sh @@ -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." diff --git a/test/test_git_runner.py b/test/test_git_runner.py new file mode 100644 index 0000000..8c0316e --- /dev/null +++ b/test/test_git_runner.py @@ -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