diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md new file mode 100644 index 0000000..9f2d49d --- /dev/null +++ b/docs/roadmap/first-run-restore.md @@ -0,0 +1,109 @@ +# LibrePortal — First-run: New Install or Restore (Roadmap / Proposal) + +**Status:** Proposal — **not built**, but most of the machinery is. · **Audience:** us, future-self · **Scope:** make "I'm rebuilding my server" a first-class path at first run, not a CLI expedition · **Origin:** "on the first install/setup we need 2 option blocks (New Install and Restore from Backup)" (2026-08-27) + +--- + +## 0. The one idea + +> **The wizard's first question becomes "is this a new server, or a replacement for one?"** Everything else follows from the answer. + +Today a rebuild means knowing that `libreportal backup location add`, `libreportal restore first-run discover` and `libreportal restore first-run bulk` exist, in that order. That is a fine CLI story and a terrible first-run story — and rebuilding after a disk dies is exactly when someone is least able to go reading docs. + +## 1. How much of this already exists + +More than it looks. The restore path is built; what is missing is the front door. + +| Piece | Status | +|---|---| +| Connect a backup repo (local / sftp / rest / s3 / b2 / gs / azure / rclone) | ✅ `locationAdd` | +| Read a repo without touching live state | ✅ `restoreFirstRunDiscover`, `migrateDiscoverHosts/Apps/AppDetail` | +| Restore the system config (settings + credentials) into staging | ✅ `backupRestoreSystemConfig`, `restore system` | +| Restore many apps in one go from another host | ✅ `restoreFirstRunBulk`, `restore first-run bulk` | +| Rewrite host-bound `CFG_*` (URL/HOST/DOMAIN) to this machine | ✅ `migrateUrlRewrite` | +| Preflight a migrate before committing | ✅ `migratePreflight` | +| Safety snapshot of the destination before overwriting | ✅ `migratePreBackupDestination` | +| Restore an app whose path differs from this host's | ✅ `storageRestoreAppTo` (§9 of storage-locations) | +| Snapshot records where the app lived | ✅ manifest `storage` block | +| **The wizard branch, and the reconciliation screen** | ❌ this document | + +So this is mostly **assembly plus one genuinely new screen**, not new plumbing. + +## 2. What the user sees + +**Step 0 becomes two blocks**, before Experience: + +``` +┌───────────────────────────┐ ┌───────────────────────────┐ +│ 🌱 New install │ │ ♻️ Restore from backup │ +│ Set this machine up │ │ Rebuild a server from │ +│ from scratch. │ │ an existing backup. │ +└───────────────────────────┘ └───────────────────────────┘ +``` + +**New install** → the wizard exactly as it is now. + +**Restore** → Identity is skipped (it comes from the backup), and the flow becomes: + +1. **Where is your backup?** — the existing backup-location fields, rendered from config metadata like the Locations page already does. +2. **Unlock it** — the repository password. See §4; this is the step that decides whether a restore is possible at all. +3. **What's in there?** — hosts found, then apps per host, with sizes and snapshot dates. +4. **What will change on this machine** — the reconciliation screen. §3. The new part. +5. **Restore** — system config first, then the chosen apps, with the task list the wizard already renders. + +## 3. The reconciliation screen — the only genuinely new logic + +A backup describes a machine that no longer exists. Restoring it onto different hardware means a set of facts have to be re-decided, and the failure mode we care about is doing that silently. Everything below is *detectable before anything is written*: + +| What differs | Where we already know it | Resolution | +|---|---|---| +| Storage location named in the manifest doesn't exist here | manifest `storage.location` vs the registry | offer the drives this host has, or register a new one — the §9 prompt | +| App's old absolute path ≠ where it goes here | manifest `storage.path` | already handled: `storageRestoreAppTo` stages and moves | +| Domains point at the old host | `migrateUrlRewrite` | rewrite (its existing per-app opt-out still applies) | +| Ports / IPs already taken on this box | port allocation tables | reallocate, and say which changed | +| Drive smaller than the data | manifest `size_bytes` vs `df` | refuse *that app*, not the whole restore | +| Apps in the backup that this version no longer ships | template presence | list them as skipped rather than failing | + +It should read as a diff, not a wall: **"12 apps · 3 need a decision · 9 restore as-is"**, with the three expanded. + +## 4. The password problem, stated plainly + +**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC__PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means. + +That single fact drives two requirements: + +- The restore step must ask for it early and say what it is, because a user who never wrote it down has no backup, and finding that out at step 2 is better than at step 5. +- Everywhere we generate that password we must push harder than a comment. The location config already says *"back up offline!"*; the WebUI should show it once, prominently, at creation — treated like a recovery key, because that is what it is. + +Once the repo opens, ordering is already correct in the CLI and should be preserved: **system config first** (it carries every *other* location's credentials, so one password unlocks the rest), then apps. + +## 5. "Set up a backup server if you don't have one" + +Same components, other direction. After a **New install**, offer: *"Where should your backups go?"* — the same location fields, then `engineInit` and a first `backup system`. That closes a real gap: today backups exist but nothing prompts you to configure them, so the people most likely to need a restore are the least likely to have one. + +Worth doing as its own wizard step even without the restore branch. + +## 6. On "upload the backup file" + +Worth being precise, because the mental model doesn't match the engines. restic, borg and kopia back up to a **repository** — a directory or a remote — not a single file. There is nothing to upload. The equivalents are: + +- **local** — a path on a plugged-in disk. "Navigate to it" is right, and a directory picker is the natural UI. +- **sftp / s3 / b2 / …** — credentials, which the existing fields already collect. + +If a genuine single-file import is wanted, that is a **different feature**: a portable per-app export (`tar` of the app dir + manifest, optionally encrypted) that could be handed around and imported. Cheap to build on the manifest that already exists, but it is not what the backup engines produce and shouldn't be conflated with them. + +## 7. Phasing + +| Phase | Deliverable | +|---|---| +| **1** | Backup destination step for the New-install path (§5) — small, useful on its own, exercises the location fields inside the wizard | +| **2** | The two blocks, and the restore branch through discovery (steps 1–3). Read-only: nothing is written, so it can ship before reconciliation exists | +| **3** | The reconciliation screen (§3) and the restore itself, driven by `restoreFirstRunBulk` | +| **4** | Portable per-app export/import (§6), if wanted | + +## 8. Open questions + +1. **Does the restore branch also restore the system config's *identity*** — install name, domains, WebUI credentials? Restoring the WebUI login means the user logs into the new box with the old password, which is probably what they expect, but it is a surprise if not stated. +2. **Partial restore of a host** — pick apps individually (already supported by `restoreFirstRunBulk`'s signature) or all-or-nothing at first run? +3. **What if the backup is newer than this LibrePortal version?** The manifest records the commit; refusing is safer than guessing, but it strands someone whose only copy is newer. +4. **Where does the repository password go once entered** — straight into the location config it will restore over, or held only in memory until the system config lands and then reconciled?