Contributing
Development setup
mirror.py requires Python 3.10 or later and runs on Linux only. The project uses uv for
package management.
Install the package in editable mode:
uv sync --group docs
Run the unit test suite:
uv run pytest
Run the integration tests (requires Docker):
uv run pytest -m integration
Building documentation
The configuration editor requires Node.js 22.12 or later and npm. From a clean checkout, install its locked dependencies, build its local assets, and build the Sphinx site with one command:
npm --prefix docs/editor run docs:build
The generated editor assets are ignored by Git. An HTML build fails with a clear
message if they are absent. After the initial build, rerun npm --prefix docs/editor run build when editing frontend sources; prose-only changes can
use the normal uv run --group docs sphinx-build -b html -W --keep-going docs docs/_build/html command.
Serve the site locally instead of opening file:// URLs, because Monaco uses
Web Workers:
python -m http.server 8000 --directory docs/_build/html
Open http://localhost:8000/guide/config-editor.html. Editor assets and workers
are bundled with the site; no CDN or external schema service is used.
Run editor unit and browser tests:
npm --prefix docs/editor test
uv run pytest tests/test_editor_contract.py
cd docs/editor
npx playwright install chromium firefox
npm run test:browser
The editor schema is shared by the forms and Monaco. Python loaders and sync validation remain authoritative. When changing configuration fields, update the editor schema and its contract fixtures as well as the reference docs.
Deploying documentation
Configure Cloudflare Pages with the repository root as the root directory:
Setting |
Value |
|---|---|
Build command |
|
Build output directory |
|
Build variable |
|
Build variable |
|
The build command installs uv and the editor’s locked npm dependencies, then
builds the editor and Sphinx HTML. Cloudflare publishes the output directory.
Keeping sync-method docs in sync
The option pages under docs/sync-methods/ and config.md document the configuration
fields accepted by each sync module. When you change a sync module’s options — adding, removing,
or renaming a field — you must update both the relevant sync-methods/ page and config.md
in the same pull request.
Integration testing
Integration Tests
End-to-end tests for mirror.py running against real rsync, ftpsync (archvsync), lftp, debmirror, and apt-mirror2 upstream fixtures in Docker. Verifies that the master-worker daemon pair correctly performs syncs, recovers from process restarts, and persists state — none of which is provable with the in-process unit suite under tests/.
These tests are deselected by default (pyproject.toml sets addopts = -m 'not integration'). Run them explicitly:
uv pip install -e ".[dev]"
uv run pytest -m integration -v
The mirror image installs a wheel built from the current source tree by a
session-scoped fixture (built_wheel in conftest.py). This means tests
exercise in-progress changes immediately — no PyPI round-trip needed. The
wheel is placed in docker/mirror/dist/ (gitignored) and rebuilt only when
the source SHA changes.
First run builds the wheel and five Docker images. Subsequent runs reuse both the wheel and cached images.
Container topology
Five containers share the default bridge network defined in docker-compose.yml:
mirror, rsync-fixture, ftpsync-fixture, lftp-fixture, and apt-fixture.
The diagram below shows the original rsync/ftpsync paths; the shared APT fixture
serves both debmirror and apt-mirror2 over HTTP port 8000 and FTP port 2121.
┌────────────────────┐ ┌────────────────────┐
│ rsync-fixture │ │ ftpsync-fixture │
│ alpine + rsyncd │ │ alpine + rsyncd │
│ module [data] │ │ module [debian] │
└─────────▲──────────┘ └──────────▲─────────┘
│ rsync:// │ rsync://
└────────────┬─────────────┘
│
┌─────────────┴───────────────────────────┐
│ mirror (ubuntu:22.04, python3.10) │
│ supervisord (PID 1) │
│ ├─ worker (priority 1) │
│ └─ master (priority 2, startsecs=2)│
│ /var/run/mirror/{master,worker}.sock │
│ │
│ bind-mounts to host: │
│ /srv/publish ─► ${TMP}/publish │
│ /var/lib/mirror ─► ${TMP}/state │
│ /var/log/mirror ─► ${TMP}/log │
└─────────────────────────────────────────┘
${TMP} is a per-session host temp dir (set as the INTEGRATION_TMP env var by conftest.py). pytest reads stat.json, package logs, and the published mirror tree directly from these bind-mounted paths.
Container names are pinned (container_name: mirror|rsync-fixture|ftpsync-fixture) so docker exec mirror … works regardless of the compose project name.
Why master and worker are split
Worker spawns the actual sync subprocesses (rsync, ftpsync) as its own children. Master only schedules and tracks state. When master restarts:
Worker keeps running.
Already-running rsync/ftpsync subprocesses keep running (parent is worker, not master).
New master reconnects to worker via
worker.sockand resumes receivingjob_finishednotifications.
This is what test_master_restart.py proves: capture worker PID, restart master mid-sync, assert worker PID is unchanged and the sync still reaches ACTIVE.
How tests interact with the stack
All test interactions go through the mirror_stack fixture (defined in conftest.py, implemented in helpers.py). No test imports mirror.* directly — the test process talks to the containerized version exclusively.
Action |
Mechanism |
|---|---|
Trigger sync |
|
Wait for status |
|
Restart process |
|
Inspect publish tree |
Read |
Swap upstream content |
|
Network isolation |
|
Package source
The mirror image installs a locally-built wheel from docker/mirror/dist/:
COPY dist/mirror_py-*.whl /tmp/
RUN pip install --no-cache-dir /tmp/mirror_py-*.whl
conftest.py:built_wheel (session-scoped) runs uv build --wheel against the
current source tree and places the artifact in dist/. The build is gated by
a SHA of mirror/** plus pyproject.toml, so unchanged source skips rebuild.
Public releases are independent: a v* tag push triggers
.github/workflows/pypi.yaml, which uses PyPI Trusted Publisher (OIDC) to
upload the artifact. The integration suite does NOT pull from PyPI — local
testing always uses the working-tree build.
Per-test isolation
Each test starts with a fresh mirror_stack:
Clear contents of the host bind-mount dirs (
publish/,state/,log/). The directories themselves are preserved so the docker mount points stay attached.supervisorctl restart master. Worker keeps running. This forces master to reloadconfig.jsonand start with empty in-memory package state, isolating tests from prior runs.Wait for master to be
RUNNINGagain before yielding.
Worker stays up across tests by design: tests that need worker restart explicitly do mirror_stack.restart_process("worker").
Test scenarios
File |
Scenario |
|---|---|
|
Runs archvsync’s |
|
Basic rsync; FFTS short-circuit when upstream unchanged; full sync when FFTS file changed |
|
Signed HTTP option discovery, explicit subsets, updates and cleanup, and failure recovery |
|
Multiple signed flat repositories, source indexes, automatic cleanup, hash and key failures, and FTP discovery |
|
Basic ftpsync; offline fallback exercises the embedded base64 archvsync ( |
|
Master restart during a 200MB sync does not kill worker subprocess (PID stable); master reconnects and sync completes |
|
Worker restart recovery; master gracefully handles worker unavailability |
|
Add/remove package via config edit + |
|
Failed package retries after |
|
|
|
Per-package log file is gzip-compressed after sync completes |
Fixtures
apt-fixture (docker/apt-fixture/)
One Python HTTP/FTP server exposes a shared signed Debian archive at
http://apt-fixture:8000/debian and ftp://apt-fixture:2121/debian.
Static v1/debian/ and v2/debian/ trees contain
multiple distributions, components, and architectures, plus binary and source
indexes and signed InRelease and Release metadata. The allonly
distribution intentionally has no InRelease, exercising the signed
Release.gpg fallback. The stable alias duplicates bookworm metadata.
Only public keyrings are stored; private keys are not included.
Compose mounts the Debian keyring read-only at /etc/mirror/debmirror-test.gpg.
The flat repositories retain their separate keys, mounted at
/etc/mirror/apt-mirror2-ubuntu2404.gpg and
/etc/mirror/apt-mirror2-ubuntu2604.gpg. Both tools use the same Debian tree;
the mirror image installs debmirror and apt-mirror2 for real subprocess tests.
test_e2e_debmirror.py exercises master socket requests, worker subprocess
execution, completion notifications, persisted status and package logs:
Omitted
dist,section, andarchdiscover all binary targets, deduplicate the suite alias, and leave source packages disabled by default.Updating to v2 discovers a new distribution, component, and architecture, downloads their payloads, preserves a pool file still referenced by another distribution, and removes the obsolete v1 payload.
Explicit list selections restrict the mirror and
source: truedownloads source payloads.Tampered signed metadata and denied or incomplete directory listings fail without deleting the existing mirror. An explicit all-only distribution bypasses listing and exercises debmirror’s
--arch nonebehavior.
Only /debian and its listing controls are restored after each debmirror test,
including failures. Flat repository contents are left intact.
The small debmirror-test package also auto-syncs when the shared stack resets
for other integration tests. Its hourly schedule avoids background updates
during the explicitly triggered debmirror scenarios.
Run only these scenarios with:
uv run pytest -m integration tests/integration/test_e2e_debmirror.py -v
They are also included in the full uv run pytest -m integration -v suite.
The same fixture also serves /ubuntu2404/, /ubuntu2604/, and /sourceonly/.
The two CUDA-shaped flat repositories use different keys. Their static v1 and
v2 trees cover updates, retained files, automatic cleanup, source indexes, and
a source-only repository. apt-mirror2 tests reset only these three directories;
they never replace the shared data root or Debian archive. Its FTP test uses
the shared Debian tree, including allonly for signed Release.gpg fallback.
test_e2e_apt_mirror2.py runs the real apt-mirror2 v16 process through the
master and worker. It also verifies repository-specific key isolation, package
hash failures, cleanup suppression after errors, and recovery.
rsync-fixture (docker/rsync-fixture/data/)
Minimal tree exposed via rsyncd’s [data] module:
fullfiletimelist-test— FFTS metadata. Stable across reads, so re-syncs short-circuit.README— single-line markerdists/test/Release— Debian-style release filepool/main/p/pkg/pkg_1.0.deb— zero-byte placeholder
ftpsync-fixture (docker/ftpsync-fixture/data/)
Minimal Debian-style archive exposed via rsyncd’s [debian] module:
Project/trace/master— required by archvsync stage 1dists/test/Release+ zero-byteRelease.gpgpool/main/p/pkg/pkg_1.0.deb— zero-bytels-lR.gz— zero-byte
fixtures/tree_v2/
Alternate content used by test_ffts_changed_triggers_full_sync:
Modified
fullfiletimelist-testaddsNEW_FILEentry → FFTS dry-run reports change → full sync.NEW_FILEitself appears in publish tree after sync.
The test restores tree v1 in a finally block to keep later tests deterministic.
Configuration (docker/mirror/config.json)
Six packages baked into the image:
Package |
synctype |
src |
syncrate |
Purpose |
|---|---|---|---|---|
|
rsync |
|
|
Auto-syncing rsync target with FFTS enabled |
|
ftpsync |
|
|
Manually triggered ftpsync target |
|
lftp |
|
|
FTP target |
|
debmirror |
|
|
Signed Debian archive target |
|
apt-mirror2 |
two flat HTTP repositories |
|
Multi-source signed flat archive target |
|
rsync |
|
|
Always fails to verify error-retry behavior |
errorcontinuetime is set to 10 seconds for fast retry observation.
Known caveats
syncrate: "PUSH"parses to-1, which the daemon’s auto-trigger condition (time.time() - lastsync > syncrate) treats as “always due” rather than “manual only”. The ftpsync package therefore usesPT1Hplus explicit triggering instead ofPUSH.pytest-dependencyis not installed; the preflight gate uses a module-level cache intest_e2e_ftpsync.pyrather than declarative test dependencies.The offline-fallback test manipulates the docker network from the host; it is marked
xfailif the network operation fails (e.g., on environments where docker is not the test runner’s default).rsyncd in fixture containers runs as
uid = rootfor simplicity; this is acceptable for a sealed test container but is not a production pattern.
Layout reference
tests/integration/
├── conftest.py # session/per-test fixtures, INTEGRATION_TMP
├── helpers.py # MirrorStack class — all docker/host interactions
├── docker-compose.yml # 5 services with pinned container_name
├── docker/
│ ├── rsync-fixture/ # Dockerfile + rsyncd.conf + data/
│ ├── ftpsync-fixture/ # Dockerfile + rsyncd.conf + data/
│ ├── apt-fixture/ # Shared HTTP/FTP server + signed flat and Debian repositories
│ └── mirror/ # Dockerfile + supervisord.conf + config.json + dist/ (gitignored)
├── fixtures/
│ └── tree_v2/ # Alternate rsync content for FFTS-changed test
└── test_*.py # Integration scenarios