Origin repos (foreign catalogs) + migrations to native apps (2026-07-17)
Status: DESIGN. Part of the platform-compat program — faithful capture of
docs/handoff/2026-07-16-hel1-reprovision-and-platform-compat.md (§C3,
§C4, §C6, §D, §E), no new design. Siblings:
2026-07-17-origin-ingest-gate.md (the
gate + pipeline every sync runs),
2026-07-17-manifest-adapters.md (the
per-origin adapters + terminology),
2026-07-17-app-metadata-schema.md
(the canonical fields + compat: bag),
2026-07-17-notes-templating.md
(post-deploy notes).
Terminology (origin, adapter, adapted app, origin repo, import,
migration) is the collision-checked set defined in
2026-07-17-manifest-adapters.md.
Problem
Section titled “Problem”Two gaps beyond single-app adaptation:
- Whole catalogs. Adapting apps one at a time doesn’t scale to
ecosystems of hundreds of packages. A whole foreign catalog should
register as an
shc repoand serve its adaptable subset like any other repo — with honest, visible coverage. - Adapted is not the destination. An adapted app runs with a dumb backup — volume snapshots that are crash-consistent but conform to NOTHING (no exporter format, no per-app backup contract), second-class for export/import. Users need a path from an adapted app to the official native SHC app(s), data included — and people running a real Umbrel/CasaOS/Runtipi/Cloudron box need a way onto SHC at all.
Design
Section titled “Design”Foreign catalogs as SHC repos
Section titled “Foreign catalogs as SHC repos”Register a whole foreign catalog as an shc repo:
shc repo add --origin umbrel https://github.com/getumbrel/umbrel-apps(same for casaos / runtipi / coolify / cloudron-packages). Semantics:
- Sync = pin + transpile + gate. Each sync pins the upstream to a
git sha, runs the ingest gate +
ManifestAdaptertranspile per app (the 7-stage pipeline in 2026-07-17-origin-ingest-gate.md), and serves the ACCEPTED subset as a normal SHC repo (index.yaml + packages). Rejected apps stay listed with their reject reason (shc repo apps --rejected) — honest coverage, no silent 40%. - Re-sync is the rot-prevention CI. New upstream sha → re-pin, re-transpile, DIFF the normalized output; changed packages surface as app updates. Conformance fixtures per origin live with the adapter (steady-state requirements in 2026-07-17-manifest-adapters.md).
- Foreign metadata rides the
compat:passthrough bag (2026-07-17-app-metadata-schema.md). - Licensing diligence applies per catalog before serving it beyond private use — a legal decision, not engineering (the Umbrel platform is PolyForm Noncommercial; catalog + gallery screenshots need diligence, given SHC is commercially deployed).
Installing from an origin repo is a plain shc install — the
enrichment core, tenancy, ingress, and backup machinery cannot tell the
package is adapted.
Migration paths — adapted app → NATIVE SHC app(s), via the backup system
Section titled “Migration paths — adapted app → NATIVE SHC app(s), via the backup system”THE KEY INSIGHT: an adapted app running on SHC has a dumb backup — volume snapshots that are crash-consistent but conform to NOTHING (no exporter format, no per-app backup contract). Migration’s whole job is to transform that dumb backup into conforming SHC backup(s) / a backup GROUP that fit the native SHC app(s) being migrated to — because then the EXISTING restore machinery (restore = deploy-from-backup + data-replay) does the rest. Migration is a data transpile whose output is exactly what restore already consumes. No new deploy path.
A *.migration.yaml (shipped in the catalog next to apps, versioned,
testable) declares:
name: runtipi-erpnextsource: platform: runtipi # umbrel | casaos | runtipi | cloudron | coolify app: erpnext match: version: '^(1[45])\.(\d+)\.(\d+)$' # REGEX w/ capture groups — one file # covers every tipi ERPNext versiontargets: # 1..N native SHC apps (splits are normal: - app: frappe # bundled-DB foreign app -> app + db app) version: '${1}' # capture substitution — target version is # DERIVED from source version… transform: job: migrate-erpnext.job.yaml # runs with source volumes mounted RO produces: exporter # emits the frappe exporter-format export - app: mariadb version: map: # …or an explicit mapping table for '14.*': '10.6' # irregular version relationships '15.*': '10.8' transform: from_volume: db-data produces: dump # logical dump extracted from the snapshotgroup: consistency # one backup GROUP -> all-or-nothing restoreLoad-bearing properties:
- Version mapping is regex/range-based, not per-version files (the
ERPNext requirement). Capture substitution for regular mappings,
map:tables for irregular ones. And it is NOT cosmetic: the target app version must match the DATA format (the same-DB-major restore constraint; frappe major ↔ site schema) — the mapping selects the restore-compatible target. - 1→N splits produce one conforming backup per target + a consistency group; restore of the group brings up the native apps together (the group-backup + #91 manifest-inventory machinery already exists and validates the output).
- Transforms run as jobs with the source volumes mounted read-only (never mutate the source), producing whatever the target’s backup contract expects (exporter-format export, logical dump, file tree). Dry-run = produce + validate the group without cutover.
- Cutover: stop foreign stack → final dumb backup → transform → restore targets → move hosts → foreign stack kept (protected) until the operator confirms, then destroyed.
- Layout + matching: migrations are defined by the app they migrate
INTO —
apps/<targetApp>/migrations/<origin>/<sourceApp>.migration.yaml(e.g.apps/postgres/migrations/runtipi/linkwarden.migration.yaml), with the transform*.job.yamlfiles beside the recipe. The loader DISCOVERS the owning app by globbing the catalog for the (origin, source-app) key; two apps claiming the same source is an error. A multi-target recipe lives under its PRIMARY target (the app the source natively is); auxiliary datastore targets ride in the same file. New apps add migration paths as a drop-in under their own dir — no shc source edit — and recipes ship automatically because app dirs are packaged recursively. (NOT the repo-rootmigrations/, which is the Atlas SQL schema catalogue.) Match key = (origin, app, version-matcher). Same target app from different origins = separate files (the data layouts differ). NO match (or unmappable version) → the adapted app simply cannot be migrated yet and keeps running as-is;shc app infoon an adapted app showsmigratable to: <apps>when a path exists. Adapted apps are first-class migration sources regardless of how they arrived (fresh install from an origin repo, orshc origin importoff a real box) — identical dumb-backup shape.
Off-box IMPORT — migrating from a real origin box to SHC
Section titled “Off-box IMPORT — migrating from a real origin box to SHC”For someone actually RUNNING umbrel/casaos/tipi/cloudron on their own box, the journey is two hops, both terminating in the existing restore machinery:
Hop 1 — shc origin import --origin runtipi ssh://root@box [app]
- The importer reads the box’s app registry + the INSTALLED manifest
version (known layouts: umbrel
~/umbrel/app-data/<id>, casaos/DATA/AppData/<id>, tipiapp-data/<id>+user-config; CLOUDRON: consume its native per-app backup tarball directly, no SSH). - The adapter transpiles that exact manifest version → the adapted SHC package.
- The importer quiesces the origin app, pulls its data, packages it as a DUMB BACKUP of the adapted app.
shc restoredeploys the adapted app from that backup.
Import = adapter (package) + importer (data) + existing restore.
Hop 2 — later, optional: run the migration.yaml above (adapted app
→ conforming backup group → restore as official native SHC app(s)). The
migration cannot tell whether the adapted app arrived by fresh install
or by import — same input either way. Users may stop at hop 1
indefinitely (the adapted app already gains SSO-gating, per-app backups,
LE ingress).
Decided
Section titled “Decided”- Sync pins the upstream to a git sha; nothing floats. Rejects are
recorded with reasons and stay visible (
shc repo apps --rejected). - The accepted set is served as a normal SHC repo; install from it
is a plain
shc install. - Re-sync = re-pin + re-transpile + diff the normalized output; conformance fixtures per origin live with the adapter.
- Migration output = conforming backup(s) + a consistency group, consumed by the EXISTING restore machinery — no new deploy path.
- Version matching is regex/map-based (never per-version files) and selects the restore-compatible target version.
- Transforms mount source volumes read-only; dry-run produces + validates the group without cutover.
- Cutover keeps the foreign stack (protected) until the operator confirms.
- Migrations are app-defined
(
apps/<targetApp>/migrations/<origin>/<sourceApp>.migration.yaml, shipped with the target app); same source app from different origins = separate files. - Unmigratable adapted apps keep running as-is;
shc app infosurfacesmigratable to:when a path exists. - Import = adapter + importer + existing restore; Cloudron’s importer consumes its native per-app backup tarball (no SSH). Hop 2 is optional and may never happen.
- Flag spelling: the draft uses both
shc repo add --type <origin>and--origin <origin>; the collision-checked terminology settles onoriginas the noun — the flag name follows it here, to be confirmed at implementation. Likewise the example’ssource.platform:key predates the terminology ruling. - Licensing diligence per catalog (legal, not engineering) before an origin repo is served beyond private use.
- Transform-job contract details (what validates a
produces:output against the target’s backup contract) ride the existing backup/restore machinery — not re-specified here.