wash_runtime/host/mod.rs
1//! Host runtime for managing WebAssembly workloads and plugins.
2//!
3//! The host module provides the runtime environment for executing WebAssembly
4//! workloads. It manages the lifecycle of components, coordinates with plugins
5//! to provide capabilities, and handles system resources.
6//!
7//! # Key Components
8//!
9//! - [`Host`] - The main runtime that manages workloads and plugins
10//! - [`HostBuilder`] - Builder for configuring host settings
11//! - [`HostApi`] - Trait defining the host's external API
12//! - [`HostWorkload`] - Internal representation of workload states
13//!
14//! # Architecture
15//!
16//! The host acts as the central coordinator between:
17//! - WebAssembly components that need execution
18//! - Plugins that provide WASI and other capabilities
19//! - System resources like networking and storage
20//! - External consumers through the HostApi
21//!
22//! # Example
23//!
24//! ```no_run
25//! use wash_runtime::host::{HostBuilder, HostApi};
26//! use wash_runtime::engine::Engine;
27//! use std::sync::Arc;
28//!
29//! # async fn example() -> anyhow::Result<()> {
30//! let engine = Engine::builder().build()?;
31//! let host = HostBuilder::new()
32//! .with_engine(engine)
33//! .with_friendly_name("my-host")
34//! .build()?;
35//!
36//! let host = host.start().await?;
37//! let heartbeat = host.heartbeat().await?;
38//! println!("Host {} is running", heartbeat.friendly_name);
39//! # Ok(())
40//! # }
41//! ```
42
43use std::collections::{HashMap, HashSet};
44use std::future::Future;
45use std::sync::Arc;
46
47use anyhow::{Context, bail};
48use names::{Generator, Name};
49use tokio::sync::RwLock;
50use tracing::{debug, trace, warn};
51
52use crate::engine::Engine;
53use crate::engine::workload::ResolvedWorkload;
54use crate::plugin::HostPlugin;
55use crate::types::*;
56use crate::wit::WitWorld;
57
58mod sysinfo;
59use sysinfo::SystemMonitor;
60
61/// The API for interacting with a wasmcloud host.
62///
63/// This trait defines the core operations for managing workloads on a host,
64/// including starting, stopping, and querying workload status, as well as
65/// retrieving host health information.
66pub trait HostApi {
67 /// Request a heartbeat containing the host's current state and system information.
68 ///
69 /// # Returns
70 /// A `HostHeartbeat` containing system metrics, version info, and capability information.
71 ///
72 /// # Errors
73 /// Returns an error if system information cannot be retrieved.
74 fn heartbeat(&self) -> impl Future<Output = anyhow::Result<HostHeartbeat>>;
75 /// Start a new workload on this host.
76 ///
77 /// # Arguments
78 /// * `request` - Contains the workload configuration to start
79 ///
80 /// # Returns
81 /// A `WorkloadStartResponse` with the status of the started workload.
82 ///
83 /// # Errors
84 /// Returns an error if the workload fails to start or validate.
85 fn workload_start(
86 &self,
87 request: WorkloadStartRequest,
88 ) -> impl Future<Output = anyhow::Result<WorkloadStartResponse>>;
89 /// Query the status of a running workload.
90 ///
91 /// # Arguments
92 /// * `request` - Contains the workload ID to query
93 ///
94 /// # Returns
95 /// A `WorkloadStatusResponse` with the current state of the workload.
96 ///
97 /// # Errors
98 /// Returns an error if the workload is not found.
99 fn workload_status(
100 &self,
101 request: WorkloadStatusRequest,
102 ) -> impl Future<Output = anyhow::Result<WorkloadStatusResponse>>;
103 /// Stop a running workload on this host.
104 ///
105 /// # Arguments
106 /// * `request` - Contains the workload ID to stop
107 ///
108 /// # Returns
109 /// A `WorkloadStopResponse` with the final status of the stopped workload.
110 ///
111 /// # Errors
112 /// Returns an error if the workload cannot be stopped or is not found.
113 fn workload_stop(
114 &self,
115 request: WorkloadStopRequest,
116 ) -> impl Future<Output = anyhow::Result<WorkloadStopResponse>>;
117}
118
119// Helper trait impl that helps with Arc-ing the Host
120impl<T: HostApi> HostApi for Arc<T> {
121 async fn heartbeat(&self) -> anyhow::Result<HostHeartbeat> {
122 self.as_ref().heartbeat().await
123 }
124 async fn workload_start(
125 &self,
126 request: WorkloadStartRequest,
127 ) -> anyhow::Result<WorkloadStartResponse> {
128 self.as_ref().workload_start(request).await
129 }
130 async fn workload_stop(
131 &self,
132 request: WorkloadStopRequest,
133 ) -> anyhow::Result<WorkloadStopResponse> {
134 self.as_ref().workload_stop(request).await
135 }
136 async fn workload_status(
137 &self,
138 request: WorkloadStatusRequest,
139 ) -> anyhow::Result<WorkloadStatusResponse> {
140 self.as_ref().workload_status(request).await
141 }
142}
143
144/// Internal representation of a workload's state within the host.
145///
146/// This enum tracks the lifecycle stages of a workload from starting
147/// through running to stopping or error states.
148#[derive(Debug, Clone)]
149pub enum HostWorkload {
150 Starting,
151 // Boxed to reduce size of the enum
152 Running(Box<ResolvedWorkload>),
153 Stopping,
154 Error,
155}
156
157impl From<&HostWorkload> for WorkloadState {
158 fn from(hw: &HostWorkload) -> Self {
159 match hw {
160 HostWorkload::Starting => WorkloadState::Starting,
161 HostWorkload::Running(_) => WorkloadState::Running,
162 HostWorkload::Stopping => WorkloadState::Stopping,
163 HostWorkload::Error => WorkloadState::Error,
164 }
165 }
166}
167
168/// A wasmcloud host that manages WebAssembly workloads and plugins.
169///
170/// The `Host` is the primary runtime for executing workloads. It manages:
171/// - An engine for compiling and running WebAssembly components
172/// - A collection of workloads and their states
173/// - Plugins that extend host functionality
174/// - System monitoring and resource tracking
175pub struct Host {
176 engine: Engine,
177 /// Workloads mapped from ID to the workload and its current state
178 workloads: Arc<RwLock<HashMap<String, HostWorkload>>>,
179 /// Plugins in a map from their ID to the plugin itself
180 plugins: HashMap<&'static str, Arc<dyn HostPlugin>>,
181 /// Host metadata
182 id: String,
183 hostname: String,
184 friendly_name: String,
185 version: String,
186 labels: HashMap<String, String>,
187 started_at: chrono::DateTime<chrono::Utc>,
188 /// System monitor for tracking CPU/memory usage
189 system_monitor: Arc<RwLock<SystemMonitor>>,
190 // endpoints: HashMap<String, EndpointConfiguration>
191}
192
193impl Host {
194 /// Create a new builder for the host.
195 pub fn builder() -> HostBuilder {
196 HostBuilder::default()
197 }
198
199 /// Start the host and initialize all plugins.
200 ///
201 /// This method must be called before the host can accept workloads.
202 /// It starts all registered plugins and prepares the host for operation.
203 ///
204 /// # Returns
205 /// An `Arc` wrapped host ready to accept workloads.
206 ///
207 /// # Errors
208 /// Returns an error if any plugin fails to start.
209 pub async fn start(self) -> anyhow::Result<Arc<Self>> {
210 // Start all plugins, any errors means the host fails to start.
211 for (id, plugin) in &self.plugins {
212 if let Err(e) = plugin.start().await {
213 tracing::error!(id = id, err = ?e, "failed to start plugin");
214 bail!(e)
215 }
216 }
217
218 Ok(Arc::new(self))
219 }
220
221 /// Stop the host and shut down all plugins.
222 ///
223 /// Attempts to gracefully stop all plugins with a 3-second timeout
224 /// for each. Errors are logged but don't prevent other plugins from
225 /// being stopped.
226 ///
227 /// # Returns
228 /// Ok if the shutdown process completes (even with plugin errors).
229 pub async fn stop(self: Arc<Self>) -> anyhow::Result<()> {
230 // Stop all plugins, log errors but continue stopping others
231 for (id, plugin) in &self.plugins {
232 let stop_fut = plugin.stop();
233 match tokio::time::timeout(std::time::Duration::from_secs(3), stop_fut).await {
234 Ok(Err(e)) => {
235 tracing::error!(id = id, err = ?e, "failed to stop plugin");
236 }
237 Err(_) => {
238 tracing::error!(id = id, "plugin stop timed out after 3 seconds");
239 }
240 _ => {}
241 }
242 }
243
244 Ok(())
245 }
246
247 /// Get a label value by key.
248 ///
249 /// # Arguments
250 /// * `label` - The label key to look up
251 ///
252 /// # Returns
253 /// The label value if it exists, None otherwise.
254 pub fn label(&self, label: impl AsRef<str>) -> Option<&String> {
255 self.labels.get(label.as_ref())
256 }
257
258 /// Get the unique identifier for this host.
259 ///
260 /// # Returns
261 /// The host's unique ID string.
262 pub fn id(&self) -> &str {
263 &self.id
264 }
265
266 /// Get the human-readable name for this host.
267 ///
268 /// # Returns
269 /// The host's friendly name string.
270 pub fn friendly_name(&self) -> &str {
271 &self.friendly_name
272 }
273
274 /// Helper function to generate a unique ID for a workload
275 fn generate_workload_id(&self) -> String {
276 uuid::Uuid::new_v4().to_string()
277 }
278
279 /// Returns the WIT (imports, exports) that this host can provide to any component.
280 ///
281 /// Put another way, this represents a simplified version of the host world. For
282 /// example, this WIT world:
283 /// ```wit
284 /// package wasmcloud:host@0.1.0;
285 ///
286 /// interface foo {
287 /// ...
288 /// }
289 /// interface bar {
290 /// ...
291 /// }
292 ///
293 /// world host {
294 /// import foo;
295 /// export bar;
296 /// }
297 /// ```
298 ///
299 /// Would be returned as:
300 /// (
301 /// vec![WitInterface { namespace: "wasmcloud", package: "host", interfaces: ["foo"], version: Some("0.1.0") }],
302 /// vec![WitInterface { namespace: "wasmcloud", package: "host", interfaces: ["bar"], version: Some("0.1.0") }],
303 /// )
304 ///
305 /// This can be viewed as an inversion of the worlds that this host can support. In the above example,
306 /// this host can support any component that imports `bar` and exports `foo`. Other exports will be ignored,
307 /// and other imports that are unsatisfied will be rejected.
308 pub fn wit_world(&self) -> WitWorld {
309 let mut imports = HashSet::new();
310 // The host provides wasi@0.2 interfaces other than wasi:http
311 // <https://docs.rs/wasmtime-wasi/36.0.2/wasmtime_wasi/p2/index.html#wasip2-interfaces>
312 let mut exports = HashSet::from([
313 "wasi:io/poll,error,streams@0.2.0".into(),
314 "wasi:clocks/monotonic-clock,wall-time@0.2.0".into(),
315 "wasi:random/random@0.2.0".into(),
316 "wasi:cli/environment,exit,stderr,stdin,stdout,terminal-input,terminal-output,terminal-stderr,terminal-stdin,terminal-stdout@0.2.0".into(),
317 "wasi:clocks/monotonic-clock,wall-clock@0.2.0".into(),
318 "wasi:filesystem/preopens,types@0.2.0".into(),
319 "wasi:random/insecure-seed,insecure,random@0.2.0".into(),
320 "wasi:sockets/instance-network,ip-name-lookup,network,tcp-create-socket,tcp,udp-create-socket,udp@0.2.0".into(),
321 ]);
322
323 // Include imports and exports that plugins specify
324 imports.extend(
325 self.plugins
326 .values()
327 .flat_map(|p| p.world().imports.into_iter().collect::<Vec<_>>()),
328 );
329 exports.extend(
330 self.plugins
331 .values()
332 .flat_map(|p| p.world().exports.into_iter().collect::<Vec<_>>()),
333 );
334
335 WitWorld { imports, exports }
336 }
337
338 /// Returns a three-tuple of (OS architecture, OS name, OS kernel)
339 async fn get_system_info(&self) -> (String, String, String) {
340 // Get OS information
341 let os_name = std::env::consts::OS.to_string();
342 let os_arch = std::env::consts::ARCH.to_string();
343 let os_kernel = std::env::consts::FAMILY.to_string();
344 (os_arch, os_name, os_kernel)
345 }
346
347 /// Returns a tuple of (total memory, free memory)
348 async fn get_memory_info(&self) -> anyhow::Result<(u64, u64)> {
349 let monitor = self.system_monitor.read().await;
350 let mem = monitor.memory_usage();
351 Ok((mem.total_memory, mem.free_memory))
352 }
353
354 /// Returns the current global CPU usage as a percentage
355 async fn get_cpu_usage(&self) -> anyhow::Result<f32> {
356 let monitor = self.system_monitor.read().await;
357 Ok(monitor.cpu_usage().global_usage)
358 }
359}
360
361impl HostApi for Host {
362 async fn heartbeat(&self) -> anyhow::Result<HostHeartbeat> {
363 // Refresh system info before reporting
364 {
365 let mut monitor = self.system_monitor.write().await;
366 monitor.refresh();
367 monitor.report_usage();
368 }
369
370 let (os_arch, os_name, os_kernel) = self.get_system_info().await;
371 let (system_memory_total, system_memory_free) = self
372 .get_memory_info()
373 .await
374 .context("failed to get memory info")?;
375 let system_cpu_usage = self
376 .get_cpu_usage()
377 .await
378 .context("failed to get CPU usage")?;
379
380 // Count components and providers from workloads
381 let (workload_count, component_count) = {
382 let workloads = self.workloads.read().await;
383 let workload_count: u64 = workloads.len() as u64;
384 let mut component_count: u64 = 0;
385 for workload in workloads.values() {
386 if let HostWorkload::Running(workload) = workload {
387 component_count += workload.component_count().await as u64;
388 }
389 }
390 (workload_count, component_count)
391 };
392
393 // Collect all imports and exports from the host and plugins
394 let mut imports = Vec::new();
395 let mut exports = Vec::new();
396
397 for plugin in self.plugins.values() {
398 let world = plugin.world();
399 imports.extend(world.imports.into_iter());
400 exports.extend(world.exports.into_iter());
401 }
402
403 Ok(HostHeartbeat {
404 id: self.id.clone(),
405 hostname: self.hostname.clone(),
406 friendly_name: self.friendly_name.clone(),
407 version: self.version.clone(),
408 labels: self.labels.clone(),
409 started_at: self.started_at,
410 os_arch,
411 os_name,
412 os_kernel,
413 system_cpu_usage,
414 system_memory_total,
415 system_memory_free,
416 component_count,
417 workload_count,
418 imports,
419 exports,
420 })
421 }
422
423 /// Start a workload
424 async fn workload_start(
425 &self,
426 request: WorkloadStartRequest,
427 ) -> anyhow::Result<WorkloadStartResponse> {
428 let workload_id = self.generate_workload_id();
429
430 // Store the workload with initial state
431 self.workloads
432 .write()
433 .await
434 .insert(workload_id.clone(), HostWorkload::Starting);
435
436 let service_present = request.workload.service.is_some();
437
438 // Initialize the workload using the engine, receiving the unresolved workload
439 let unresolved_workload = self
440 .engine
441 .initialize_workload(&workload_id, request.workload)?;
442
443 let mut resolved_workload = unresolved_workload.resolve(Some(&self.plugins)).await?;
444
445 // If the service didn't run and we had one, warn
446 if resolved_workload.execute_service().await? != service_present {
447 warn!(
448 workload_id = workload_id,
449 "service did not properly execute"
450 );
451 }
452
453 // Update the workload state to `Running`
454 self.workloads
455 .write()
456 .await
457 .entry(workload_id.clone())
458 .and_modify(|workload| {
459 *workload = HostWorkload::Running(Box::new(resolved_workload));
460 });
461
462 Ok(WorkloadStartResponse {
463 workload_status: WorkloadStatus {
464 workload_id,
465 workload_state: WorkloadState::Running,
466 message: "Workload started successfully".to_string(),
467 },
468 })
469 }
470
471 async fn workload_status(
472 &self,
473 request: WorkloadStatusRequest,
474 ) -> anyhow::Result<WorkloadStatusResponse> {
475 if let Some(workload) = self.workloads.read().await.get(&request.workload_id) {
476 let workload_state = workload.into();
477 Ok(WorkloadStatusResponse {
478 workload_status: WorkloadStatus {
479 workload_id: request.workload_id,
480 message: format!("Workload is {workload_state:?}"),
481 workload_state,
482 },
483 })
484 } else {
485 anyhow::bail!("Workload not found: {}", request.workload_id)
486 }
487 }
488
489 async fn workload_stop(
490 &self,
491 request: WorkloadStopRequest,
492 ) -> anyhow::Result<WorkloadStopResponse> {
493 let has_workload = self
494 .workloads
495 .read()
496 .await
497 .contains_key(&request.workload_id);
498
499 let (workload_state, message) = if has_workload {
500 // Update state to stopping
501 let resolved_workload = {
502 let mut workloads = self.workloads.write().await;
503 trace!(
504 workload_id = request.workload_id,
505 "updating workload state to stopping"
506 );
507 // Insert Stopping state, extract the running workload if it was running
508 workloads
509 .insert(request.workload_id.clone(), HostWorkload::Stopping)
510 .and_then(|hw| match hw {
511 HostWorkload::Running(rw) => Some(*rw),
512 _ => None,
513 })
514 };
515
516 // Stop the workload:
517 // 1. Unbind from all plugins
518 // 2. Clean up resources (drop will handle wasmtime cleanup)
519 // 3. Remove from active workloads
520 if let Some(resolved_workload) = resolved_workload {
521 debug!(
522 workload_id = request.workload_id,
523 workload_name = resolved_workload.name(),
524 "stopping workload"
525 );
526
527 // Stop the service if running
528 resolved_workload.stop_service();
529
530 // Unbind all plugins from the workload
531 if let Err(e) = resolved_workload.unbind_all_plugins().await {
532 warn!(
533 workload_id = request.workload_id,
534 error = ?e,
535 "error unbinding plugins during workload stop, continuing"
536 );
537 }
538 }
539
540 // Remove the workload from the active workloads map
541 // This will drop the workload and clean up wasmtime resources
542 self.workloads.write().await.remove(&request.workload_id);
543
544 debug!(
545 workload_id = request.workload_id,
546 "workload stopped successfully"
547 );
548
549 (
550 WorkloadState::Stopping,
551 "Workload stopped successfully".to_string(),
552 )
553 } else {
554 (WorkloadState::Unspecified, "Workload not found".to_string())
555 };
556
557 Ok(WorkloadStopResponse {
558 workload_status: WorkloadStatus {
559 workload_id: request.workload_id,
560 workload_state,
561 message,
562 },
563 })
564 }
565}
566
567impl std::fmt::Debug for Host {
568 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569 f.debug_struct("Host")
570 .field("id", &self.id)
571 .field("hostname", &self.hostname)
572 .field("friendly_name", &self.friendly_name)
573 .field("version", &self.version)
574 .field("labels", &self.labels)
575 .field("started_at", &self.started_at)
576 .field("workloads", &self.workloads)
577 .finish()
578 }
579}
580
581/// Builder for the [`Host`]
582#[derive(Default)]
583pub struct HostBuilder {
584 engine: Option<Engine>,
585 plugins: HashMap<&'static str, Arc<dyn HostPlugin>>,
586 hostname: Option<String>,
587 friendly_name: Option<String>,
588 labels: HashMap<String, String>,
589}
590
591impl HostBuilder {
592 pub fn new() -> Self {
593 Self::default()
594 }
595
596 pub fn with_engine(mut self, engine: Engine) -> Self {
597 self.engine = Some(engine);
598 self
599 }
600
601 pub fn with_plugin<T: HostPlugin>(mut self, plugin: Arc<T>) -> anyhow::Result<Self> {
602 let plugin_id = plugin.id();
603
604 // Check for duplicate plugin IDs
605 if self.plugins.contains_key(plugin_id) {
606 bail!("Duplicate plugin ID '{plugin_id}' - plugin IDs must be unique");
607 }
608
609 self.plugins.insert(plugin_id, plugin);
610 Ok(self)
611 }
612
613 /// Sets the hostname for this host.
614 ///
615 /// # Arguments
616 /// * `hostname` - The hostname to use
617 ///
618 /// # Returns
619 /// The builder instance for method chaining.
620 pub fn with_hostname(mut self, hostname: impl AsRef<str>) -> Self {
621 self.hostname = Some(hostname.as_ref().to_string());
622 self
623 }
624
625 /// Sets a human-readable friendly name for this host.
626 ///
627 /// # Arguments
628 /// * `name` - The friendly name to use
629 ///
630 /// # Returns
631 /// The builder instance for method chaining.
632 pub fn with_friendly_name(mut self, name: impl AsRef<str>) -> Self {
633 self.friendly_name = Some(name.as_ref().to_string());
634 self
635 }
636
637 /// Adds a label to the host.
638 ///
639 /// Labels are key-value pairs that can be used to categorize
640 /// or identify the host.
641 ///
642 /// # Arguments
643 /// * `key` - The label key
644 /// * `value` - The label value
645 ///
646 /// # Returns
647 /// The builder instance for method chaining.
648 pub fn with_label(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
649 self.labels
650 .insert(key.as_ref().to_string(), value.as_ref().to_string());
651 self
652 }
653
654 /// Builds and returns a configured [`Host`].
655 ///
656 /// This method finalizes the configuration and creates the host.
657 /// If no engine is provided, a default engine is created.
658 /// If no hostname is provided, the system hostname is used.
659 /// If no friendly name is provided, a random name is generated.
660 ///
661 /// # Returns
662 /// A new `Host` instance ready to be started.
663 ///
664 /// # Errors
665 /// Returns an error if the default engine cannot be created (when no engine is provided).
666 pub fn build(self) -> anyhow::Result<Host> {
667 let engine = if let Some(engine) = self.engine {
668 engine
669 } else {
670 Engine::builder().build()?
671 };
672
673 // Get hostname from system if not provided
674 let hostname = self.hostname.unwrap_or_else(|| {
675 hostname::get()
676 .map(|h| h.to_string_lossy().to_string())
677 .unwrap_or_else(|_| "unknown".to_string())
678 });
679
680 // Generate a friendly name if not provided
681 let friendly_name = self.friendly_name.unwrap_or_else(|| {
682 let mut generator = Generator::with_naming(Name::Numbered);
683 generator
684 .next()
685 .unwrap_or_else(|| format!("host-{}", uuid::Uuid::new_v4()))
686 });
687
688 Ok(Host {
689 engine,
690 workloads: Arc::default(),
691 plugins: self.plugins,
692 id: uuid::Uuid::new_v4().to_string(),
693 hostname,
694 friendly_name,
695 version: env!("CARGO_PKG_VERSION").to_string(),
696 labels: self.labels,
697 started_at: chrono::Utc::now(),
698 system_monitor: Arc::new(RwLock::new(SystemMonitor::new())),
699 })
700 }
701}