Remove a mirrored repository (tullictl + web UI, admin-only) #24

Closed
opened 2026-07-13 16:43:41 +02:00 by qwc · 0 comments
Owner

Motivation

There is currently no way to remove a mirrored repository — not via tullictl, not via the web API/UI. Deleting one on a testing (or real) instance means manual surgery in two places:

  1. Delete the on-disk bare mirror in the data volume: rm -rf /data/mirrors/<host>/<path>.git
  2. Delete the repos row in Postgres (cascades to refs/scans/findings/verdicts/overrides/manual_blocks/components/vuln_matches/triage/notifications; access_events null out): DELETE FROM repos WHERE upstream_url = '…';

This is error-prone and, done half-way, is unsafe:

  • DB row only (disk left) → next fetch re-upserts a fresh repos row, but EnsurePresent finds the existing bare repo → created=falseno re-scan enqueued. A previously-quarantined repo then serves as CLEAN/unscanned. (This is the important footgun — removal must be atomic across DB + disk.)
  • Disk only (DB row left) → next fetch re-clones + re-scans (created=true); fine as a "force re-mirror", but stale findings/verdicts linger.

Removal needs to be a single first-class operation that handles both stores in the right order.

Ask

A proper "remove repository" action, exposed in both surfaces:

tullictl repo rm <host>/<path> (or by id)

  • Deletes the DB repos row (cascade handles children) and removes the on-disk mirror dir.
  • Bearer-token, admin-only (destructive).
  • --yes / confirmation prompt; clear output of what was removed.

Web UI / API — admin-only

  • DELETE /api/repos/{id} (admin role via existing guard(minRole, …)).
  • A "Remove" affordance on the repo detail page (admin-only), with a confirm dialog.
  • Returns 202/204; the actual disk removal goes through the mirror-owning process (reconciler), consistent with the §4 single-writer / desired-state model — the web process writes intent, the mirror owner deletes the dir. May want a deleted/tombstone state or a dedicated reconcile action rather than a raw FS delete from the web handler.

Design notes / open questions

  • Atomicity + order. Remove (or hide) the mirror on disk and delete the DB row as one logical op so you never land in the "DB-gone-but-disk-serving → unscanned CLEAN" state above. Likely: mark intent in DB → reconciler removes dir → finalize row deletion; or delete row + have the reconciler's sweep prune orphaned mirror dirs.
  • Re-mirror semantics. After removal, a later fetch of the same URL should lazily re-mirror from scratch (fresh first_seen, fresh scan) — that's the desired behavior; just make it explicit/tested.
  • In-flight fetch. Guard against removing a repo mid-clone/mid-serve (single-flight inflight map in mirror.go).
  • Audit. Record who removed what (access_events / a dedicated audit line).
  • Reconciler role. This is a natural fit for internal/mirror — it already owns the on-disk mirror lifecycle; add a "prune/remove" path alongside the hideRefs reconcile.
  • Depends on #25tullictl isn't in the shipped Docker image yet, so the CLI half can't be exercised via compose until that's fixed.

Relationship to other issues

  • #18 — "what's mirrored + verdict" helper. Removal is the natural companion (list → remove).
  • #12 / #22 — refresh/update machinery lives in the same internal/mirror lifecycle; removal is the destructive sibling of refresh.

Labels: Kind/Enhancement, web, rbac.

## Motivation There is currently **no way to remove a mirrored repository** — not via `tullictl`, not via the web API/UI. Deleting one on a testing (or real) instance means manual surgery in two places: 1. Delete the on-disk bare mirror in the data volume: `rm -rf /data/mirrors/<host>/<path>.git` 2. Delete the `repos` row in Postgres (cascades to refs/scans/findings/verdicts/overrides/manual_blocks/components/vuln_matches/triage/notifications; access_events null out): `DELETE FROM repos WHERE upstream_url = '…';` This is error-prone and, done half-way, is **unsafe**: - **DB row only (disk left)** → next fetch re-upserts a fresh `repos` row, but `EnsurePresent` finds the existing bare repo → `created=false` → **no re-scan enqueued**. A previously-quarantined repo then serves as CLEAN/unscanned. (This is the important footgun — removal must be atomic across DB + disk.) - **Disk only (DB row left)** → next fetch re-clones + re-scans (`created=true`); fine as a "force re-mirror", but stale findings/verdicts linger. Removal needs to be a single first-class operation that handles both stores in the right order. ## Ask A proper "remove repository" action, exposed in both surfaces: ### `tullictl repo rm <host>/<path>` (or by id) - Deletes the DB `repos` row (cascade handles children) **and** removes the on-disk mirror dir. - Bearer-token, **admin-only** (destructive). - `--yes` / confirmation prompt; clear output of what was removed. ### Web UI / API — admin-only - `DELETE /api/repos/{id}` (admin role via existing `guard(minRole, …)`). - A "Remove" affordance on the repo detail page (admin-only), with a confirm dialog. - Returns 202/204; the actual disk removal goes through the mirror-owning process (reconciler), consistent with the §4 single-writer / desired-state model — the web process writes intent, the mirror owner deletes the dir. May want a `deleted`/tombstone state or a dedicated reconcile action rather than a raw FS delete from the web handler. ## Design notes / open questions - **Atomicity + order.** Remove (or hide) the mirror on disk and delete the DB row as one logical op so you never land in the "DB-gone-but-disk-serving → unscanned CLEAN" state above. Likely: mark intent in DB → reconciler removes dir → finalize row deletion; or delete row + have the reconciler's sweep prune orphaned mirror dirs. - **Re-mirror semantics.** After removal, a later fetch of the same URL should lazily re-mirror from scratch (fresh `first_seen`, fresh scan) — that's the desired behavior; just make it explicit/tested. - **In-flight fetch.** Guard against removing a repo mid-clone/mid-serve (single-flight `inflight` map in `mirror.go`). - **Audit.** Record who removed what (access_events / a dedicated audit line). - **Reconciler role.** This is a natural fit for `internal/mirror` — it already owns the on-disk mirror lifecycle; add a "prune/remove" path alongside the hideRefs reconcile. - **Depends on #25** — `tullictl` isn't in the shipped Docker image yet, so the CLI half can't be exercised via compose until that's fixed. ## Relationship to other issues - **#18** — "what's mirrored + verdict" helper. Removal is the natural companion (list → remove). - **#12 / #22** — refresh/update machinery lives in the same `internal/mirror` lifecycle; removal is the destructive sibling of refresh. Labels: `Kind/Enhancement`, `web`, `rbac`.
qwc closed this issue 2026-07-15 23:11:03 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
qwc-open/tulli#24
No description provided.