The registry authority, its copies and held adapters

How a service directory resolver reads the registry authority and its last-known-good copy, moves the authority, and treats an adapter whose service holds its requests.

A resolver whose registry authority is another host reads its first snapshot from that host over SSH before it binds anything. An authority that accepts the connection and answers nothing does not keep the resolver in state starting with no listener: the first read is given the refresh interval the target declares; past it the resolver says stado resolver recovery: the registry authority's first snapshot did not answer (<the abandoned read's sentence>); serving generation <N> from the registry store immediately while authority retries continue, binds its adapters from the registry its store (or its last-known-good copy) already holds, publishes serving, and keeps asking the authority on every refresh. While that first read stands, the published state says what it waits on: reading this host's registry store before binding any port, then reading its first registry snapshot from the authority <host> before binding any port, so connect and stado resolver status name the read (… holds no listener: reading its first registry snapshot from the authority <host> …) rather than report that nothing was published. tests/service_directory/held/silent/ runs the real resolver against a real OpenSSH server as the authority, which greets once and is then stopped with SIGSTOP so it accepts every connection and never speaks, and relays a client through the adapter it bound to a real object API.

A refresh whose authority does not answer reads this host's last-known-good copy too. When that copy's service_directory.generation is above the one the resolver serves, the resolver adopts it, says stado resolver: the registry authority did not answer (<cause>); adopting the newer registry this host was handed, directory generation <N>, and from then on asks the authority that copy names. This is how a registry naming a new authority reaches a host whose old authority is down: it is written into the host's copy, and the resolver takes it on its next refresh without a restart. The generation is the fence: a later answer of the old authority carries a lower generation and is refused as service directory rollback rejected: generation <old> < <new>. A copy that cannot be read is named beside the authority's own refusal. When it holds no higher generation, the refresh fails with the authority's refusal, as before.

The copy is written on each host with stado registry push PATH|- --last-known-good [--json]. It validates the document as every push does, then refuses when the authority answers (the registry authority answers; a registry it would serve is pushed through it, without --last-known-good), when this host is the authority or keeps no copy naming one, and when the document's service_directory.generation is not above the copy's (… not above the <held> this host's last-known-good copy holds; a copy is never rolled back). Otherwise it writes the copy with the store version handed-over and prints handed <path> to this host's last-known-good copy at service directory generation <N>; the authority did not answer: <cause>, or under --json {"state": "handed-over", "directory_generation", "authority_unanswered"}. It cannot be combined with --if-generation, --force or --allow-empty-fleet.

stado placement move --authority --to-host <host> [--json], run on <host>, takes the registry authority role while the declared authority does not answer. It refuses on any other host, when <host> is already the authority, when this host keeps no last-known-good copy, and while the authority answers (the registry authority <old> answers; move the role through it with stado placement move on <old>). Otherwise it promotes this host's copy: service_directory.authority names <host> and this program's own path as the command resolvers run there, and the directory generation advances by one. The promoted document is validated against the copy, written into this host's copy (its resolver adopts it and reads its own store from then on), written into the store this host reads, and handed to every other registered host except the old authority with registry push - --last-known-good over that host's SSH paths. The receipt names the new authority, the fenced one, the generation, the authority's own refusal, the store's version or error, and each host's receipt or error; its state is taken only when the store and every host took it, and otherwise the command fails naming the store's result and every host that was not handed the registry.

The object API publishes what it stands on at the vault the same way, in ~/.stado/resolver-vault.json: per bearer key (an object namespace, a release publisher item, the host-health role) the czekam line of the consultation holding it with open_since, and the blad czekania line of the last one that failed, plus the keyring_lock_repair it ran (holder, started, outcome) when a failure named the process holding the vault's keyring lock. It is the document /api/state.json serves under vault, written on every change, so it answers with the process stopped. stado resolver status lists it under vault and turns three states into blockers: a key whose last consultation failed (the object API's last consultation of the vault under <key> failed, so requests under <key> are refused with that line while one of them consults again: …), a key held longer than the refresh interval (… has stood <N>s, longer than the <N>s directory refresh, and every request under <key> is refused behind it: …) and a keyring-lock repair still running (… runs since <started>: the vault names that holder, so consultations fail until the lock is released); the text report prints the repair as vault keyring-lock-repair holder=… started=… outcome=…. A document that is there and cannot be read is a blocker naming the file. tests/storage/vault_consultation/ proves the file follows the live document through a held keyring and a revoked grant.

The resolver itself refuses the same way. A new connection accepted on an adapter whose published list already holds one unanswered longer than the refresh interval is not relayed: before anything is read from the client the adapter writes the refusal with the held wait's own sentence (not relayed: this host's resolver holds <N> connection(s) on that adapter unanswered longer than the <N>s refresh interval it declares, …) and closes, so a client that does not read ~/.stado/resolver-waiting.json — a curl in a git hook, a program on another machine — learns at once what it would otherwise learn never. The held connection keeps its place in stado resolver status; no clock ends it on its behalf.

One thing does end it from the resolver's side: the adapter's listener running out of file descriptors. Every held connection is a descriptor the resolver keeps for a request nothing answers, and an adapter whose accept fails with Too many open files serves nobody at all. On that one accept error the resolver ends every relay that has waited longer than the refresh interval the target declares, answers each such client with an HTTP 502 whose body is this host's resolver ran out of file descriptors accepting on <bind> (<accept error>): this connection had waited longer than the <N>s refresh interval for an answer that never came, so the resolver ended it to accept again, logs each shed wait as stado resolver adapter service=<s> consumer=<c> shed the wait of <client> to <endpoint> on <host> held since <instant> and the count as shed <N> held relay(s) after <error> and accepts again; <M> held wait(s) stand on channels still opening, gives their places back, and accepts again. A wait still opening its channel to the host (phase: open) stands on the SSH session, not on a relay, and is not shed. When no held relay is older than the interval, the adapter ends as before and stado resolver status shows failed with <bind> accept failed: <error>; no relay older than the <N>s refresh interval could be shed (<M> held wait(s) stand on channels still opening), so the descriptors are held by something this adapter cannot free.

A held adapter whose service lives on another host is first asked whether its held waits stand on the SSH session the new connection would ride. Every connection to one service endpoint on a host shares one native session, and that session can stop answering its channels while the host answers a new session at once. When the youngest held wait began after the current session answered, the session itself stands: the resolver opens a new session to the same host in its place, and when it answers the connection rides it, said on stderr as relaying although the adapter holds connections: the session to <host> for <endpoint> stands, … while a new session to <host> answered: new connections ride the new session and the held ones stay on the standing one until their clients leave. When the held waits all began before the current session answered, they ride one already replaced and the connection rides the current session. Only when no new session opens is the connection refused, with the held sentence followed by the session to <host> for <endpoint> stands with them and a new session in its place did not open: <cause>; a held adapter to a service on this host is refused as before. A service that holds its requests on every session costs one new session per refresh interval, since a wait counts as held only after it. The held waits a replaced session keeps carry session_replaced_at in ~/.stado/resolver-waiting.json and in stado resolver status waiting_opens, and the route's blocker ends with the SSH session it rides was replaced at <instant> by a new session to <host> that carries new connections, so it stands only until its client leaves. Such a wait no longer refuses a caller: held_at, the host-channel reading and the in-flight reading skip it, since the route it stood on is carried again.

The resolver does not wait for a connection to arrive before replacing a standing session: a caller that reads the waiting list refuses itself before it connects, so that connection would never come. On every refresh interval it reads its own waits, and for each route held past the interval on a session it has not replaced it opens a new session to the route's host in its place, saying stado resolver service=<name> consumer=<consumer> the session to <host> for <endpoint> stands, … while a new session to <host> answered: … on stderr, or the same prefix followed by the reason no new session opened. This runs in its own loop, so a host that answers no new session never holds the directory refresh.

A relay to another host that fails says on stderr how long it ran and whether the SSH session under it survived: stado resolver adapter service=<name> consumer=<consumer> rejected connection: proxy to <host>:<port> failed: <cause> after <n> ms; the SSH session to "<host>" is still open, so the service end closed this one channel when the service on that host dropped this one connection, or … is closed, so every channel on it failed together when the session died under every channel. The first points at the service on the destination host; the second at the session, which the resolver opens again for the next connection. The real journey cargo test --test service-directory-held -- closed_channel:: runs a real SSH server that closes every forwarded channel while its session lives, and reads the first sentence from the resolver's log.

A destination dials SSH's own port unless it names another the way known_hosts writes a server off that port: <user>@[<host>]:<port>. A native session (the resolver's relay and the registry authority read) dials <port> and trusts the host key known_hosts holds for [<host>]:<port>; a command that rides OpenSSH's ssh (the host channel, recovery, reboot, account and bootstrap commands) is given the ssh://<user>@<host>:<port> URI ssh reads. A port that is not a port is refused with SSH destination <destination> names port "<word>", which is not a port. The real test tests/service_directory/held/session/ runs a real SSH server on such a port whose first session carrying a channel stands while every other forwards to a service that answers: a first client's request stands on the resolver's session, and once it has waited past the refresh interval a second client on the same adapter is answered over a new session, the resolver says so, and the held wait carries session_replaced_at in the published list and in resolver status.

A route whose endpoint on a host is one of that host's resolver listeners — an adapter's bind, or the resolver API — is never relayed. Relaying it would send the connection back into the same process: accepted, forwarded to the same socket, accepted again, and held with nothing to answer it, which from the client looks like a healthy port with a hung service. The resolver refuses such a route when it resolves it, before any channel is opened, and the client receives the refusal as an HTTP 502 at once: service <name> resolves to <url>, which is adapter <service> for consumer <consumer> (<bind>) of this resolver on <host>: the service directory must carry the address the service listens on, not the stable address published for its clients; relaying it would loop back into this process and no client would ever be answered. localhost and the loopback address name the same listener. The resolver keeps serving; stado registry doctor names the same pair in the document so it can be corrected. The real test tests/service_directory/held/self_loop.rs drives it.

No clock cuts a relayed request. A request the service never begins answering stays in the published waiting list as phase: answer, where stado resolver status names it and every caller that reads the list before sending (the object API client, Skarbiec requests, Tama's judge client and stado service directory connect) is refused at once with the held wait's own sentence — for as long as the client that sent it waits for the answer. A client that closes before the service's first byte comes down (a hook process that ended, a caller whose turn was over) has nobody left to answer: the resolver ends that relay, closes its channel to the service, gives the place back and says request to <endpoint> on <host> abandoned by its client before an answer; the channel is closed; <N> open(s) still waiting, so a caller that died with its request sent does not hold the adapter against every later caller. A client that closes its sending side after the answer began is still read the rest of the answer. The former route field answer_seconds is retired: registry import refuses a route that declares it (unknown field \answer_seconds`) and stado service declare --file refuses a file that names it ('answer_seconds' is retired: a resolver never cuts a relayed request by a clock; …; remove the key). The real test tests/service_directory/held/held_answer.rs` holds a request against a service that never answers, reads it from the published list, closes the client and reads the list empty and the next caller relayed.

Source: this website