chore: bump sdk to beta.54, add device-info RPC, improve SDK abort handling and InputSpec filtering

- Bump SDK version to 0.4.0-beta.54
- Add `server.device-info` RPC endpoint and `s9pk select` CLI command
- Extract `HardwareRequirements::is_compatible()` method, reuse in registry filtering
- Add `AbortedError` class with `muteUnhandled` flag, replace generic abort errors
- Handle unhandled promise rejections in container-runtime with mute support
- Improve `InputSpec.filter()` with `keepByDefault` param and boolean filter values
- Accept readonly tuples in `CommandType` and `splitCommand`
- Remove `sync_host` calls from host API handlers (binding/address changes)
- Filter mDNS hostnames by secure gateway availability
- Derive mDNS enabled state from LAN IPs in web UI
- Add "Open UI" action to address table, disable mDNS toggle
- Hide debug details in service error component
- Update rpc-toolkit docs for no-params handlers
This commit is contained in:
Aiden McClelland
2026-02-26 14:08:33 -07:00
parent 7f66c62848
commit d422cd3c66
33 changed files with 379 additions and 117 deletions

View File

@@ -21,6 +21,14 @@ pub async fn my_handler(ctx: RpcContext, params: MyParams) -> Result<MyResponse,
from_fn_async(my_handler)
```
If a handler takes no params, simply omit the params argument entirely (no need for `_: Empty`):
```rust
pub async fn no_params_handler(ctx: RpcContext) -> Result<MyResponse, Error> {
// ...
}
```
### `from_fn_async_local` - Non-thread-safe async handlers
For async functions that are not `Send` (cannot be safely moved between threads). Use when working with non-thread-safe types.
@@ -181,9 +189,9 @@ pub struct MyParams {
### Adding a New RPC Endpoint
1. Define params struct with `Deserialize, Serialize, Parser, TS`
1. Define params struct with `Deserialize, Serialize, Parser, TS` (skip if no params needed)
2. Choose handler type based on sync/async and thread-safety
3. Write handler function taking `(Context, Params) -> Result<Response, Error>`
3. Write handler function taking `(Context, Params) -> Result<Response, Error>` (omit Params if none needed)
4. Add to parent handler with appropriate extensions (display modifiers before `with_about`)
5. TypeScript types auto-generated via `make ts-bindings`

View File

@@ -268,6 +268,18 @@ pub fn server<C: Context>() -> ParentHandler<C> {
.with_about("about.display-time-uptime")
.with_call_remote::<CliContext>(),
)
.subcommand(
"device-info",
ParentHandler::<C, WithIoFormat<Empty>>::new().root_handler(
from_fn_async(system::device_info)
.with_display_serializable()
.with_custom_display_fn(|handle, result| {
system::display_device_info(handle.params, result)
})
.with_about("about.get-device-info")
.with_call_remote::<CliContext>(),
),
)
.subcommand(
"experimental",
system::experimental::<C>().with_about("about.commands-experimental"),

View File

@@ -20,9 +20,6 @@ use crate::context::RpcContext;
use crate::middleware::auth::DbContext;
use crate::prelude::*;
use crate::rpc_continuations::OpenAuthedContinuations;
use crate::util::Invoke;
use crate::util::io::{create_file_mod, read_file_to_string};
use crate::util::serde::{BASE64, const_true};
use crate::util::sync::SyncMutex;
pub trait SessionAuthContext: DbContext {

View File

@@ -204,7 +204,6 @@ pub async fn add_public_domain<Kind: HostApiKind>(
})
.await
.result?;
Kind::sync_host(&ctx, inheritance).await?;
tokio::task::spawn_blocking(|| {
crate::net::dns::query_dns(ctx, crate::net::dns::QueryDnsParams { fqdn })
@@ -242,7 +241,6 @@ pub async fn remove_public_domain<Kind: HostApiKind>(
})
.await
.result?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
@@ -279,7 +277,6 @@ pub async fn add_private_domain<Kind: HostApiKind>(
})
.await
.result?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
@@ -306,7 +303,6 @@ pub async fn remove_private_domain<Kind: HostApiKind>(
})
.await
.result?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}

View File

@@ -358,5 +358,5 @@ pub async fn set_address_enabled<Kind: HostApiKind>(
})
.await
.result?;
Kind::sync_host(&ctx, inheritance).await
Ok(())
}

View File

@@ -1,5 +1,4 @@
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::net::{IpAddr, SocketAddrV4};
use std::panic::RefUnwindSafe;
@@ -182,15 +181,26 @@ impl Model<Host> {
opt.secure
.map_or(true, |s| !(s.ssl && opt.add_ssl.is_some()))
}) {
available.insert(HostnameInfo {
ssl: opt.secure.map_or(false, |s| s.ssl),
public: false,
hostname: mdns_host.clone(),
port: Some(port),
metadata: HostnameMetadata::Mdns {
gateways: mdns_gateways.clone(),
},
});
let mdns_gateways = if opt.secure.is_some() {
mdns_gateways.clone()
} else {
mdns_gateways
.iter()
.filter(|g| gateways.get(*g).map_or(false, |g| g.secure()))
.cloned()
.collect()
};
if !mdns_gateways.is_empty() {
available.insert(HostnameInfo {
ssl: opt.secure.map_or(false, |s| s.ssl),
public: false,
hostname: mdns_host.clone(),
port: Some(port),
metadata: HostnameMetadata::Mdns {
gateways: mdns_gateways,
},
});
}
}
if let Some(port) = net.assigned_ssl_port {
available.insert(HostnameInfo {
@@ -429,10 +439,6 @@ pub trait HostApiKind: 'static {
inheritance: &Self::Inheritance,
db: &'a mut DatabaseModel,
) -> Result<&'a mut Model<Host>, Error>;
fn sync_host(
ctx: &RpcContext,
inheritance: Self::Inheritance,
) -> impl Future<Output = Result<(), Error>> + Send;
}
pub struct ForPackage;
impl HostApiKind for ForPackage {
@@ -451,12 +457,6 @@ impl HostApiKind for ForPackage {
) -> Result<&'a mut Model<Host>, Error> {
host_for(db, Some(package), host)
}
async fn sync_host(ctx: &RpcContext, (package, host): Self::Inheritance) -> Result<(), Error> {
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.sync_host(host).await?;
Ok(())
}
}
pub struct ForServer;
impl HostApiKind for ForServer {
@@ -472,9 +472,6 @@ impl HostApiKind for ForServer {
) -> Result<&'a mut Model<Host>, Error> {
host_for(db, None, &HostId::default())
}
async fn sync_host(ctx: &RpcContext, _: Self::Inheritance) -> Result<(), Error> {
ctx.os_net_service.sync_host(HostId::default()).await
}
}
pub fn host_api<C: Context>() -> ParentHandler<C, RequiresPackageId> {

View File

@@ -725,13 +725,6 @@ impl NetService {
.result
}
pub async fn sync_host(&self, _id: HostId) -> Result<(), Error> {
let current = self.synced.peek(|v| *v);
let mut w = self.synced.clone();
w.wait_for(|v| *v > current).await;
Ok(())
}
pub async fn remove_all(mut self) -> Result<(), Error> {
if Weak::upgrade(&self.data.lock().await.controller).is_none() {
self.shutdown = true;

View File

@@ -9,7 +9,7 @@ use async_compression::tokio::bufread::GzipEncoder;
use axum::Router;
use axum::body::Body;
use axum::extract::{self as x, Request};
use axum::response::{IntoResponse, Response};
use axum::response::Response;
use axum::routing::{any, get};
use base64::display::Base64Display;
use digest::Digest;

View File

@@ -255,30 +255,7 @@ impl Model<PackageVersionInfo> {
}
if let Some(hw) = &device_info.hardware {
self.as_s9pks_mut().mutate(|s9pks| {
s9pks.retain(|(hw_req, _)| {
if let Some(arch) = &hw_req.arch {
if !arch.contains(&hw.arch) {
return false;
}
}
if let Some(ram) = hw_req.ram {
if hw.ram < ram {
return false;
}
}
if let Some(dev) = &hw.devices {
for device_filter in &hw_req.device {
if !dev
.iter()
.filter(|d| d.class() == &*device_filter.class)
.any(|d| device_filter.matches(d))
{
return false;
}
}
}
true
});
s9pks.retain(|(hw_req, _)| hw_req.is_compatible(hw));
if hw.devices.is_some() {
s9pks.sort_by_key(|(req, _)| req.specificity_desc());
} else {

View File

@@ -3,16 +3,17 @@ use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use rpc_toolkit::{Empty, HandlerExt, ParentHandler, from_fn_async};
use rpc_toolkit::{Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use ts_rs::TS;
use url::Url;
use crate::ImageId;
use crate::context::CliContext;
use crate::context::{CliContext, RpcContext};
use crate::prelude::*;
use crate::s9pk::manifest::Manifest;
use crate::registry::device_info::DeviceInfo;
use crate::s9pk::manifest::{HardwareRequirements, Manifest};
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
use crate::s9pk::v2::SIG_CONTEXT;
use crate::s9pk::v2::pack::ImageConfig;
@@ -70,6 +71,15 @@ pub fn s9pk() -> ParentHandler<CliContext> {
.no_display()
.with_about("about.publish-s9pk"),
)
.subcommand(
"select",
from_fn_async(select)
.with_custom_display_fn(|_, path: PathBuf| {
println!("{}", path.display());
Ok(())
})
.with_about("about.select-s9pk-for-device"),
)
}
#[derive(Deserialize, Serialize, Parser)]
@@ -323,3 +333,93 @@ async fn publish(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Res
.await?;
crate::registry::package::add::cli_add_package_impl(ctx, s9pk, vec![s3url], false).await
}
#[derive(Deserialize, Serialize, Parser)]
struct SelectParams {
#[arg(help = "help.arg.s9pk-file-paths")]
s9pks: Vec<PathBuf>,
}
async fn select(
HandlerArgs {
context,
params: SelectParams { s9pks },
..
}: HandlerArgs<CliContext, SelectParams>,
) -> Result<PathBuf, Error> {
// Resolve file list: use provided paths or scan cwd for *.s9pk
let paths = if s9pks.is_empty() {
let mut found = Vec::new();
let mut entries = tokio::fs::read_dir(".").await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("s9pk") {
found.push(path);
}
}
if found.is_empty() {
return Err(Error::new(
eyre!("no .s9pk files found in current directory"),
ErrorKind::NotFound,
));
}
found
} else {
s9pks
};
// Fetch DeviceInfo from the target server
let device_info: DeviceInfo = from_value(
context
.call_remote::<RpcContext>("server.device-info", imbl_value::json!({}))
.await?,
)?;
// Filter and rank s9pk files by compatibility
let mut compatible: Vec<(PathBuf, HardwareRequirements)> = Vec::new();
for path in &paths {
let s9pk = match super::S9pk::open(path, None).await {
Ok(s9pk) => s9pk,
Err(e) => {
tracing::warn!("skipping {}: {e}", path.display());
continue;
}
};
let manifest = s9pk.as_manifest();
// OS version check: package's required OS version must be in server's compat range
if !manifest.metadata.os_version.satisfies(&device_info.os.compat) {
continue;
}
let hw_req = &manifest.hardware_requirements;
if let Some(hw) = &device_info.hardware {
if !hw_req.is_compatible(hw) {
continue;
}
}
compatible.push((path.clone(), hw_req.clone()));
}
if compatible.is_empty() {
return Err(Error::new(
eyre!(
"no compatible s9pk found for device (arch: {}, os: {})",
device_info
.hardware
.as_ref()
.map(|h| h.arch.to_string())
.unwrap_or_else(|| "unknown".into()),
device_info.os.version,
),
ErrorKind::NotFound,
));
}
// Sort by specificity (most specific first)
compatible.sort_by_key(|(_, req)| req.specificity_desc());
Ok(compatible.into_iter().next().unwrap().0)
}

View File

@@ -154,6 +154,32 @@ pub struct HardwareRequirements {
pub arch: Option<BTreeSet<InternedString>>,
}
impl HardwareRequirements {
/// Returns true if this s9pk's hardware requirements are satisfied by the given hardware.
pub fn is_compatible(&self, hw: &crate::registry::device_info::HardwareInfo) -> bool {
if let Some(arch) = &self.arch {
if !arch.contains(&hw.arch) {
return false;
}
}
if let Some(ram) = self.ram {
if hw.ram < ram {
return false;
}
}
if let Some(devices) = &hw.devices {
for device_filter in &self.device {
if !devices
.iter()
.filter(|d| d.class() == &*device_filter.class)
.any(|d| device_filter.matches(d))
{
return false;
}
}
}
true
}
/// returns a value that can be used as a sort key to get most specific requirements first
pub fn specificity_desc(&self) -> (u32, u32, u64) {
(

View File

@@ -52,7 +52,7 @@ use crate::util::serde::Pem;
use crate::util::sync::SyncMutex;
use crate::util::tui::choose;
use crate::volume::data_dir;
use crate::{ActionId, CAP_1_KiB, DATA_DIR, HostId, ImageId, PackageId};
use crate::{ActionId, CAP_1_KiB, DATA_DIR, ImageId, PackageId};
pub mod action;
pub mod cli;
@@ -683,14 +683,6 @@ impl Service {
memory_usage: MiB::from_MiB(used),
})
}
pub async fn sync_host(&self, host_id: HostId) -> Result<(), Error> {
self.seed
.persistent_container
.net_service
.sync_host(host_id)
.await
}
}
struct ServiceActorSeed {

View File

@@ -20,6 +20,7 @@ use crate::context::{CliContext, RpcContext};
use crate::disk::util::{get_available, get_used};
use crate::logs::{LogSource, LogsParams, SYSTEM_UNIT};
use crate::prelude::*;
use crate::registry::device_info::DeviceInfo;
use crate::rpc_continuations::{Guid, RpcContinuation, RpcContinuations};
use crate::shutdown::Shutdown;
use crate::util::Invoke;
@@ -249,6 +250,67 @@ pub async fn time(ctx: RpcContext, _: Empty) -> Result<TimeInfo, Error> {
})
}
pub async fn device_info(ctx: RpcContext) -> Result<DeviceInfo, Error> {
DeviceInfo::load(&ctx).await
}
pub fn display_device_info(
params: WithIoFormat<Empty>,
info: DeviceInfo,
) -> Result<(), Error> {
use prettytable::*;
if let Some(format) = params.format {
return display_serializable(format, info);
}
let mut table = Table::new();
table.add_row(row![br -> "PLATFORM", &*info.os.platform]);
table.add_row(row![br -> "OS VERSION", info.os.version.to_string()]);
table.add_row(row![br -> "OS COMPAT", info.os.compat.to_string()]);
if let Some(lang) = &info.os.language {
table.add_row(row![br -> "LANGUAGE", &**lang]);
}
if let Some(hw) = &info.hardware {
table.add_row(row![br -> "ARCH", &*hw.arch]);
table.add_row(row![br -> "RAM", format_ram(hw.ram)]);
if let Some(devices) = &hw.devices {
for dev in devices {
let (class, desc) = match dev {
crate::util::lshw::LshwDevice::Processor(p) => (
"PROCESSOR",
p.product.as_deref().unwrap_or("unknown").to_string(),
),
crate::util::lshw::LshwDevice::Display(d) => (
"DISPLAY",
format!(
"{}{}",
d.product.as_deref().unwrap_or("unknown"),
d.driver
.as_deref()
.map(|drv| format!(" ({})", drv))
.unwrap_or_default()
),
),
};
table.add_row(row![br -> class, desc]);
}
}
}
table.print_tty(false)?;
Ok(())
}
fn format_ram(bytes: u64) -> String {
const GIB: u64 = 1024 * 1024 * 1024;
const MIB: u64 = 1024 * 1024;
if bytes >= GIB {
format!("{:.1} GiB", bytes as f64 / GIB as f64)
} else {
format!("{:.1} MiB", bytes as f64 / MIB as f64)
}
}
pub fn logs<C: Context + AsRef<RpcContinuations>>() -> ParentHandler<C, LogsParams> {
crate::logs::logs(|_: &C, _| async { Ok(LogSource::Unit(SYSTEM_UNIT)) })
}