docs/repo-mirrors.md
Rama main · réplica de lectura del repositorio original.
# Repository mirrors
A **pull mirror** is a read-only repository that keeps its branches and tags equal to an upstream's: a copy of
`https://github.com/go-git/go-git` that builds and browsing can use, and that survives the upstream going away. Push
mirrors are not built yet; see the spec in `.scratch/2026-10-06-repo-mirrors/spec.md`.
## Creating one
A mirror starts life as an [import](./repo-import.md) with **Keep this repository in sync** ticked, or `"mirror": true`
on `POST /api/imports`.
- The interval is picked from presets (10 minutes, 1 hour, 8 hours, 1 day, 1 week) that fit `mirror.min_interval`, plus
`mirror.default_interval`. The API takes any Go duration from `mirror.min_interval` to 30 days.
- The token is sealed with `security.secret_key` (see [Credentials](#credentials)); the username is stored as is. A
token with no key configured is refused before the import starts.
- `RepoService.createFromImport` writes the `repo_mirrors` row right after the repo row. If that fails, the import is
abandoned, so there is never a mirror without its row.
- The new mirror is indexed for code search and its dependencies parsed once. Plain imports skip this.
- The import is audited as `repo.mirror.create`.
An existing repository can't become a mirror.
## Syncing
`MirrorService.Sync` (`internal/service/mirror_service.go`) fetches into the live bare repo:
- **Refs:** `+refs/heads/*:refs/heads/*` and `+refs/tags/*:refs/tags/*`, forced, with `Prune`. The mirror follows force
pushes and deleted branches and tags. GitHub's `refs/pull/*` is never fetched.
- **Guard:** the fetch goes through the import's SSRF guard (`importGuard`): private-network addresses are refused
unless `mirror.allow_local_networks` is set, and the pack and ref advertisement are capped as for imports.
- **Packs:** go-git's client strips `thin-pack` from what it asks for, so fetched packs are self-contained and keep the
storer's packfile fast path. See [git-transport: Thin packs](./git-transport.md#thin-packs).
- **No branches:** an upstream that advertises no branches fails the sync before the fetch, so prune can't delete the
mirror's branches. The mirror keeps its last copy.
- **Default branch:** when the upstream's HEAD points elsewhere, the mirror's HEAD and `default_branch` follow. An
upstream that doesn't say is handled as an import handles it.
- **Side effects:** the refs that moved are diffed into push commands, and the sync runs what a push would: push
webhooks for updated branches (with an empty pusher name), `OnPostReceive` (contributor stats, open PRs' head SHAs),
code search re-indexing and dependency parsing. These run on their own five-minute context once refs have moved, even
if the sync's time is nearly up or following HEAD fails, because the next sync's diff won't see those refs again. A
sync records no activity events, sends no notifications and closes no issues from commit messages: an upstream's
`fixes #12` names the upstream's issue.
- **Failures:** returned as `MirrorSyncError`, with a message for the repo's admins that never carries the token: a
private address, a size cap, the timeout, rejected credentials, an empty upstream, or a generic "check that the source
is reachable". The full error is logged.
## Scheduling
`MirrorService.Run`, started from `cmd/server/main.go` when `mirror.enabled` is set:
- **Claiming:** every 30 s, or when woken, it claims due mirrors with `MirrorStore.ClaimDue`, an
`UPDATE … WHERE repo_id IN (SELECT … FOR UPDATE SKIP LOCKED)`. Each claim sets a lease of `mirror.timeout` plus a
minute. Two instances never claim the same mirror, and a crashed instance's lease expires.
- **Concurrency:** at most `mirror.max_concurrent` syncs run at once on each instance. When a sync finishes it wakes the
loop, so a mirror waiting for a slot starts at once.
- **Shutdown:** `MirrorService.Shutdown` cancels the syncs in flight and releases their leases without recording a
failure, so a restart or another instance syncs them at once. Refs a cancelled fetch already moved keep their new
values but miss their side effects.
- **Success:** sets `next_sync_at` to now plus the interval.
- **Failure:** stores the message and backs off. The first failure waits one interval, and each further one doubles it,
up to 24 h but never below the interval.
- **Skipped:** archived and soft-deleted repos.
- **Requests during a sync:** Sync now and new settings set `requested_at`. A sync claimed before that leaves the mirror
due when it finishes, rather than pushing the next sync out by the interval or backoff.
- **Sync now:** (`POST /api/repos/{owner}/{repo}/mirror/sync`, `CanWrite`) makes the mirror due and wakes the loop. A
sync already holding the lease finishes first. It is refused (409), and hidden, while `mirror.enabled` is off or the
mirror is archived, since no sync would run.
Every schedule time the store sets comes from the database clock.
## Read-only
`Repository.IsMirror` is derived with `EXISTS` on `repo_mirrors` in every repo query, and `Repository.ContentReadOnly()`
is true for mirrors and archived repos alike. `service.CheckContentWritable` refuses with `ErrRepoMirror` on every
git-content write:
- HTTP and SSH pushes, which get `Repository is a mirror and is read-only.`
- branch and tag create and delete
- merge and auto-merge
- applying suggestions
- web file edits
- release-created tags
- changing the default branch
The UI hides the matching controls. Creating a pull request into a mirror is refused with
`Pull mirrors are read-only; open the pull request upstream.`
Issues, discussions, the wiki and forks keep working. A fork of a mirror is an ordinary repository.
The code page opens with a strip naming the source, the last sync and **Sync now**. While syncs fail it turns red, with
the error, the next try and a link to the mirror settings.
## Managing
The settings page's **Mirror** section (`CanManage`) shows the sync status and edits the source URL, username, token and
interval.
- **Token:** write-only. An empty field keeps the stored token, and **Remove the stored token** clears it.
- **Rescheduling:** a new source or new credentials make the mirror due at once. A new interval counts from the last
sync.
- **Stop mirroring:** deletes the row and its token. The repository keeps its branches and becomes writable. It can't be
turned back into a mirror. It is refused (409) while a sync holds the lease, because that sync would go on
force-fetching into the now writable repo.
- **Audit:** changes are audited as `repo.mirror.update` (the fields that changed and the URL, never the token) and
`repo.mirror.delete`. Background syncs aren't audited.
## Credentials
Tokens are sealed by `internal/secretbox`:
- AES-256-GCM, under a key derived with HKDF-SHA256 from `security.secret_key`, with the info string
`cloudzilla/mirror-credential`.
- A version byte prefixes each value, and the purpose is bound as additional data.
- A stored token is never returned by the UI or API, and never logged.
Losing or changing the key makes stored tokens unreadable. Syncs then fail with "re-enter the token in the mirror
settings".
## Configuration
| Key | Default | Meaning |
| ----------------------------- | ------- | -------------------------------------------------------------------------------- |
| `mirror.enabled` | `true` | Run the sync loop and offer mirroring. When off, existing mirrors stay read-only |
| `mirror.allow_local_networks` | `false` | Let mirrors reach private-network sources |
| `mirror.min_interval` | `10m` | Shortest sync interval |
| `mirror.default_interval` | `8h` | Interval for new mirrors |
| `mirror.max_concurrent` | `3` | Syncs at once on each instance |
| `mirror.timeout` | `30m` | Time limit for one sync |
| `security.secret_key` | `""` | Seals stored tokens; at least 32 bytes |
See [configuration](./configuration.md) for the env vars.