From 8fe11c75cda391f4a10ac8a4f2f04cab5909d476 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 9 Jun 2026 10:55:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(hephd):=20--owner-id=20flag=20=E2=80=94=20?= =?UTF-8?q?one-step=20Path-A=20hub=20seeding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh hub started with an existing device's owner id rebuilds itself entirely from that spoke's first full op-log push: no snapshot copy and no origin reset (the new store mints its own). adopt_owner is idempotent once adopted, so the flag is safe baked into a service unit. [[set-up-sync-hub]] documents the recipe and drops the manual-seeding gap. Co-Authored-By: Claude Fable 5 --- crates/hephd/src/main.rs | 13 ++++++++- crates/hephd/tests/sync_http.rs | 51 +++++++++++++++++++++++++++++++++ docs/how-to/set-up-sync-hub.md | 37 +++++++++++++----------- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index 4037245..78d954e 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -84,6 +84,12 @@ struct Cli { #[arg(long)] oidc_client_id: Option, + /// Adopt this canonical owner id at startup (idempotent once adopted). + /// Path-A hub seeding: a fresh hub started with an existing device's owner + /// id rebuilds from that spoke's first full op-log push — no snapshot copy. + #[arg(long)] + owner_id: Option, + /// Opt-in (default off): periodically poll the forge for a newer release and /// auto-update this daemon. Off unless this flag is given. #[arg(long)] @@ -167,7 +173,12 @@ async fn main() -> Result<()> { } // Take the exclusive lock before opening the store (tech-spec §3.1). let lock = LockGuard::acquire(&db)?; - let store = LocalStore::open(&db, Box::new(SystemClock))?; + let mut store = LocalStore::open(&db, Box::new(SystemClock))?; + if let Some(owner) = &cli.owner_id { + use heph_core::Store as _; + store.adopt_owner(owner)?; + tracing::info!(%owner, "adopted canonical owner id"); + } let spoke = cli.hub_url.as_deref().and_then(|hub| { spoke_auth(hub, cli.oidc_issuer.as_ref(), cli.oidc_client_id.as_ref()) }); diff --git a/crates/hephd/tests/sync_http.rs b/crates/hephd/tests/sync_http.rs index ed093ce..73e15b8 100644 --- a/crates/hephd/tests/sync_http.rs +++ b/crates/hephd/tests/sync_http.rs @@ -204,3 +204,54 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() { "B recorded no conflict" ); } + +#[tokio::test] +async fn fresh_hub_seeded_with_owner_id_rebuilds_from_first_sync() { + // Path-A seeding: a brand-new hub started with the device's owner id (the + // `hephd --owner-id` flag) needs no snapshot copy — the spoke's first sync + // replays its whole op-log into the hub, and the hub keeps its own fresh + // device origin by construction. + let http = reqwest::Client::new(); + + // The device: its own generated owner, with pre-existing data. + let dir = tempfile::tempdir().unwrap(); + let mut device = + LocalStore::open(dir.path().join("heph.db"), Box::new(StepClock::new(1000))).unwrap(); + let owner = device.owner_id().to_string(); + let task = device + .create_task(NewTask { + title: "Pre-hub task".into(), + ..Default::default() + }) + .unwrap(); + let device = Arc::new(Mutex::new(device)); + + // The hub: a fresh store adopting the device's owner (what --owner-id does). + let hub_dir = tempfile::tempdir().unwrap(); + let mut hub_store = LocalStore::open( + hub_dir.path().join("heph.db"), + Box::new(StepClock::new(1000)), + ) + .unwrap(); + hub_store.adopt_owner(&owner).unwrap(); + let hub = Arc::new(Mutex::new(hub_store)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = sync::router(hub.clone(), None); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let hub_url = format!("http://{addr}"); + + let report = sync::sync_once(device.clone(), &hub_url, &http, None) + .await + .unwrap(); + assert!(report.pushed > 0, "device pushed nothing"); + let on_hub = hub + .lock() + .unwrap() + .get_node(&task.node_id) + .unwrap() + .expect("task reached the hub"); + assert_eq!(on_hub.title, "Pre-hub task"); +} diff --git a/docs/how-to/set-up-sync-hub.md b/docs/how-to/set-up-sync-hub.md index 4d654a9..b838a74 100644 --- a/docs/how-to/set-up-sync-hub.md +++ b/docs/how-to/set-up-sync-hub.md @@ -73,23 +73,37 @@ avoid re-authenticating often, set generous validities on the heph provider: ## 2. Bring up the hub on `indri` -**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`), -copy its store to `indri`, and give `indri` its **own device origin** so the two -replicas don't share one (see *Current gaps* — this seeding step is the bit the -blumeops deployment finalizes). `indri` now holds `gilbert`'s data under the same -`owner_id`. +**Seed it from `gilbert` (Path A) with `--owner-id`.** No snapshot copy: start +the hub on a **fresh, empty store** that adopts `gilbert`'s `owner_id`, and the +spoke's first sync replays its entire op-log into the hub (sync is op-based, so +a hub that shares the owner rebuilds completely from ops). The hub gets its own +device origin by construction — no origin-reset step, and `gilbert` is never +rewritten. -Run the hub with auth enabled (issuer **and** audience together turn auth on; -omit both only for local dev): +Find the device's owner id on `gilbert` (any node row carries it): + +```bash +heph show "$(heph list --json | jq -r '.[0].node_id')" | grep owner_id +# or directly: sqlite3 ~/.local/share/heph/heph.db 'SELECT id FROM users' +``` + +Run the hub with that owner and auth enabled (issuer **and** audience together +turn auth on; omit both only for local dev): ```bash hephd --mode server \ --http-addr 0.0.0.0:8787 \ --db /var/lib/heph/heph.db \ + --owner-id \ --oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \ --oidc-audience ``` +`--owner-id` is idempotent once adopted, so it is safe baked into the service +unit. (Copying the SQLite snapshot still works as a seeding shortcut for huge +stores, but then the copy shares `gilbert`'s device origin and must have it +reset — prefer the flag.) + The first identity to authenticate **claims** the hub's owner; thereafter only that identity is served (single-owner today — see [[design]] and the `Adoption + multi-tenant` task for the multi-tenancy seam). @@ -150,15 +164,6 @@ The spoke then can't refresh on its own and needs a re-login — but this is Run the printed `heph auth login …` command to restore sync. -## Current gaps (finalized by the blumeops deployment) - -The flag-level flow above works today; one enabler makes it a clean, managed -deployment rather than a hand-run process — tracked in the `Hephaestus` project: - -- **Path A seeding is manual** (copy the store + reset the device origin). A - small enabler — seed a hub from a snapshot with a fresh origin, or - `hephd --owner-id` — would make this one step. - > `heph daemon start`/`restart` can now bake the spoke/hub config (`--hub-url`, > `--mode server`, `--http-addr`, `--oidc-*`) into the generated service (see > [[run-the-daemon]]). The canonical hub on `indri` is still provisioned via the