Files
rust-xmpp/tests/upload_migration_network.py
T
wt b30fc524d8 - Offline CLI `upload-migrate OLD_CONFIG NEW_CONFIG --component HOST
[--execute]`: dry run по умолчанию, local ↔ S3 и S3 ↔ S3 без удаления
  source.
- Последовательное копирование с проверкой размера и полным SHA-256
  readback
  destination; конфликтующий объект не перезаписывается. Временный диск
  —
  максимум один переносимый файл дополнительно к постоянному
  destination.
- Durable migration journal позволяет продолжить прерванную операцию с
  теми
  же конфигурациями. Незавершённый или повреждённый journal блокирует
  startup;
  backend identity меняется только после проверки всех файлов.
- Native `uploads`/`upload_stats`, slots и JWT сохраняются;
  поддерживаются
  native локальные файлы без Rust completion sidecar. Миграция требует
  остановки RustXMPP и Prosody, использующих общий `data_path`.
- Multipart intent сохраняется до создания удалённого upload. Recovery
  находит потерянный UploadId через paginated listing и очищает только
  точный
  key принадлежащего slot. При сохранных metadata cleanup не зависит от
  lifecycle; lifecycle остаётся дополнительной защитой, а не гарантией.
- Перед completion сохраняется durable marker. Неопределённый результат
  completion при отсутствии подтверждённого объекта помещает только этот
  slot в quarantine: PUT 409, retention не удаляет его, maintenance
  повторяет
  сверку. Startup продолжается; сохраняется single-use семантика.
- Добавлены network regressions миграции и расширены проверки multipart
  recovery, потерянных ответов, отмены запросов и конфликтов
  destination.

Настройка, команды миграции, recovery и ограничения отката:
[docs/HTTP-UPLOAD.md](docs/HTTP-UPLOAD.md).

Команды, результаты и границы подтверждения, включая qualification
реального
object store и 10 GiB:
[docs/VALIDATION-0.7.9.md](docs/VALIDATION-0.7.9.md).

Итог: 265 тестов, нагрузка 500 TLS клиентов и native reference —
успешно.
Реальные 10 GiB через MinIO (описанный test adapter), 1280parts и полный
SHA-256
пройдены; подробности — [VALIDATION-0.7.9](docs/VALIDATION-0.7.9.md).
2026-10-20 17:06:14 +07:00

264 lines
15 KiB
Python

#!/usr/bin/env python3
"""Offline upload-migrate regression using the independent signed S3 fixture.
Usage: python3 tests/upload_migration_network.py target/release/rust-xmpp
Exercises real XMPP slots and HTTP bodies; no cloud service or large disk is needed.
The fixture validates SigV4 independently (see upload_s3_network.py).
"""
import copy
import hashlib
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
from urllib.parse import parse_qs, urlsplit
from _wire_client import Client
from upload_s3_network import (ACCESS, PART, REGION, SECRET, TOKEN, Store,
free_port, request, slot, start, stop)
HOST = 'upload.localhost'
class MigrationStore(Store):
"""Fail a chosen completion after earlier objects have really been copied."""
def __init__(self):
super().__init__()
self.completions = 0
self.fail_completion = None
def verify(self, handler, payload):
super().verify(handler, payload)
query = parse_qs(urlsplit(handler.path).query, keep_blank_values=True)
if handler.command == 'POST' and 'uploadId' in query:
self.completions += 1
if self.completions == self.fail_completion:
self.fail = 'complete-error'
def sha(data):
return hashlib.sha256(data).hexdigest()
def checked(command, success=True):
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
output = result.stdout + result.stderr
assert (result.returncode == 0) == success, (command, result.returncode, output)
assert all(value not in output for value in (ACCESS, SECRET, TOKEN)), 'credentials leaked by CLI'
return output
def migrate(binary, old, new, execute=False, success=True):
command = [binary, 'upload-migrate', str(old), str(new), '--component', HOST]
if execute:
command.append('--execute')
return checked(command, success)
def source_files(directory):
return {str(path.relative_to(directory)): sha(path.read_bytes())
for path in directory.rglob('*.bin')}
def run(binary):
with tempfile.TemporaryDirectory(prefix='xmpp-upload-migration-') as directory, \
MigrationStore() as first, MigrationStore() as second:
root = Path(directory)
stores = (first, second)
workers = [threading.Thread(target=store.serve_forever, daemon=True) for store in stores]
for worker in workers:
worker.start()
checked(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
'-keyout', str(root / 'key.pem'), '-out', str(root / 'cert.pem'),
'-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'])
c2s, http_port = free_port(), free_port()
while c2s == http_port:
http_port = free_port()
cert = {'key': str(root / 'key.pem'), 'certificate': str(root / 'cert.pem')}
options = {'http_host': '127.0.0.1', 'http_file_share_backend': 'local',
'http_file_share_base_url': f'http://127.0.0.1:{http_port}/files',
'http_paths': {'file_share': '/files'},
'http_file_share_size_limit': PART + 123,
'http_file_share_daily_quota': PART * 8,
'http_file_share_global_quota': PART * 8,
'http_file_share_expires_after': False}
base = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'],
'c2s_ports': [c2s], 's2s_ports': [], 'http_ports': [http_port],
'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'ping', 'disco']},
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed',
'password_hash': 'SHA-256', 'ssl': cert}},
HOST: {'component': 'http_file_share', 'options': options}}}
configs = []
for name, store in [('local', None), ('s3-first', first), ('s3-second', second)]:
config = copy.deepcopy(base)
if store:
component = config['hosts'][HOST]['options']
component['http_file_share_backend'] = 's3'
component['http_file_share_s3'] = {
'endpoint': f'http://127.0.0.1:{store.server_address[1]}/api',
'bucket': 'fixture-bucket', 'region': REGION, 'access_key': ACCESS,
'secret_key': SECRET, 'session_token': TOKEN, 'key_prefix': 'space +'}
path = root / f'{name}.json'
path.write_text(json.dumps(config))
configs.append(path)
local, remote_a, remote_b = configs
result = subprocess.run([binary, 'create-user', str(local), 'alice', '--password-stdin'],
input='secret\n', text=True, capture_output=True, timeout=30)
assert result.returncode == 0, result.stderr
component_path = root / 'data' / 'upload%2elocalhost'
identity_path = component_path / 'rust_upload_backend.json'
metadata_path = component_path / 'rust_uploads.json'
archive_path = component_path / 'uploads.list'
process = client = None
uploads = []
with (root / 'server.log').open('w+') as log:
try:
process = start(binary, local, c2s, log)
client = Client(c2s, root / 'cert.pem', 'alice', 'secret')
for index, payload in enumerate((b'A' * PART + b'B' * 123, b'migration\x00hash\xff', b'')):
url, headers = slot(client, HOST, len(payload), f'migrate-{index}')
assert request(url, 'PUT', payload, headers)[0] == 201
status, head, body = request(url, 'HEAD')
assert status == 200 and not body
head = {key.lower(): value for key, value in head.items()}
uploads.append((url, headers, payload, head['etag']))
# Both dry-run and execute need the same exclusive data lock.
migrate(binary, local, remote_a, success=False)
migrate(binary, local, remote_a, execute=True, success=False)
assert not first.events, 'live-server lock denial reached S3'
client.close(); client = None
stop(process); process = None
original_files = source_files(component_path / 'http_file_share')
assert len(original_files) == len(uploads)
original_records = json.loads(metadata_path.read_text())
original_archive = archive_path.read_bytes()
def verify_serving(config, preserve_metadata=True):
nonlocal process
process = start(binary, config, c2s, log)
for url, headers, payload, etag in uploads:
status, _, data = request(url)
assert status == 200 and sha(data) == sha(payload), 'original URL body/hash changed'
status, head, body = request(url, 'HEAD')
head = {key.lower(): value for key, value in head.items()}
assert status == 200 and body == b''
assert int(head['content-length']) == len(payload) and head['etag'] == etag
assert request(url, headers={'If-None-Match': etag})[0] == 304
assert request(url, 'PUT', payload, headers)[0] == 409, 'original JWT became reusable'
stop(process); process = None
if preserve_metadata:
records = json.loads(metadata_path.read_text())
stable = lambda rows: {key: {field: value for field, value in record.items() if field != 'xml'}
for key, record in rows.items()}
assert stable(records) == stable(original_records), 'slot/JWT metadata changed'
# Invalid destination configuration must not mutate source state.
invalid = json.loads(remote_a.read_text())
del invalid['hosts'][HOST]['options']['http_file_share_s3']['bucket']
bad = root / 'invalid.json'
bad.write_text(json.dumps(invalid))
identity = identity_path.read_bytes()
migrate(binary, local, bad, execute=True, success=False)
assert identity_path.read_bytes() == identity and not first.objects
assert source_files(component_path / 'http_file_share') == original_files
# Default is a plan only: no writes, no identity/metadata cutover.
migrate(binary, local, remote_a)
assert not first.objects and not first.uploads
assert not any(stage in ('init', 'part', 'complete', 'abort', 'delete')
for stage, _, _ in first.events), 'dry-run wrote to destination'
assert identity_path.read_bytes() == identity
assert json.loads(metadata_path.read_text()) == original_records
# One object is durable before a later copy fails. Neither the
# failed command nor a server restart may cut over early.
first.fail_completion = first.completions + 2
migrate(binary, local, remote_a, execute=True, success=False)
assert first.objects, 'failure injection did not leave a resumable copied object'
assert identity_path.read_bytes() == identity, 'incomplete migration changed active backend'
assert json.loads(metadata_path.read_text()) == original_records
assert source_files(component_path / 'http_file_share') == original_files
# A pending journal fences both configurations until resume.
for blocked in (local, remote_a):
rejected = subprocess.Popen([binary, 'serve', str(blocked), '--experimental'],
stdout=log, stderr=log)
try:
assert rejected.wait(timeout=15) != 0, 'pending migration started serving'
finally:
if rejected.poll() is None:
rejected.kill(); rejected.wait(timeout=10)
first.fail_completion = None
completed_before_resume = first.completions
migration_cache, migration_archive = metadata_path.read_bytes(), archive_path.read_bytes()
migrate(binary, local, remote_a, execute=True)
assert metadata_path.read_bytes() == migration_cache and archive_path.read_bytes() == migration_archive
assert first.completions - completed_before_resume < len(uploads), 'resume recopied verified objects'
assert source_files(component_path / 'http_file_share') == original_files
verify_serving(remote_a)
assert sorted(sha(data) for data in first.objects.values()) == sorted(sha(row[2]) for row in uploads)
# S3 -> S3 and S3 -> local preserve remote source objects, even
# though final local files already exist from the first backend.
first_objects = dict(first.objects)
for old, new, destination in ((remote_a, remote_b, second), (remote_b, local, None)):
second_objects = dict(second.objects)
before = identity_path.read_bytes()
destination_objects = dict(destination.objects) if destination else None
migrate(binary, old, new)
assert identity_path.read_bytes() == before
if destination:
assert destination.objects == destination_objects
migration_cache, migration_archive = metadata_path.read_bytes(), archive_path.read_bytes()
migrate(binary, old, new, execute=True)
assert metadata_path.read_bytes() == migration_cache and archive_path.read_bytes() == migration_archive
verify_serving(new)
assert first.objects == first_objects, 'S3 source objects were deleted/changed'
if destination is None:
assert second.objects == second_objects, 'second S3 source objects were deleted/changed'
assert sorted(sha(data) for data in second.objects.values()) == sorted(sha(row[2]) for row in uploads)
assert source_files(component_path / 'http_file_share') == original_files
# A native final local file is authoritative even when its Rust
# cache completion flag is false. Unfinished multipart intent
# still rejects the operation without modifying the backend.
native = copy.deepcopy(original_records)
native_id = next(iter(native))
native[native_id]['multipart_intent'] = True
metadata_path.write_text(json.dumps(native))
before = identity_path.read_bytes()
migrate(binary, local, remote_a, execute=True, success=False)
assert identity_path.read_bytes() == before
native[native_id] = copy.deepcopy(original_records[native_id])
native[native_id]['complete'] = False
metadata_path.write_text(json.dumps(native))
native_bytes = metadata_path.read_bytes()
native_archive = archive_path.read_bytes()
migrate(binary, local, remote_a, execute=True)
assert metadata_path.read_bytes() == native_bytes, 'migration rewrote native completion cache'
assert archive_path.read_bytes() == native_archive
verify_serving(remote_a, preserve_metadata=False)
assert all(not store.errors for store in stores), [store.errors for store in stores]
print('PASS upload migration: local -> S3 -> S3 -> local, hashes, original URLs/JWT/HEAD, lock, dry-run, fail-closed resume')
except BaseException:
log.flush(); log.seek(0)
output = log.read()
for credential in (ACCESS, SECRET, TOKEN):
output = output.replace(credential, '[REDACTED]')
print('SERVER LOG:\n' + output)
print('FIXTURE ERRORS:', [store.errors for store in stores])
raise
finally:
if client is not None:
client.close()
if process is not None:
stop(process)
for store in stores:
store.shutdown()
for worker in workers:
worker.join(timeout=5)
if __name__ == '__main__':
run(str(Path(sys.argv[1] if len(sys.argv) > 1 else 'target/release/rust-xmpp').resolve()))