capsule_runtime/registration.rs
1//! What a session tells `mur-roost` about itself at launch, and what it is handed back.
2//!
3//! The daemon referees; it stages nothing, so what a session holds is not something it can know
4//! by having launched it. A session that declares `capabilities.spawn.allow` registers: it names
5//! which artifact is running under which session id, and the daemon resolves that artifact from
6//! its *own* registry and lowers the manifest into a [`crate::SpawnEnvelope`] itself. The
7//! registrant never states its grants — a registrant that could would be a registrant that could
8//! declare its own ceiling.
9//!
10//! What comes back is the session's [`SpawnCredential`], which is the only thing that makes a
11//! later `POST /spawn` answerable. It is held in runtime memory for the life of the session and
12//! reaches no workdir file, no environment variable and no trace record.
13//!
14//! Registration is required for a delegating session and fatal when it fails
15//! ([`RuntimeError::SpawnRegistrationFailed`]): a capsule that can delegate but that the daemon
16//! has never heard of is a capsule spawning outside the only thing that bounds it. Deregistration
17//! is the mirror image and is best effort — a session that has already finished has nothing left
18//! to refuse.
19
20use std::time::Duration;
21
22use serde_json::json;
23
24use crate::errors::RuntimeError;
25use crate::http_client::{http_json, http_json_with_timeout};
26use crate::spawn_credential::{
27 SpawnApproval, SpawnCredential, SPAWN_APPROVAL_HEADER, SPAWN_CREDENTIAL_HEADER,
28};
29
30/// Deadline [`check_roost_health`] asks under. Its callers report to a person waiting on the
31/// answer, so an unreachable address has to fail in seconds rather than at the deadline a
32/// registration is allowed.
33const HEALTH_TIMEOUT: Duration = Duration::from_secs(2);
34
35/// Whether a daemon answers `GET /health` at `roost_url`, asked over the client
36/// [`register_session`] registers with — so the answer is the one a launch would get rather than a
37/// second client's opinion.
38///
39/// `Err` carries the transport or response reason: an unparseable URL, a refused or timed-out
40/// connection, a non-2xx status, or a body that is not JSON. It never carries a request header or
41/// a request body, for the reason [`crate::http_client`] states. Fails within [`HEALTH_TIMEOUT`]
42/// rather than at the deadline a registration gets.
43pub fn check_roost_health(roost_url: &str) -> Result<(), String> {
44 let roost_url = roost_url.trim_end_matches('/');
45 http_json_with_timeout(
46 "GET",
47 &format!("{roost_url}/health"),
48 None,
49 &[],
50 HEALTH_TIMEOUT,
51 )
52 .map(|_| ())
53}
54
55/// How a session ended, as `POST /deregister` reports it.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum SessionOutcome {
58 Complete,
59 Failed,
60}
61
62impl SessionOutcome {
63 /// The wire value, and the `status` `GET /status/{session_id}` reports afterwards.
64 pub fn as_str(self) -> &'static str {
65 match self {
66 Self::Complete => "complete",
67 Self::Failed => "failed",
68 }
69 }
70}
71
72/// Announce a running session to the daemon and take the credential it mints.
73///
74/// `grant` is the approval a delegated child was launched with; it is presented in
75/// [`SPAWN_APPROVAL_HEADER`] and redeemed exactly once by the daemon against the artifact it
76/// resolves for `capsule_name`/`capsule_version`. `None` is a top-level launch, which the daemon
77/// admits only for a name in its own `--spawn-allow`.
78pub fn register_session(
79 roost_url: &str,
80 session_id: &str,
81 capsule_name: &str,
82 capsule_version: &str,
83 grant: Option<&SpawnApproval>,
84) -> Result<SpawnCredential, RuntimeError> {
85 let roost_url = roost_url.trim_end_matches('/');
86 let body = json!({
87 "session_id": session_id,
88 "name": capsule_name,
89 "version": capsule_version,
90 })
91 .to_string();
92
93 // The approval's one reading. `SpawnApproval` has no `Display`, no `Serialize` and a redacted
94 // `Debug`, so this is the only route by which the token can reach the wire — and it reaches a
95 // request header, never the body and never the process environment.
96 let headers: Vec<(&str, &str)> = match grant {
97 Some(grant) => vec![(SPAWN_APPROVAL_HEADER, grant.expose())],
98 None => Vec::new(),
99 };
100
101 let response = http_json(
102 "POST",
103 &format!("{roost_url}/register"),
104 Some(&body),
105 &headers,
106 )
107 .map_err(|reason| RuntimeError::SpawnRegistrationFailed {
108 roost_url: roost_url.to_string(),
109 reason,
110 })?;
111
112 let credential = response
113 .get("credential")
114 .and_then(serde_json::Value::as_str)
115 .ok_or_else(|| RuntimeError::SpawnRegistrationFailed {
116 roost_url: roost_url.to_string(),
117 reason: "the register response carried no credential".to_string(),
118 })?;
119
120 Ok(SpawnCredential::new(credential.to_string()))
121}
122
123/// Tell the daemon this session has ended, so its credential stops verifying against anything.
124///
125/// Best effort by construction: the session is over, and a daemon that has restarted or gone away
126/// already holds no record to retire. A failure here is reported on stderr and returns nothing —
127/// failing a completed session because its bookkeeping call did not land would turn a successful
128/// run into a failed one.
129pub fn deregister_session(roost_url: &str, credential: &SpawnCredential, outcome: SessionOutcome) {
130 let roost_url = roost_url.trim_end_matches('/');
131 let body = json!({ "outcome": outcome.as_str() }).to_string();
132 if let Err(reason) = http_json(
133 "POST",
134 &format!("{roost_url}/deregister"),
135 Some(&body),
136 &[(SPAWN_CREDENTIAL_HEADER, credential.expose())],
137 ) {
138 crate::runtime_err!(
139 "[capsule-runtime] could not deregister from mur-roost at {roost_url}: {reason}"
140 );
141 }
142}