rebase engine design
Status: accepted design; phase selection landed in bootc-rebase for RFC #30
Scope: target architecture for generalizing bootc-migrate from a
composefs-only migrator into a bootc image re-base / migration engine.
This document is the design counterpart to the RFC; it does not change
current behavior. Phasing follows the RFC's M1βM3.
1. Problem restatedβ
Today the pipeline is a single fixed scenario: OSTree(bootc) β composefs(bootc)
with GRUB β systemd-boot. The phase functions are already split
(phase1_import_objects β¦ phase5_setup_bootloader, run_rollback,
migrate_bootloader_standalone), but the scenario is compiled in: the
sequence, the bootloader policy, and the /etc + UID handling are hard-wired in
migration/mod.rs and migration/deploy.rs (the #123 God-modules).
The RFC generalizes along five axes:
| # | Scenario | New machinery |
|---|---|---|
| A | OSTree β OSTree (same family) | skip composefs phases; keep bootloader |
| B | GRUB β systemd-boot standalone | first-class migrate-bootloader |
| C | Cross-base (Fedora β CentOS) | UID/GID remap, /etc conflict policy, SELinux relabel |
| D | Cross-image (Bluefin β tuna-os) | none beyond C + catalog |
| E | DE config carry-over (GNOME β KDE) | pluggable stash/restore hook |
The trap to avoid: adding --backend/--base flags that switch inside the
existing God-modules. That compounds #123. The generalization is the
refactor: extract a planner + phase interface, then express every scenario
(AβE) as a phase selection over one pipeline.
2. Core model: RebasePlanβ
A run is a plan, not a branch. The plan is built by a planner and executed by
the pipeline; both live in bootc-migrate-core.
pub struct RebasePlan {
pub source: ImageDescriptor, // current deployment, read from ostree/state
pub target: ImageDescriptor, // image ref from the catalog / CLI
pub mode: RebaseMode, // which scenario family (see Β§4)
pub phases: Vec<PhaseSpec>, // ordered phase selection for this mode
pub policies: Policies, // bootloader, uid/gid, /etc conflict (Β§5)
pub hooks: Vec<HookSpec>, // DE-migrate + user hooks (Β§6)
}
pub struct ImageDescriptor {
pub base: BaseFamily, // fedora | centos | ...
pub backend: Backend, // ostree | composefs (bootc internals cfs)
pub bootloader: Bootloader, // grub | systemd-boot
pub verity: bool, // fs-verity sealed
pub cpu: Option<CpuLevel>, // x86-64-v2/v3 β for cross-image guards
}
ImageDescriptor is cheap to obtain: os-release + BLS inspection for source,
registry manifest for target (already implemented in registry.rs).
3. Phase pipelineβ
Keep the existing phase split (they are already the right granularity) but give it an interface and a context so phases stop reaching into globals:
pub trait RebasePhase {
fn name(&self) -> &'static str;
fn required_by(&self, mode: &RebaseMode) -> bool; // hard dependency
fn optional_for(&self, mode: &RebaseMode) -> bool; // may be skipped
fn run(&self, ctx: &mut PhaseContext) -> Result<PhaseReport>;
}
pub struct PhaseContext<'a> {
pub plan: &'a RebasePlan,
pub report: &'a PreflightReport,
pub dry_run: bool,
pub force: bool,
// scratch dirs, lock file, progress sink β no module globals
}
Phases (current names kept where possible):
| Phase | Today | In the engine |
|---|---|---|
| preflight | preflight.rs | unchanged; gains cross-base checks (UID divergence) |
| import | phase1_import_objects | unchanged |
| pull | phase2_pull_image | registry streaming stays (Β§7) |
| seal | phase3_create_image | only when target is composefs β skipped in A/B |
| deploy | phase4_stage_deploy | split: var/user carry-over vs. image stage |
| bootloader | phase5_setup_bootloader | policy-driven (Β§5.1); reused by standalone B |
| rollback/commit | transaction.rs | unchanged; covers the whole phase set |
Each phase returns a PhaseReport (what changed, what was skipped, size of
delta) β the CLI and the future TUI render the same report.
4. Mode matrixβ
The planner selects phases per mode. --backend=ostree (A) is not a flag on
the pipeline; it is a mode that omits seal and pins bootloader=keep.
| Mode | import | pull | seal | deploy | bootloader | /etc merge | UID remap |
|---|---|---|---|---|---|---|---|
| A ostreeβostree | β | β | β | β | keep | β | β |
| B bootloader-only | β | β | β | β | target | β | β |
| C cross-base | β | β | β/β | β | per Β§5.1 | conflict policy | β |
| D cross-image | β | β | β/β | β | per Β§5.1 | conflict policy | β |
| E (hooks) | β | β | β | β | β | β | β |
migrate-bootloader (B) reuses phase5_setup_bootloader with a plan whose
only phase is bootloader β no new code path.
The executable counterpart is crates/bootc-rebase/src/routing.rs::plan.
bootc-rebase --plan prints the selected phases and bootloader policy without
touching the host. The planner is pure and tested across all four backend
pairs; strategy execution remains behind the existing protected paths until
the phase trait extraction below lands.
For frontends and orchestration, bootc-rebase --plan-json emits the same
route as a single JSON object (from, to, strategy, implemented,
phases, and bootloader). It implies --plan: no preflight, registry access,
or filesystem mutation occurs. This keeps the route/phase contract consumable
without scraping human-oriented output and makes unsupported reverse routes
explicit before an apply attempt.
5. Decision policies (RFC open questions 1β3)β
5.1 Bootloader (Q1)β
Default: keep the source bootloader for A (ostreeβostree) β minimal-change principle; a bootloader swap is risk with no user-visible benefit. Migrate to systemd-boot only when the target mandates it (composefs targets ship systemd-boot by default, as today) or the user explicitly asks (standalone B). This makes B a standalone capability, not an automatic side effect.
5.2 UID/GID remap (Q2)β
Auto-remap with a report; refuse only on ambiguous collisions. A migration tool that hard-refuses by default is a dead end for its main users. Rules:
- Remap is per-entry (
/etc/passwd,/etc/group,/etc/shadow) using the target'sbasedefaults; the report lists every remapped entry. - Hard refuse (require
--force) when two distinct source users collide on the target UID β that is a data-integrity ambiguity, not a preference. /varand/homeownership are rewritten in the same pass, before the bootloader phase, so the system never boots with mismatched ownership.
5.3 /etc merge conflict policy (Q3)β
Keep the 3-way merge (old-default β current β new-default) β it is already
correct for the common case. New rule for the cross-base case: when both
current and target modified the same key (true conflict), write a
.rpmnew-style sidecar next to the file and add one summary line to the
final report the user resolves, rather than silently preferring either side.
Reuse etc_conflict.rs; it already implements most of this.
6. DE-migrate hook contract (Q4)β
Design the DE translation as a typed manifest over stdin + exit-code contract, not an env-var handshake:
plugin run <<JSON # {"action":"stash"|"restore","de":"gnome","user":"alice",...}
JSON
exit 0 β success; report on stdout (JSON)
exit 3 β "nothing portable" (not an error)
other β failure; stderr carries the reason
- Stash location:
~/.local/share/de-migrate/<from-de>/(namespaced, not deleted β enables round-trip restore per the RFC). - The engine calls plugins pre- and post-phase as
HookSpecs in the plan; a missing plugin binary is a warning, never a failure of the migration. - The GNOMEβKDE translation itself stays outside the core engine (per RFC out of scope), but the contract is the extension point.
7. Acquisition strategy (unchanged)β
Registry streaming (extract_files_via_registry, extract_subtree_via_registry,
extract_kernel_modules_via_registry) remains the only acquisition path β see
docs/architecture.md Β§1β2 for why (EROFS zero-fill past 4 KB, ENOSPC with
podman cp/skopeo). The planner does not get to choose a different strategy.
8. Refactor boundary with #123β
This is the key constraint: the planner + phase interface extraction is the
fix for #123, so it must not be done as a separate "cleanup" after the
feature work. Concretely for migration/deploy.rs (1,660 LOC) and
migration/boot.rs (1,651 LOC):
- M1 moves
deploy's image-stage vs. var/user carry-over into two phase structs (same code, new seams) β pure move, no behavior change. - M1 moves bootloader policy out of
boot.rsintopolicies.rs(the Β§5.1 rules), keepingphase5_setup_bootloaderas the executor. - M2 (cross-base) adds
uid_gid.rs+ extendsetc_conflict.rsβ no growth of the God-modules.
Anything that does not fit a phase or a policy belongs in a new module, not in
mod.rs glue.
9. Test strategy (Q5)β
- Phase Γ mode unit matrix: every phase declares
required_by/optional_for; a unit test iterates all modes Γ phases and asserts the expected selection (Β§4 table is executable). This catches a regression in A/B the moment C/D land. - Policy unit tests: bootloader keep-vs-migrate, UID remap collision
refusal,
.rpmnewsidecar generation β all pure, runnable on any host. - E2E: keep the existing composefs leg; add C (stableβLTS) and D (stableβtuna-os) legs once M2 lands. The phase-selection unit matrix is the fast guard; E2E legs are the slow proof.
10. Phasing map (RFC M1βM3)β
| RFC | Engine work | Modules touched |
|---|---|---|
| M1 | RebasePlan + planner + phase trait; --backend=ostree (A); standalone B | migration/plan.rs, migration/pipeline.rs, policies.rs (moves from deploy.rs/boot.rs) |
| M2 | cross-base (C): UID remap, conflict sidecars, SELinux relabel | uid_gid.rs, etc_conflict.rs |
| M3 | DE hooks (E): manifest contract + stash/restore | hooks.rs + payload/ plugins |
Drafted by the architect agent as the design counterpart to RFC #30 and
reviewed against docs/architecture.md (lessons) and the #111/#123 God-file
findings. Phase selection is now executable; destructive phase extraction is
still deliberately staged behind the protected MVP.