dp-zp-agent/test/test_git_runner.py

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