help='Per-test timeout in seconds (default: 300)')
p.add_argument('--rsync-bin', default=None, metavar='PATH',
help='Path to rsync binary (default: ./rsync)')
+ p.add_argument('--rsync-bin2', default=None, metavar='PATH',
+ help='Path to a second ("peer") rsync binary used for the '
+ 'daemon side and remote-shell --rsync-path. Lets the '
+ 'suite mix two rsync versions over the wire. Default: '
+ 'same as --rsync-bin (no version mixing).')
p.add_argument('--tooldir', default=None, metavar='DIR',
help='Tool/build directory (default: cwd)')
p.add_argument('--srcdir', default=None, metavar='DIR',
help='Force protocol version (adds --protocol=VER to rsync)')
p.add_argument('--expect-skipped', default=None, metavar='LIST',
help='Comma-separated list of expected-skipped tests')
+ p.add_argument('--expect-result', default=None, metavar='FILE',
+ help='Path to an expected-outcome manifest (one '
+ '"<testname> <pass|skip|fail|xfail>" per line). When '
+ 'set, ONLY the tests listed in FILE are run, and each '
+ "test's actual outcome is compared against its "
+ 'expected one; any mismatch (including an unexpected '
+ 'pass) fails the run. Used for version-mixing CI.')
p.add_argument('--use-tcp', action='store_true',
help='Run daemon tests against a real rsyncd bound to '
'127.0.0.1 (non-default). The default is the secure '
return tests
+_VALID_OUTCOMES = ('pass', 'skip', 'fail', 'xfail')
+
+
+def parse_expect_result(path):
+ """Parse an expected-outcome manifest into {testbase: outcome}.
+
+ One "<testname> <outcome>" entry per line; '#' comments and blank lines
+ are ignored. outcome is one of pass|skip|fail|xfail. The set of listed
+ tests doubles as the run set (see main()). Exits 2 on a malformed file.
+ """
+ expect = {}
+ with open(path) as f:
+ for lineno, raw in enumerate(f, 1):
+ line = raw.split('#', 1)[0].strip()
+ if not line:
+ continue
+ fields = line.split()
+ if len(fields) != 2 or fields[1] not in _VALID_OUTCOMES:
+ sys.stderr.write(
+ f"{path}:{lineno}: expected '<testname> "
+ f"<{'|'.join(_VALID_OUTCOMES)}>', got: {raw.rstrip()}\n"
+ )
+ sys.exit(2)
+ expect[fields[0]] = fields[1]
+ return expect
+
+
+def outcome_of(result):
+ """Map a per-test exit code to an outcome string."""
+ if result == 0:
+ return 'pass'
+ if result == 77:
+ return 'skip'
+ if result == 78:
+ return 'xfail'
+ return 'fail'
+
+
def build_rsync_cmd(rsync_bin, args, scratchbase):
"""Build the RSYNC command string for tests."""
parts = []
if rsync_bin and not os.path.isabs(rsync_bin):
rsync_bin = os.path.abspath(rsync_bin)
+ # Optional second ("peer") binary for the daemon / remote-shell side, so a
+ # run can mix two rsync versions. Defaults to rsync_bin -> no mixing.
+ rsync_bin2 = args.rsync_bin2 or os.environ.get('rsync_bin2') or rsync_bin
+ if rsync_bin2 and not os.path.isabs(rsync_bin2):
+ rsync_bin2 = os.path.abspath(rsync_bin2)
+
suitedir = os.path.join(srcdir, 'testsuite')
scratchbase = os.path.join(os.environ.get('scratchbase', tooldir), 'testtmp')
os.makedirs(scratchbase, exist_ok=True)
tls_args = get_tls_args(os.path.join(tooldir, 'config.h'))
setfacl_nodef = find_setfacl_nodef(scratchbase)
rsync_cmd = build_rsync_cmd(rsync_bin, args, scratchbase)
+ rsync_peer_cmd = build_rsync_cmd(rsync_bin2, args, scratchbase)
if not os.path.isfile(rsync_bin):
sys.stderr.write(f"rsync_bin {rsync_bin} is not a file\n")
sys.exit(2)
+ if not os.path.isfile(rsync_bin2):
+ sys.stderr.write(f"rsync_bin2 {rsync_bin2} is not a file\n")
+ sys.exit(2)
if not os.path.isdir(srcdir):
sys.stderr.write(f"srcdir {srcdir} is not a directory\n")
sys.exit(2)
print('=' * 60)
print(f'{sys.argv[0]} running in {tooldir}')
print(f' rsync_bin={rsync_cmd}')
+ if rsync_peer_cmd != rsync_cmd:
+ print(f' rsync_peer={rsync_peer_cmd}')
print(f' srcdir={srcdir}')
print(f' TLS_ARGS={tls_args}')
print(f' testuser={testuser}')
'TOOLDIR': tooldir,
'srcdir': srcdir,
'RSYNC': rsync_cmd,
+ 'RSYNC_PEER': rsync_peer_cmd,
'TLS_ARGS': tls_args,
'RUNSHFLAGS': '-e',
'scratchbase': scratchbase,
print(f"Excluding {before - len(tests)} test(s) matching: "
f"{', '.join(excl)}")
+ # An expected-result manifest defines BOTH the run set (its keys) and the
+ # expected per-test outcome (its values). Used for version-mixing runs.
+ expect = parse_expect_result(args.expect_result) if args.expect_result else None
+ if expect is not None:
+ have = {_testbase(t) for t in tests}
+ unknown = sorted(k for k in expect if k not in have)
+ if unknown:
+ sys.stderr.write(
+ "runtests.py: --expect-result lists test(s) with no matching "
+ f"test file (ignored): {', '.join(unknown)}\n"
+ )
+ tests = [t for t in tests if _testbase(t) in expect]
+ full_run = False
+
+ def _cls(outcome):
+ """Equivalence class for outcome comparison: fail and xfail both just
+ mean 'broke', so a manifest 'fail' matches an actual fail OR xfail."""
+ return 'broken' if outcome in ('fail', 'xfail') else outcome
+
+ def mismatch(testbase, actual):
+ """True if actual outcome disagrees with the manifest expectation."""
+ return expect is not None and _cls(expect[testbase]) != _cls(actual)
+
# Record test order for consistent skipped-list output
test_order = {_testbase(t): i for i, t in enumerate(tests)}
failed = 0
skipped = 0
skipped_list = []
+ outcomes = {} # testbase -> actual outcome string ('pass'/'skip'/'fail'/'xfail')
def process_result(tr):
- """Process a TestResult and update counters. Returns True if test failed."""
+ """Process a TestResult and update counters. Returns True if the test
+ should count as a failure for --stop-on-fail purposes."""
nonlocal passed, failed, skipped
with _print_lock:
if tr.output:
print(tr.output)
scratchdir = os.path.join(scratchbase, tr.testbase)
+ oc = outcome_of(tr.result)
+ outcomes[tr.testbase] = oc
if tr.result == 0:
passed += 1
- if not args.preserve_scratch and os.path.isdir(scratchdir):
- subprocess.run(['rm', '-rf', scratchdir], capture_output=True)
- return False
elif tr.result == 77:
skipped_list.append(tr.testbase)
skipped += 1
- if not args.preserve_scratch and os.path.isdir(scratchdir):
- subprocess.run(['rm', '-rf', scratchdir], capture_output=True)
- return False
- elif tr.result == 78:
- failed += 1
- return True
else:
failed += 1
- return True
+ if tr.result in (0, 77) and not args.preserve_scratch \
+ and os.path.isdir(scratchdir):
+ subprocess.run(['rm', '-rf', scratchdir], capture_output=True)
+ # With a manifest, only a mismatch is a "failure" (an expected fail is
+ # fine); without one, any non-pass/non-skip result is a failure.
+ if expect is not None:
+ return mismatch(tr.testbase, oc)
+ return tr.result not in (0, 77)
if args.parallel > 1:
# Parallel execution
if vg_errors > 0:
print(f' {vg_errors} valgrind error(s) found (see logs in {scratchbase})')
+ if expect is not None:
+ # Version-mixing mode: the run is judged purely on whether each test's
+ # actual outcome matched its manifest expectation. An expected 'fail'
+ # is fine; an UNEXPECTED pass (xpass) or any other divergence is not.
+ mismatches = []
+ for tb in sorted(expect, key=lambda x: test_order.get(x, 1 << 30)):
+ actual = outcomes.get(tb, 'notrun')
+ if actual == 'notrun' or mismatch(tb, actual):
+ mismatches.append((tb, expect[tb], actual))
+ if mismatches:
+ print('----- expected-result mismatches:')
+ for tb, want, got in mismatches:
+ tag = ' (xpass)' if _cls(want) == 'broken' and got == 'pass' else ''
+ print(f' {tb}: expected {want}, got {got}{tag}')
+ print('-' * 60)
+ exit_code = len(mismatches) + vg_errors
+ print(f'overall result is {exit_code}')
+ sys.exit(exit_code)
+
skipped_str = ','.join(sorted(skipped_list, key=lambda x: test_order.get(x, 0)))
if full_run and args.expect_skipped != 'IGNORE':
print('----- skipped results:')
import os
from rsyncfns import (
- FROMDIR, RSYNC, SRCDIR, TODIR,
+ FROMDIR, RSYNC, RSYNC_PEER, SRCDIR, TODIR,
checkit, run_rsync, test_fail,
)
def copy_weird(args: list, src_host: str, dst_host: str) -> None:
checkit(
- [*args, f'--rsync-path={RSYNC}',
+ [*args, f'--rsync-path={RSYNC_PEER}',
f'{src_host}{weird_dir}/',
f'{dst_host}{TODIR / weird_name}'],
FROMDIR, TODIR,
saved = os.getcwd()
os.chdir(FROMDIR)
try:
- run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC}',
+ run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC_PEER}',
'lh:one two', f'{TODIR}/')
finally:
os.chdir(saved)
os.chdir(FROMDIR)
try:
subprocess.run(
- rsync_argv('-ai', f'--rsync-path={RSYNC}',
+ rsync_argv('-ai', f'--rsync-path={RSYNC_PEER}',
'lh:one two', f'{TODIR}/'),
env=env, check=True,
)
import time
from rsyncfns import (
- CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
+ CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
checkit, hands_setup, rmtree, run_rsync, test_fail,
)
for srchost in ('', 'localhost:'):
desthost = 'localhost:' if not srchost else ''
rmtree(TODIR)
- checkit(['-ave', SSH, f'--rsync-path={RSYNC}', *maybe_inplace,
+ checkit(['-ave', SSH, f'--rsync-path={RSYNC_PEER}', *maybe_inplace,
f'--copy-dest={alt3dir}',
f'{srchost}{FROMDIR}/', f'{desthost}{TODIR}/'],
FROMDIR, TODIR)
import subprocess
from rsyncfns import (
- CHKFILE, FROMDIR, OUTFILE, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
+ CHKFILE, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR,
build_rsyncd_conf, get_rootuid, get_testuid, makepath,
rsync_argv, run_rsync, start_test_daemon, test_fail,
)
# Module list via the lsh.sh stand-in.
-rsync_path = f"{RSYNC}{(' ' + ' '.join(confopt)) if confopt else ''}"
+rsync_path = f"{RSYNC_PEER}{(' ' + ' '.join(confopt)) if confopt else ''}"
out = run_and_check(
['-ve', SSH, f'--rsync-path={rsync_path}', 'localhost::'],
"module list via lsh.sh",
import sys
from rsyncfns import (
- CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
+ CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
all_plus, allspace, dots,
checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail,
)
script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
if 'lsh' in script_name:
os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
- rpath = [f'--rsync-path={RSYNC}']
+ rpath = [f'--rsync-path={RSYNC_PEER}']
host = 'lh:'
else:
rpath = []
# --- main checks ------------------------------------------------------------
# Start with a check of --prune-empty-dirs.
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'-f', '-_foo/too/', '-f', '-_foo/down/',
'-f', '-_foo/and/', '-f', '-_new/',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
(up2 / 'dst-newness').touch()
# Un-tweak the directory times in our first (weak) exclude test.
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--existing', '--include=*/', '--exclude=*',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
(CHKDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').unlink()
(CHKDIR / 'mid' / 'one-in-one-out').unlink()
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--existing', '--filter=exclude,! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
cp_touch(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to',
CHKDIR / 'bar' / 'down' / 'to' / 'foo')
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--existing', '-f', 'show .filt*', '-f', 'hide,! */', '--del',
f'{host}{FROMDIR}/', f'{TODIR}/')
cp_touch(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep',
CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz')
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--existing', '--filter=-! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
(CHKDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').unlink()
(CHKDIR / 'mid' / '.filt').unlink()
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--existing', '--include=*/', '--exclude=*',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
"+ file3\n*.bak\n"
)
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--del', f'{host}{FROMDIR}/', f'{CHKDIR}/')
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').unlink()
(CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').unlink()
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--existing', '--filter=-! */',
f'{host}{FROMDIR}/', f'{CHKDIR}/')
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
'--delete-excluded', '--exclude=*',
f'{host}{FROMDIR}/', f'{TODIR}/')
# Combine with --relative.
relative_opts = ['--relative', '--chmod=Du+w', '--copy-unsafe-links']
-run_rsync('-av', f'--rsync-path={RSYNC}', *relative_opts,
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}', *relative_opts,
f'{host}{FROMDIR}/foo', f'{CHKDIR}/')
shutil.rmtree(str(CHKDIR) + str(FROMDIR) + '/foo/down', ignore_errors=True)
run_rsync('-av', *relative_opts, '--existing', '--filter=-! */',
# files-host / src-host / dest-host placement combinations.
from rsyncfns import (
- CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
+ CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR,
checkit, hands_setup, rmtree, run_rsync,
)
rmtree(TODIR)
checkit(
- ['-avse', SSH, f'--rsync-path={RSYNC}',
+ ['-avse', SSH, f'--rsync-path={RSYNC_PEER}',
f'--files-from={filehost}{filelist}',
f'{srchost}{SCRATCHDIR}', f'{desthost}{TODIR}/'],
CHKDIR, TODIR,
import subprocess
from rsyncfns import (
- CHKDIR, FROMDIR, OUTFILE, RSYNC, SRCDIR, TODIR,
+ CHKDIR, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SRCDIR, TODIR,
checkit, makepath, rsync_argv, test_fail, test_skipped,
)
os.link(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file')
(TODIR / 'text').unlink()
-checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC}',
+checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC_PEER}',
f'{FROMDIR}/', f'localhost:{TODIR}/'], FROMDIR, TODIR)
# --link-dest and --copy-dest should also keep hard-linked entries.
os.environ['HOME'] = str(SCRATCHDIR)
RSYNC = _required('RSYNC') # full command line, possibly with valgrind/protocol
+# The "peer" rsync command -- used for the SERVER side of two-sided transfers
+# (the daemon process; the remote-shell --rsync-path target). The runner sets
+# RSYNC_PEER to a second binary when invoked with --rsync-bin2, letting a run
+# mix two rsync versions over the wire. When no second binary was selected,
+# RSYNC_PEER == RSYNC, so every consumer below behaves exactly as before and
+# single-binary runs are unchanged. Use .get (not _required) so a test invoked
+# by hand without the runner still works.
+RSYNC_PEER = os.environ.get('RSYNC_PEER', RSYNC)
+
# TLS_ARGS controls how the 'tls' helper formats listings (e.g. --atimes,
# -l, -L). Tests that exercise non-default rsync features (atimes, etc.)
# assign to rsyncfns.TLS_ARGS before calling checkit / rsync_ls_lR.
pass
-def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen':
+def start_rsyncd(conf_path, port: int, rsync_cmd: str = None) -> 'subprocess.Popen':
"""Spawn `rsync --daemon --no-detach --address=127.0.0.1 --port=N
--config=conf` and return the Popen handle after the port is accepting
connections.
the test process doesn't strand the daemon either. The caller is expected
to have already claim_ports()'d `port`.
+ rsync_cmd selects the binary to run as the daemon; it defaults to
+ RSYNC_PEER (the peer side of a two-sided run), so ordinary daemon tests
+ get current-client <-> peer-daemon. The reverse-direction test passes
+ rsync_cmd=RSYNC to put the current build on the daemon side and drive with
+ the old client.
+
This is only ever reached from start_test_daemon() in --use-tcp mode; the
default (pipe) mode never starts a listening daemon.
"""
- argv = shlex.split(RSYNC) + [
+ argv = shlex.split(rsync_cmd or RSYNC_PEER) + [
'--daemon', '--no-detach',
'--address=127.0.0.1',
f'--port={port}',
test_fail(f"rsyncd never listened on 127.0.0.1:{port}: {last_err}")
-def start_test_daemon(conf_path, port: int) -> str:
+def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str:
"""Bring up the test daemon and return a URL prefix for client commands.
+ rsync_cmd selects the daemon-side binary (default RSYNC_PEER); pass
+ rsync_cmd=RSYNC for the reverse-direction test (current daemon, old client).
+
This is the single seam every daemon test uses. The transport depends on
the mode the runner selected:
Build URLs as f"{prefix}module/path". `port` is only used (and claimed)
in --use-tcp mode.
"""
+ daemon_cmd = rsync_cmd or RSYNC_PEER
if USE_TCP:
claim_ports(port)
- start_rsyncd(conf_path, port)
+ start_rsyncd(conf_path, port, daemon_cmd)
return f'rsync://localhost:{port}/'
- os.environ['RSYNC_CONNECT_PROG'] = f'{RSYNC} --config={conf_path} --daemon'
+ os.environ['RSYNC_CONNECT_PROG'] = f'{daemon_cmd} --config={conf_path} --daemon'
return 'rsync://localhost/'
hands_setup()
# RSYNC may be a multi-word command line; pass it through --rsync-path.
-from rsyncfns import RSYNC
+from rsyncfns import RSYNC, RSYNC_PEER
def _basic():
- checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC}',
+ checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}',
f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
def _delete_after_rename():
shutil.move(str(TODIR / 'text'), str(TODIR / 'ThisShouldGo'))
- checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC}',
+ checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}',
f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
import time
from rsyncfns import (
- RSYNC, SCRATCHDIR, SRCDIR, TMPDIR,
+ RSYNC, SCRATCHDIR, RSYNC_PEER, SRCDIR, TMPDIR,
make_data_file, resolve_beneath_supported, rsync_argv, test_fail,
test_skipped,
)
os.chdir(srcbase)
try:
proc = subprocess.run(
- rsync_argv(f'--rsync-path={RSYNC}', *args),
+ rsync_argv(f'--rsync-path={RSYNC_PEER}', *args),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
print(proc.stdout, end='')