Skip to content

start: --ephemeral-identity and --datadir for nodes sharing a $HOME - #68

Merged
xzyaoi merged 4 commits into
eth-easl:mainfrom
mzueri:ephemeral-identity-datadir
Nov 2, 2026
Merged

xzyaoi merged 4 commits into
eth-easl:mainfrom
mzueri:ephemeral-identity-datadir

Conversation

@mzueri

@mzueri mzueri commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Nodes that share one home directory, e.g. the nodes of a multi-node Slurm job on a shared filesystem (Clariden), all load the same ~/.config/opentela/keys/id and announce the same peer ID. --seed doesn't help: a seed is only used when no key file exists yet. The gateway then answers 503 "No provider found" for the model.

They also all write their peer databases to ~/.ocfcore/ocfcore.<id>.db. A node only removes its own database on a clean shutdown, so nodes that get killed leave theirs behind. On Clariden, 52 of them (19 GB) filled the 50 GB home quota, and OpenTela then failed to start with disk quota exceeded.

The current workaround in model-launch (swiss-ai/model-launch#263) is to run OpenTela with HOME and --config-dir pointed at a per-job /tmp folder, and copy the user's cfg.yaml into it. It also still breaks when two OpenTela processes run on one node (sglang router + replica 0 head). The Euler deployment overrides HOME to /tmp for the same database reason.

Change

Two new otela start flags:

  • --ephemeral-identity: the node uses an identity generated in memory, once per process. No key file is read or written. It overrides --seed. The instance link (deploy key) signs with the same in-memory key, so the linked peer is the one that serves. Using it without --datadir logs a warning.
  • --datadir (or OF_DATADIR): the existing datadir setting (usage store) now also moves the peer database to <datadir>/ocfcore. Unset keeps ~/.ocfcore.

A multi-node job then only needs:

otela start --ephemeral-identity --datadir "$(mktemp -d)" ...

No HOME override and no copying of cfg.yaml; the user's config is read from its normal place.

Neither flag is bound with viper.BindPFlag: bound keys are written into the cfg.yaml seeded on first run, so a one-off --ephemeral-identity or temporary --datadir would stick to every later start. initConfig applies them per run, like --seed.

Identity selection moves out of newHost into loadOrCreateIdentity, so it can be unit-tested without starting a libp2p host. A short note in the spin-up tutorial explains when to use the flags.

For the maintainers to weigh

  • An ephemeral node links a new peer to the account on every run, if a deploy key is used.
  • Peer-ID allowlists can't cover ephemeral nodes. This is opt-in, and the default behaviour is unchanged.
  • Anyone who already sets datadir in cfg.yaml gets their peer database moved there too. No config in this repo sets it, and the database is wiped at start by default (cleanslate).

Testing

  • 9 new unit tests:
    • an existing key file is neither used nor overwritten, and no key file is written;
    • one identity per process, including concurrent callers, and a new identity per new process;
    • the default stays stable;
    • --datadir moves the database;
    • the instance link uses the served identity;
    • the flags are not saved into cfg.yaml. This test runs in a child test process, because other tests' viper.Reset would hide the bug.
  • Mutation check: each fix was reverted in turn, and its test failed every time.
  • End to end with the built binary:
    • three standalone nodes on one shared HOME get three distinct peer IDs;
    • nothing is written to the shared home, and nothing is saved into cfg.yaml;
    • databases are removed on SIGTERM;
    • a kill -9'd node leaves its database only in its own datadir;
    • a later plain otela start gets its persisted identity back.
  • go test -race ./... passes (also with -shuffle=on -count=3 on the touched packages), make lint reports 0 issues, and the linux/amd64 and linux/arm64 builds succeed (Go 1.25).
  • Not yet tested on Clariden itself; that will happen through model-launch once this is released.

Nodes that share one home directory (e.g. the nodes of a multi-node Slurm
job on a shared filesystem) all load the same ~/.config/opentela/keys/id and
announce the same peer ID; --seed does not help, since a seed is only used
when no key file exists yet. They also all write their peer databases to
~/.ocfcore, which fills the home quota when nodes are killed before they can
clean up.

- --ephemeral-identity: the node uses an in-memory identity generated once
  per process; no key file is read or written. Overrides --seed. The
  instance link (deploy key) signs with the same in-memory key, so the linked
  peer is the one that serves. Warns when used without --datadir.
- --datadir (or OF_DATADIR): the existing datadir setting (usage store) now
  also moves the peer database to <datadir>/ocfcore. Unset keeps ~/.ocfcore.

Neither flag is bound with viper.BindPFlag: bound keys are written into the
cfg.yaml seeded on first run, so a one-off --ephemeral-identity or temporary
--datadir would stick to every later start. initConfig applies them per run,
like --seed.

Identity selection moves out of newHost into loadOrCreateIdentity so it can
be unit-tested without starting a libp2p host.
@mzueri

mzueri commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

@xzyaoi @robmsmt @AryanAhadinia
(can not assign reviewer)

robmsmt pushed a commit to swiss-ai/OpenTela that referenced this pull request Oct 29, 2026
Nodes that share one home directory (e.g. the nodes of a multi-node Slurm
job on a shared filesystem) all load the same ~/.config/opentela/keys/id and
announce the same peer ID; --seed does not help, since a seed is only used
when no key file exists yet. They also all write their peer databases to
~/.ocfcore, which fills the home quota when nodes are killed before they can
clean up.

- --ephemeral-identity: the node uses an in-memory identity generated once
  per process; no key file is read or written. Overrides --seed. The
  instance link (deploy key) signs with the same in-memory key, so the linked
  peer is the one that serves. Warns when used without --datadir.
- --datadir (or OF_DATADIR): the existing datadir setting (usage store) now
  also moves the peer database to <datadir>/ocfcore. Unset keeps ~/.ocfcore.

Neither flag is bound with viper.BindPFlag: bound keys are written into the
cfg.yaml seeded on first run, so a one-off --ephemeral-identity or temporary
--datadir would stick to every later start. initConfig applies them per run,
like --seed.

Identity selection moves out of newHost into loadOrCreateIdentity so it can
be unit-tested without starting a libp2p host.

(cherry picked from eth-easl#68, 9578e3c, onto the swiss-ai fork.
The instance_link.go changes are dropped: the fork doesn't have the
instance-link / deploy-key feature yet. Everything else applies as-is, and
both flags stay opt-in. datadir defaults to empty and nothing in rob-poc or
model-launch sets it, so the peer DB location is unchanged for current users.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TuTFZ3x4obuvY516B6r7aj
xzyaoi and others added 2 commits November 2, 2026 14:49
An ingress-only process never brings up a libp2p host, so linking its
identity (especially an ephemeral one) only spends deploy-key uses and
leaves phantom instances in the console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8BSW3HH9JZF5S51EM9B6V
Review follow-ups for --ephemeral-identity / --datadir:

- protocol.NodeIdentity(seed) is now the single resolver for both the
  libp2p host and nodeLinkSigner, so linking and serving cannot drift
  (nodeLinkSigner previously re-implemented the selection and ignored
  the seed). ConfiguredSeed() shares the seed parsing.
- One newKey helper for persisted, seeded and ephemeral keys.
- OF_EPHEMERAL_IDENTITY enables the flag; the hyphenated viper key was
  unreachable via AutomaticEnv. Bound after the first-run cfg.yaml seed,
  so it never persists.
- With --ephemeral-identity and no --datadir, node data goes to a
  per-process temp dir instead of piling up in the shared ~/.ocfcore.
- datadir expands a leading ~ (common.DataDir), for both the peer
  database and the usage store.
- `otela peer-id` warns on stderr when ephemeral identity is enabled.
- Drop the dead connection-manager error branch in newHost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8Q1ECER7DRR2Q2HTF3CZD
@xzyaoi
xzyaoi self-requested a review November 2, 2026 13:58
@xzyaoi xzyaoi self-assigned this Nov 2, 2026
- nodeLinkSigner refuses a seeded identity: its key is derivable from a
  small integer, so linking it would let anyone who guesses the seed
  sign as an account-linked peer. Seeds stay for tests and benchmarks.
- 'otela instance link' refuses when OF_EPHEMERAL_IDENTITY is set: the
  identity dies with the command, so the link would spend a deploy-key
  use on a peer that never comes online. Boot-time links by the serving
  node are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8Z0MWVH1H60DHPYH383PN
@xzyaoi
xzyaoi merged commit c7a4a96 into eth-easl:main Nov 2, 2026
xzyaoi added a commit that referenced this pull request Nov 2, 2026
Resolve conflicts with #68 (ephemeral identity): nodeLinkSigner goes through
protocol.NodeIdentity; seeded keys keep this branch's Ed25519
GenerateKeyFromSeed; GenerateAndWriteKey keeps its seed parameter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoa4Sf4BDt1BrxB1v4c
Entire-Checkpoint: 01M6E9QKR0DMNQJH5VD77CQ6KR
xzyaoi added a commit that referenced this pull request Nov 2, 2026
* feat(identity): attest wallet-less nodes from a derived deploy key

Wallet-less nodes linked to OpenTela Cloud via a deploy key previously
published no identity attestation, so the cloud's ownership checks and
peers' trust computation saw an inconsistent record. Derive a stable
Ed25519 key from the deploy key (domain-separated SHA-256 -> seed) and
sign the identity attestation with it, so Owner == attested pubkey and
the signature verifies without a Solana wallet.

- wallet.DeriveIdentityKeyFromDeployKey: deterministic, path-independent.
- wallet.SignIdentityWithKey: sign with a raw Ed25519 key.
- protocol.deployKeyIdentity + InitializeMyself fallback.
- tests for derivation stability and consistent record.

Entire-Checkpoint: 01M4TC34XQTYJ5E62907C3BWWD

* feat(cli): seedable identity generation for 'otela init'

'otela init' pre-generates the libp2p identity key used by --config-dir
and the mesh-bench runner, but had no way to control it: the key was
always random, so a later 'start --seed N' just loaded that random key
and the seed had no effect.

Add --seed to 'otela init' and generate a reproducible, distinct PeerID
from a non-zero seed. To make the seed actually reproducible, the seeded
path now uses Ed25519: Go's RSA generation is deliberately non-
deterministic from a seeded reader, so the old RSA-based --seed never
produced a stable PeerID. RSA is retained for the seed=0 random path,
and existing persisted keys are always loaded unchanged.

- protocol.GenerateKeyFromSeed (Ed25519) and GenerateAndWriteKey(seed).
- newHost reuses the shared keygen instead of duplicating it.
- init_test: same seed => same key, different seed => different key.
- docs: correct the false '--seed 0 is deterministic' claim.

Entire-Checkpoint: 01M4TC3GYSW0FQSHZM0RJJE6S4

* feat(cli): make boot-time instance link opt-in (--auto-link)

'otela start' used to re-link the node with any stored deploy key on
every boot. Same-peer re-links are free, but each new peer ID spends a
key use, so nodes that mint fresh identities (e.g. --ephemeral-identity
on Slurm) drained the key on every restart and left phantom instances.

Linking at boot now requires instance.auto_link (--auto-link /
OF_INSTANCE_AUTO_LINK=true). An explicit 'otela start --deploy-key'
still links that run. Without either, a stored key only logs a hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8V0Q98CKK4XRXFRJYYWCE

* fix(protocol): worker connectivity watchdog retries bootstrap + relay reservation (#67)

* fix(protocol): worker connectivity watchdog retries bootstrap + relay reservation

A worker is only routable from the head if it holds a bootstrap connection
and a live relay reservation. Both were attempted once at startup (the
reservation 10 s after the CRDT store starts); if that raced the network,
the node stayed locally healthy but unreachable. Recovery relied on
Reconnect() firing with 50% probability per minute, and the auto-reconnect
loop only acts when there are zero connections. All failures logged at Debug,
so nothing was visible.

Add a watchdog (workers only) that, until both conditions hold, retries the
bootstrap dial and relay reservation with 5s..60s jittered backoff, and logs
state transitions at Info/Warn. Track reservation expiry so a lapsed
reservation is re-made. Export IsBootstrapConnected/HasRelayReservation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjjN5e5xxxaFYKQcW51ytb

* fix(protocol): make connectivity watchdog checks cheap and tie reservations to the advertised relay

Review follow-ups for the worker connectivity watchdog:

- Cache resolved bootstrap peers. Health checks are now in-memory reads;
  discovery (HTTP/DNS) runs only on refresh, reconnect, or when the cache
  is older than 10 minutes. An empty or failed resolution keeps the last
  known set, so a discovery outage no longer marks a connected node as
  disconnected.
- Parse bootstrap multiaddrs one by one, skipping entries without a
  /p2p/<id> component instead of failing the whole batch.
- Track reservations per relay peer and drop them when the last
  connection to that relay closes. "Relay reserved" now means the
  RelayPeer advertised in the CRDT is connected and holds an unexpired
  reservation, or AutoRelay advertises a circuit address through it.
- Keep the advertised relay stable while it is still valid; otherwise
  prefer a freshly reserved bootstrap peer.
- Guard MakeRelayReservations against overlapping runs from startup,
  Reconnect() and the watchdog.
- Start the watchdog under the same condition as AutoRelay, and not in
  standalone mode. With no bootstrap peers resolved, log once and back
  off without making reservations.
- Tests restore the package-global state they touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E5X1BDH1J2XKKHPEPX3D1G

* fix(protocol): reserve on relay-capable peers in parallel; add docker NAT mesh test

MakeRelayReservations tried every connected peer serially with a 10 s
timeout each, so one watchdog step could block for minutes. Every node
runs a relay service, so it also reserved on other NATed workers, which
heads cannot route through (AutoRelay already covers worker-to-worker
circuits).

- Reserve only on bootstrap peers and peers the node table marks as
  relay/head or with a public address; fall back to all connected peers
  when none qualifies.
- Run reservations in parallel (at most 8 in flight, 10 s timeout each).
  Successes are returned in candidate order, so pickAdvertisedRelay's
  preference is unchanged.

Add TestRelayWatchdogMesh (integration tag): two isolated docker bridge
networks simulate a public network and an HPC private network, so the
head can reach workers only through the relay. It covers a baseline
worker, a worker started while the relay is unreachable, and a relay
restart while the workers stay connected to each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E6QF8VP2TPTQ0EE3FE7J09

* test(integration): check the response body in the relay mesh routability probe

The p2p forward error handler writes "ERROR: ..." with status 200, so a
status check alone treated failed forwards as success. Require the
worker's {"status":"ok"} health body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E7G9T32HJGVCJNDW5DEJPM

* fix(server): report failed p2p forwards as 502/504 instead of 200

ErrorHandler wrote "ERROR: <reason>" without setting a status, so the
response went out as 200. Clients and health checks took failed
forwards for success. A relay that could not reach a worker also
answered the head with 200, and the head passed that body to the
client as a successful response.

- ErrorHandler now responds 504 on deadline errors and 502 otherwise.
  The body keeps the "ERROR: <reason>" text. Responses carry
  X-Otela-Proxy-Error: 1 to mark them as generated by the proxy.
- The service-forward retry path reuses ErrorHandler.
- On a relay hop, the head treats a marked response from the relay as
  a transport failure and retries another worker. An unmarked 5xx from
  the worker's own service is still passed through.
- The docker NAT mesh test asserts that a forward to an unreachable
  peer returns a marked 5xx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E7XGGN2KSSE0GY9YX8RBD7

* test(integration): exercise the relay v2 circuit path in the NAT mesh test

The pub network used an RFC 1918 subnet. AutoRelay drops a relay's
private addresses when building circuit addresses, so workers
advertised no /p2p-circuit address. The head could never dial them
directly, and the test only covered the HTTP relay hop, which does not
depend on reservations. Code from before this PR passed the test.

- Use 11.213.10.0/24, which libp2p treats as public, for the pub
  network.
- Require both the relay hop and a direct circuit dial from the head
  in every scenario.

With this change, code from before the PR fails the relay-restart
scenario in 2 of 2 runs: the relay hop recovers, but the circuit dial
fails for 90 s. This PR passes all scenarios in 3 of 3 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E99Z6NMQVKN8SQNBJWWMXN

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Xiaozhe Yao <askxzyao@gmail.com>

* start: --ephemeral-identity and --datadir for nodes sharing a $HOME (#68)

* start: --ephemeral-identity and --datadir for nodes sharing a $HOME

Nodes that share one home directory (e.g. the nodes of a multi-node Slurm
job on a shared filesystem) all load the same ~/.config/opentela/keys/id and
announce the same peer ID; --seed does not help, since a seed is only used
when no key file exists yet. They also all write their peer databases to
~/.ocfcore, which fills the home quota when nodes are killed before they can
clean up.

- --ephemeral-identity: the node uses an in-memory identity generated once
  per process; no key file is read or written. Overrides --seed. The
  instance link (deploy key) signs with the same in-memory key, so the linked
  peer is the one that serves. Warns when used without --datadir.
- --datadir (or OF_DATADIR): the existing datadir setting (usage store) now
  also moves the peer database to <datadir>/ocfcore. Unset keeps ~/.ocfcore.

Neither flag is bound with viper.BindPFlag: bound keys are written into the
cfg.yaml seeded on first run, so a one-off --ephemeral-identity or temporary
--datadir would stick to every later start. initConfig applies them per run,
like --seed.

Identity selection moves out of newHost into loadOrCreateIdentity so it can
be unit-tested without starting a libp2p host.

* start: skip bootAccount for --component ingress

An ingress-only process never brings up a libp2p host, so linking its
identity (especially an ephemeral one) only spends deploy-key uses and
leaves phantom instances in the console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8BSW3HH9JZF5S51EM9B6V

* ephemeral-identity: one identity resolver, env form, temp datadir

Review follow-ups for --ephemeral-identity / --datadir:

- protocol.NodeIdentity(seed) is now the single resolver for both the
  libp2p host and nodeLinkSigner, so linking and serving cannot drift
  (nodeLinkSigner previously re-implemented the selection and ignored
  the seed). ConfiguredSeed() shares the seed parsing.
- One newKey helper for persisted, seeded and ephemeral keys.
- OF_EPHEMERAL_IDENTITY enables the flag; the hyphenated viper key was
  unreachable via AutomaticEnv. Bound after the first-run cfg.yaml seed,
  so it never persists.
- With --ephemeral-identity and no --datadir, node data goes to a
  per-process temp dir instead of piling up in the shared ~/.ocfcore.
- datadir expands a leading ~ (common.DataDir), for both the peer
  database and the usage store.
- `otela peer-id` warns on stderr when ephemeral identity is enabled.
- Drop the dead connection-manager error branch in newHost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8Q1ECER7DRR2Q2HTF3CZD

* instance link: refuse seeded and ephemeral identities

- nodeLinkSigner refuses a seeded identity: its key is derivable from a
  small integer, so linking it would let anyone who guesses the seed
  sign as an account-linked peer. Seeds stay for tests and benchmarks.
- 'otela instance link' refuses when OF_EPHEMERAL_IDENTITY is set: the
  identity dies with the command, so the link would spend a deploy-key
  use on a peer that never comes online. Boot-time links by the serving
  node are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8Z0MWVH1H60DHPYH383PN

---------

Co-authored-by: Xiaozhe Yao <askxzyao@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Rob <rob@robmsmt.com>
Co-authored-by: Mel Zuercher <mel.zuercher@ai.ethz.ch>
mzueri added a commit to swiss-ai/model-launch that referenced this pull request Nov 9, 2026
…of a per-job HOME

eth-easl/OpenTela#68 (v0.2.7) added --ephemeral-identity and --datadir to
`otela start`, so the per-job HOME override, --config-dir and cfg.yaml copy are
no longer needed. Each OpenTela process gets an in-memory identity and a
node-local database directory of its own (mktemp, removed on exit), so nodes
sharing a $HOME, and two OpenTela processes on one node, no longer share a
peer ID or database.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants