308 lines
12 KiB
Python
308 lines
12 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 / "tools/git_runner"
|
|
LAUNCHER = ROOT / "scripts/runner.sh"
|
|
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 helper_layout(root):
|
|
launcher = root / "scripts/runner.sh"
|
|
launcher.parent.mkdir()
|
|
shutil.copy2(LAUNCHER, launcher)
|
|
config = root / "tools/git_runner"
|
|
config.mkdir(parents=True)
|
|
shutil.copy2(RUNNER / "compose.yml", config / "compose.yml")
|
|
return launcher, config
|
|
|
|
|
|
def test_runner_init_preserves_main_env_and_existing_private_token(tmp_path):
|
|
launcher, config = helper_layout(tmp_path)
|
|
command = ["bash", str(launcher), "init"]
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
env = tmp_path / ".env"
|
|
content = "SEARCH_API_KEY=existing-api-value\nRUNNER_REGISTRATION_TOKEN=private-test-value\n"
|
|
env.write_text(content)
|
|
env.chmod(0o644)
|
|
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
|
assert env.read_text() == content
|
|
assert stat.S_IMODE(env.stat().st_mode) == 0o600
|
|
assert stat.S_IMODE((config / "data").stat().st_mode) == 0o700
|
|
assert not (config / ".env").exists()
|
|
assert "private-test-value" not in result.stdout + result.stderr
|
|
|
|
|
|
def test_runner_init_adds_token_setting_without_changing_backend_settings(tmp_path):
|
|
launcher, config = helper_layout(tmp_path)
|
|
env = tmp_path / ".env"
|
|
content = "SEARCH_API_KEY=existing-api-value\nNEO4J_DATABASE=neo4j\n"
|
|
env.write_text(content)
|
|
command = ["bash", str(launcher), "init"]
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
updated = env.read_text()
|
|
assert updated.startswith(content)
|
|
assert updated.count("RUNNER_REGISTRATION_TOKEN=") == 1
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
assert env.read_text() == updated
|
|
assert not (config / ".env").exists()
|
|
|
|
|
|
def test_runner_helper_targets_only_its_own_compose_project(tmp_path):
|
|
launcher, config = helper_layout(tmp_path)
|
|
subprocess.run(["bash", str(launcher), "init"], check=True, capture_output=True)
|
|
(tmp_path / ".env").write_text("RUNNER_REGISTRATION_TOKEN=private-test-value\n")
|
|
registration = config / "data/.runner"
|
|
registration.write_text('{"id":1,"token":"keep-existing-registration"}')
|
|
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(launcher), "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(config / "compose.yml")
|
|
assert command[command.index("--env-file") + 1] == str(tmp_path / ".env")
|
|
assert "private-test-value" not in " ".join(command)
|
|
assert registration.read_text() == '{"id":1,"token":"keep-existing-registration"}'
|
|
assert not (config / ".env").exists()
|
|
|
|
|
|
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["container_name"] == "zpwiki-sync"
|
|
assert runner["environment"]["GITEA_RUNNER_NAME"] == "zpwiki-sync"
|
|
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/tools/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", ["tools/git_runner/.env", "tools/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
|