mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: resident guest init (#333)
Get this thing going..
<!-- greptile_comment -->
<!-- greptile_summary -->
<h2><a
href="https://app.greptile.com/api/retrigger?id=63134761"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"
align="right"></picture></a>Confidence Score: 5/5</h2>
The PR appears safe to merge; all previous findings are resolved and the
latest readiness change introduces no established actionable regression.
<h3>Summary</h3>
- Establishes required guest filesystems, runtime directories, device
permissions, and service processes.
- Reports initialization and service deaths over the lifecycle channel.
- Supports launch, restart, and shutdown commands for a resident guest.
- Separates service and workload identities and configures per-launch
runtime environments.
- Removes the currently inactive nescope screenshot option and makes
capture-chain verification fail explicitly when compositor readback is
unavailable.
- Reworks the guest image around `nesinit` as PID 1 without a
distribution service manager.
<h3>Diagram</h3>
```mermaid
sequenceDiagram
participant Host
participant Init as nesinit
participant FS as Guest filesystems
participant Services as Service stack
participant Workload
Init->>Host: Ready(protocol version)
Host->>Init: Boot(mount descriptors)
Init->>FS: Establish and mount shares
Init->>Services: Spawn services in order
Services-->>Init: Required sockets ready
Init->>Host: Initialized(service names)
Host->>Init: Launch(id, exec, on_exit)
Init->>Workload: Spawn with isolated UID/runtime
Init->>Host: Started(id)
Workload-->>Init: Exit status
Init->>Host: WorkloadExited(id, status)
Host->>Init: Launch / Restart / Shutdown
```
<sub>Reviews (4) · Last reviewed commit: ["fix(nesinit): readiness is a
socket
that..."](731d34df9d)</sub>
<!-- /greptile_comment -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
ec8b13d0c9
commit
8246aa5538
@@ -1,14 +1,34 @@
|
||||
// The lifecycle layer of the control channel between a box and whatever runs
|
||||
// it: the boot descriptor the guest is handed, and what the guest says back
|
||||
// about carrying it out.
|
||||
// it: the document describing the box, the commands that run things inside it,
|
||||
// and what the guest says back about carrying either out.
|
||||
//
|
||||
// It lives beside the media types for the same reason they live here — one
|
||||
// definition, so the two ends cannot drift from each other silently.
|
||||
//
|
||||
// Nothing in this module describes *what* the guest runs. A command line, a
|
||||
// set of share tags, an output geometry, and what an exit means: that is the
|
||||
// whole vocabulary, and a field that only makes sense for one kind of workload
|
||||
// does not belong in it. ref(d-0033)
|
||||
// Nothing in this module describes *what* the guest runs. A set of share tags,
|
||||
// a command line, and what an exit means: that is the whole vocabulary, and a
|
||||
// field that only makes sense for one kind of workload does not belong in it.
|
||||
// ref(d-0033)
|
||||
//
|
||||
// # The box outlives what runs in it
|
||||
//
|
||||
// The guest init is resident: it mounts what the descriptor names, brings up
|
||||
// the box's service stack, says so, and then takes commands for as long as the
|
||||
// box lives. So the descriptor describes the *box* — which shares are mounted
|
||||
// where — and a command describes an occupant. A box may be launched into many
|
||||
// times. ref(d-0064)
|
||||
//
|
||||
// That is why every launch carries an id and every event about a launch carries
|
||||
// it back. Without one, a second launch's exit is indistinguishable from the
|
||||
// first's, which reads as an ended session that keeps billing or a running one
|
||||
// reported as stopped.
|
||||
//
|
||||
// # What is deliberately absent
|
||||
//
|
||||
// **Output geometry.** The compositor wraps the workload rather than running as
|
||||
// a service, so it is started by a launch with that launch's geometry in its own
|
||||
// argv, and the numbers appear nowhere else. Two sources of truth for one number
|
||||
// is a worse failure than either choice, because the wrong one is used silently.
|
||||
//
|
||||
// The channel also carries a second layer, which the guest relays as opaque
|
||||
// bytes and never parses. Those types land with the relay that needs them.
|
||||
@@ -34,7 +54,12 @@ pub const CONTROL_PORT: u32 = 7000;
|
||||
///
|
||||
/// Adding a variant or a field does not need a bump; removing or renaming one
|
||||
/// does.
|
||||
pub const CONTROL_VERSION: u32 = 2;
|
||||
///
|
||||
/// Version 3 took three fields off the descriptor and put an id on three
|
||||
/// messages, so a version-2 peer and a version-3 peer do not talk at all.
|
||||
/// There is deliberately no shim: nothing is deployed, and a shim would be the
|
||||
/// second definition of this wire that one shared crate exists to prevent.
|
||||
pub const CONTROL_VERSION: u32 = 3;
|
||||
|
||||
/// The command to run, and who runs it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -76,14 +101,32 @@ pub struct Mount {
|
||||
pub ro: bool,
|
||||
}
|
||||
|
||||
/// The output the compositor should produce.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Geometry {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
#[serde(default)]
|
||||
pub hdr: bool,
|
||||
/// Names one launch, for as long as anything has something to say about it.
|
||||
///
|
||||
/// Minted by the caller and only ever echoed by the guest. A guest that
|
||||
/// generated these would be naming things the caller then has to correlate
|
||||
/// against something else.
|
||||
///
|
||||
/// Opaque on purpose: nothing in this layer parses it, and a caller that wants
|
||||
/// meaning in it can put meaning in it without this crate having an opinion.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct LaunchId(pub String);
|
||||
|
||||
impl LaunchId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LaunchId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the workload exiting means for the session.
|
||||
@@ -95,21 +138,33 @@ pub struct OnExit {
|
||||
/// reports the exit and stops, and starting something again is a new
|
||||
/// command from the caller — the only end that can see whether restarting
|
||||
/// is repair or a loop. ref(d-0033)
|
||||
///
|
||||
/// It rides on the launch rather than on the descriptor, because a box that
|
||||
/// can be launched into repeatedly cannot have one answer to this fixed at
|
||||
/// boot. ref(d-0064)
|
||||
pub terminal: bool,
|
||||
}
|
||||
|
||||
/// Everything the guest is told at boot, in one document.
|
||||
/// What the box *is*, in one document: the shares it has and where they land.
|
||||
///
|
||||
/// Sent once, immediately after the handshake, and read once. Deliberately not
|
||||
/// a conversation: boot configuration is a document, and a document cannot
|
||||
/// half-arrive.
|
||||
/// a conversation: this much is a document, and a document cannot half-arrive.
|
||||
/// What runs *in* the box is a conversation, and a separate one — see
|
||||
/// [`HostToGuest::Launch`]. ref(d-0064)
|
||||
///
|
||||
/// An empty `mounts` is legitimate. A box with nothing mounted still boots and
|
||||
/// still brings up its services.
|
||||
///
|
||||
/// `deny_unknown_fields` is load-bearing rather than strictness for its own
|
||||
/// sake. A descriptor still carrying a command line is a caller that has not
|
||||
/// been updated, and the default behaviour — ignore what it does not recognise —
|
||||
/// would mount the shares, silently drop the command, and leave a box that came
|
||||
/// up correctly and runs nothing. Refusing it says so instead.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BootDescriptor {
|
||||
pub exec: Exec,
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<Mount>,
|
||||
pub geometry: Geometry,
|
||||
pub on_exit: OnExit,
|
||||
}
|
||||
|
||||
/// How a workload ended.
|
||||
@@ -145,6 +200,12 @@ impl Exit {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum GuestToHost {
|
||||
/// First line on the connection, before anything else is read or written.
|
||||
///
|
||||
/// **This is the handshake, not readiness.** It says a connection exists
|
||||
/// and both ends speak the same version. Whether anything in the box works
|
||||
/// is [`GuestToHost::Initialized`], which is a different fact and must not
|
||||
/// be merged with this one — a caller that treats this as readiness has a
|
||||
/// wait stage that succeeds before the guest has started anything.
|
||||
Ready { protocol_version: u32 },
|
||||
/// Every share the descriptor named is where it said to put it.
|
||||
Mounted,
|
||||
@@ -154,13 +215,45 @@ pub enum GuestToHost {
|
||||
/// looked at: a share that did not appear and a command that did not run
|
||||
/// are not the same incident.
|
||||
MountFailed { reason: String },
|
||||
/// The command the descriptor named is running.
|
||||
Started,
|
||||
/// The command could not be run, in the words the operating system used.
|
||||
StartFailed { reason: String },
|
||||
/// The workload the descriptor named has ended. Terminal or not is the
|
||||
/// descriptor's answer, not this message's.
|
||||
/// The box's own services are up and it will accept launches.
|
||||
///
|
||||
/// The one fact a caller waits on before it may launch anything. It names
|
||||
/// what came up, so a log says which — an empty list is a box with no
|
||||
/// service stack, which is legitimate and worth being able to see.
|
||||
Initialized {
|
||||
#[serde(default)]
|
||||
services: Vec<String>,
|
||||
},
|
||||
/// The box's services could not be brought up, in the words of whatever
|
||||
/// refused.
|
||||
///
|
||||
/// Kept separate from [`GuestToHost::MountFailed`] and
|
||||
/// [`GuestToHost::StartFailed`] for the reason those two are separate from
|
||||
/// each other: a share that did not appear, a box that could not be made,
|
||||
/// and a command that did not run are three incidents that want three
|
||||
/// different things looked at. A box in this state cannot be launched into
|
||||
/// at all, which is what distinguishes it from a refused launch.
|
||||
InitFailed { reason: String },
|
||||
/// A service in the box's own stack exited.
|
||||
///
|
||||
/// **Reported, never repaired.** Nothing else in the guest is watching
|
||||
/// these, so a death that is not said here is a box that looks healthy and
|
||||
/// cannot work. Restarting one is a decision for whoever can see whether
|
||||
/// restarting is repair or a loop, and that is not this end. ref(d-0064)
|
||||
ServiceDied {
|
||||
name: String,
|
||||
#[serde(flatten)]
|
||||
exit: Exit,
|
||||
},
|
||||
/// The launch with this id is running.
|
||||
Started { id: LaunchId },
|
||||
/// The launch with this id could not be run, in the words the operating
|
||||
/// system used.
|
||||
StartFailed { id: LaunchId, reason: String },
|
||||
/// The launch with this id has ended. Terminal or not is the launch's own
|
||||
/// answer, not this message's.
|
||||
WorkloadExited {
|
||||
id: LaunchId,
|
||||
#[serde(flatten)]
|
||||
exit: Exit,
|
||||
},
|
||||
@@ -182,8 +275,26 @@ pub enum HostToGuest {
|
||||
#[serde(flatten)]
|
||||
descriptor: Box<BootDescriptor>,
|
||||
},
|
||||
/// Stop the workload. Idempotent, and does not end the session.
|
||||
Stop,
|
||||
/// Run something in the box. Any number of times, after `initialized`.
|
||||
///
|
||||
/// The caller mints `id` and every message about this launch carries it
|
||||
/// back. `on_exit` belongs here rather than on the descriptor because a box
|
||||
/// that can be launched into repeatedly has one answer per launch, not one
|
||||
/// per boot. ref(d-0064)
|
||||
Launch {
|
||||
id: LaunchId,
|
||||
exec: Exec,
|
||||
on_exit: OnExit,
|
||||
},
|
||||
/// Stop one launch. Idempotent, and does not end the session.
|
||||
Stop { id: LaunchId },
|
||||
/// Stop one launch and start it again with the same command.
|
||||
///
|
||||
/// **Defined as a kill followed by a launch of the same `Exec`, and nothing
|
||||
/// more.** No retry, no backoff, no policy of any kind in the guest — it
|
||||
/// exists as one message only because a caller sending two has the same
|
||||
/// effect with a worse race in it. The relaunch keeps the id. ref(d-0064)
|
||||
Restart { id: LaunchId },
|
||||
/// Shut the guest down.
|
||||
Shutdown,
|
||||
/// Bytes for the workload, relayed. See [`Payload`].
|
||||
@@ -260,25 +371,21 @@ mod tests {
|
||||
|
||||
fn descriptor() -> BootDescriptor {
|
||||
BootDescriptor {
|
||||
exec: Exec {
|
||||
argv: vec!["/usr/bin/true".into()],
|
||||
env: BTreeMap::from([("HOME".to_string(), "/mnt/user".to_string())]),
|
||||
cwd: Some("/mnt/user".into()),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
},
|
||||
mounts: vec![Mount {
|
||||
tag: "install".into(),
|
||||
at: "/mnt/install".into(),
|
||||
ro: true,
|
||||
}],
|
||||
geometry: Geometry {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
hdr: false,
|
||||
},
|
||||
on_exit: OnExit { terminal: true },
|
||||
}
|
||||
}
|
||||
|
||||
fn exec() -> Exec {
|
||||
Exec {
|
||||
argv: vec!["/usr/bin/true".into()],
|
||||
env: BTreeMap::from([("HOME".to_string(), "/mnt/user".to_string())]),
|
||||
cwd: Some("/mnt/user".into()),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,9 +407,122 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_round_trips_with_its_id() {
|
||||
let launch = HostToGuest::Launch {
|
||||
id: LaunchId::new("l-1"),
|
||||
exec: exec(),
|
||||
on_exit: OnExit { terminal: true },
|
||||
};
|
||||
let back: HostToGuest = from_line(&to_line(&launch).unwrap()).unwrap();
|
||||
assert_eq!(back, launch);
|
||||
}
|
||||
|
||||
/// The reason ids exist: a caller has to be able to tell which launch it is
|
||||
/// being told about, or the second one's exit overwrites the first's record.
|
||||
#[test]
|
||||
fn every_event_about_a_launch_carries_the_launch_it_is_about() {
|
||||
let first = LaunchId::new("l-1");
|
||||
let second = LaunchId::new("l-2");
|
||||
|
||||
let events = [
|
||||
GuestToHost::Started { id: first.clone() },
|
||||
GuestToHost::StartFailed {
|
||||
id: first.clone(),
|
||||
reason: "ENOENT".into(),
|
||||
},
|
||||
GuestToHost::WorkloadExited {
|
||||
id: first.clone(),
|
||||
exit: Exit::code(0),
|
||||
},
|
||||
];
|
||||
|
||||
for event in events {
|
||||
let line = to_line(&event).unwrap();
|
||||
assert!(
|
||||
line.contains(first.as_str()) && !line.contains(second.as_str()),
|
||||
"an event does not say which launch it is about: {line}"
|
||||
);
|
||||
let back: GuestToHost = from_line(&line).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
}
|
||||
|
||||
/// The descriptor describes the box. A caller still sending a command in it
|
||||
/// has not been updated, and the cost of accepting one quietly is a box that
|
||||
/// mounts, comes up, and runs nothing.
|
||||
#[test]
|
||||
fn a_descriptor_carrying_a_command_is_refused_rather_than_ignored() {
|
||||
let stale = r#"{"exec":{"argv":["/bin/sh"],"uid":1000,"gid":1000},
|
||||
"mounts":[],
|
||||
"geometry":{"width":1280,"height":720,"fps":60},
|
||||
"on_exit":{"terminal":true}}"#;
|
||||
let parsed: Result<BootDescriptor, _> = from_line(stale);
|
||||
assert!(
|
||||
parsed.is_err(),
|
||||
"a descriptor with a command in it parsed: {parsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Geometry is in the launched argv and nowhere else, so there is no field
|
||||
/// here for it to disagree with.
|
||||
#[test]
|
||||
fn geometry_is_not_on_this_layer() {
|
||||
let line = to_line(&HostToGuest::Boot {
|
||||
descriptor: Box::new(descriptor()),
|
||||
})
|
||||
.unwrap();
|
||||
for named in ["width", "height", "fps", "hdr", "geometry"] {
|
||||
assert!(
|
||||
!line.contains(named),
|
||||
"the descriptor names {named}, which belongs in the launch: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A dead service is not a dead workload: they are different incidents and
|
||||
/// want different things looked at.
|
||||
#[test]
|
||||
fn a_dead_service_says_which_one_and_how() {
|
||||
let died = GuestToHost::ServiceDied {
|
||||
name: "pipewire".into(),
|
||||
exit: Exit::signal(9),
|
||||
};
|
||||
let line = to_line(&died).unwrap();
|
||||
assert!(line.contains("pipewire"), "no service name: {line}");
|
||||
assert!(
|
||||
!line.contains("exit_code"),
|
||||
"a signalled service has no exit code: {line}"
|
||||
);
|
||||
let back: GuestToHost = from_line(&line).unwrap();
|
||||
assert_eq!(back, died);
|
||||
}
|
||||
|
||||
/// `ready` is the handshake and `initialized` is the box working. A caller
|
||||
/// waiting on the wrong one succeeds before anything has started.
|
||||
#[test]
|
||||
fn readiness_and_initialisation_are_two_messages() {
|
||||
let ready = to_line(&GuestToHost::Ready {
|
||||
protocol_version: CONTROL_VERSION,
|
||||
})
|
||||
.unwrap();
|
||||
let initialized = to_line(&GuestToHost::Initialized {
|
||||
services: vec!["dbus".into(), "pipewire".into()],
|
||||
})
|
||||
.unwrap();
|
||||
assert_ne!(ready, initialized);
|
||||
|
||||
// An empty stack is legitimate and has to survive the round trip, or a
|
||||
// box with no services looks like a box that never came up.
|
||||
let empty = GuestToHost::Initialized { services: vec![] };
|
||||
let back: GuestToHost = from_line(&to_line(&empty).unwrap()).unwrap();
|
||||
assert_eq!(back, empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signalled_exit_is_not_a_zero_exit() {
|
||||
let signalled = to_line(&GuestToHost::WorkloadExited {
|
||||
id: LaunchId::new("l-1"),
|
||||
exit: Exit::signal(9),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -312,6 +532,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let clean = to_line(&GuestToHost::WorkloadExited {
|
||||
id: LaunchId::new("l-1"),
|
||||
exit: Exit::code(0),
|
||||
})
|
||||
.unwrap();
|
||||
@@ -381,13 +602,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn defaults_cover_what_a_caller_may_leave_out() {
|
||||
let json = r#"{"exec":{"argv":["/bin/sh"],"uid":1000,"gid":1000},
|
||||
"geometry":{"width":1280,"height":720,"fps":30},
|
||||
"on_exit":{"terminal":false}}"#;
|
||||
let parsed: BootDescriptor = from_line(json).unwrap();
|
||||
// A box with nothing mounted is a legitimate box.
|
||||
let parsed: BootDescriptor = from_line("{}").unwrap();
|
||||
assert!(parsed.mounts.is_empty());
|
||||
assert!(parsed.exec.env.is_empty());
|
||||
assert_eq!(parsed.exec.cwd, None);
|
||||
assert!(!parsed.geometry.hdr);
|
||||
|
||||
let launch = r#"{"type":"launch","id":"l-1",
|
||||
"exec":{"argv":["/bin/sh"],"uid":1000,"gid":1000},
|
||||
"on_exit":{"terminal":false}}"#;
|
||||
let HostToGuest::Launch { id, exec, on_exit } = from_line(launch).unwrap() else {
|
||||
panic!("not a launch: {launch}")
|
||||
};
|
||||
assert_eq!(id, LaunchId::new("l-1"));
|
||||
assert!(exec.env.is_empty());
|
||||
assert_eq!(exec.cwd, None);
|
||||
assert!(!on_exit.terminal);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user