generated from eblume/project-template
feat(hephd): --owner-id flag — one-step Path-A hub seeding
All checks were successful
Build / validate (pull_request) Successful in 13m12s
All checks were successful
Build / validate (pull_request) Successful in 13m12s
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 <noreply@anthropic.com>
This commit is contained in:
parent
0e5bed3282
commit
8fe11c75cd
3 changed files with 84 additions and 17 deletions
|
|
@ -84,6 +84,12 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
oidc_client_id: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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())
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <gilbert-owner-id> \
|
||||
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
|
||||
--oidc-audience <heph-client-id>
|
||||
```
|
||||
|
||||
`--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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue