245 lines
11 KiB
Python
245 lines
11 KiB
Python
"""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
|