Skip to main content

harn_hostlib/
host_lease_capability.rs

1//! Harn VM bridge for cancellation-safe machine-global host lease scopes.
2//!
3//! The lease registry and stale-recovery policy remain in [`crate::host_lease`].
4//! This module owns only the Harn host-capability boundary: request validation,
5//! explicit wire shaping, and backend error translation. Keeping the bridge
6//! separate prevents product or orchestration callers from learning the SQLite
7//! layout or parsing the `harn host lease` CLI envelope.
8
9use std::collections::BTreeMap;
10use std::time::Duration;
11
12use harn_vm::{VmResourceGuardHandle, VmValue};
13
14use crate::error::HostlibError;
15use crate::host_lease::{
16    HostLeaseAcquireReceipt, HostLeaseAcquireStatus, HostLeaseDeferReceipt, HostLeaseHandle,
17    HostLeaseMetadataUpdateReceipt, HostLeasePriorityClass, HostLeaseReleaseReceipt,
18    HostLeaseRequest, HostLeaseResourceClass, HostLeaseState, HostLeaseStore,
19    DEFAULT_HOST_LEASE_DOMAIN,
20};
21use crate::registry::{BuiltinRegistry, HostlibCapability};
22use crate::tools::args::{
23    build_dict, dict_arg, optional_int, optional_string, require_string, str_value,
24};
25
26const STATUS_BUILTIN: &str = "hostlib_host_lease_status";
27const ACQUIRE_BUILTIN: &str = "hostlib_host_lease_acquire";
28const UPDATE_METADATA_BUILTIN: &str = "hostlib_host_lease_update_metadata";
29const RELEASE_BUILTIN: &str = "hostlib_host_lease_release";
30const MAX_WAIT_SLICE_MS: i64 = 5_000;
31
32/// Read-only access to the authoritative local host-lease state.
33///
34/// The caller must name a local resource explicitly. This capability does not
35/// invent remote observation, and it deliberately does not acquire, renew, or
36/// release leases; those lifecycle operations need their own cancellation-safe
37/// scope boundary.
38#[derive(Default)]
39pub struct HostLeaseCapability;
40
41impl HostlibCapability for HostLeaseCapability {
42    fn module_name(&self) -> &'static str {
43        "host_lease"
44    }
45
46    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
47        registry.register_fn("host_lease", STATUS_BUILTIN, "status", handle_status);
48        registry.register_fn("host_lease", ACQUIRE_BUILTIN, "acquire", handle_acquire);
49        registry.register_fn(
50            "host_lease",
51            UPDATE_METADATA_BUILTIN,
52            "update_metadata",
53            handle_update_metadata,
54        );
55        registry.register_fn("host_lease", RELEASE_BUILTIN, "release", handle_release);
56    }
57}
58
59fn handle_update_metadata(args: &[VmValue]) -> Result<VmValue, HostlibError> {
60    let dict = dict_arg(UPDATE_METADATA_BUILTIN, args)?;
61    let host = require_nonempty_string(UPDATE_METADATA_BUILTIN, &dict, "host")?;
62    let resource_class = resource_class(UPDATE_METADATA_BUILTIN, dict.get("resource_class"))?;
63    let domain = optional_string(UPDATE_METADATA_BUILTIN, &dict, "domain")?
64        .unwrap_or_else(|| DEFAULT_HOST_LEASE_DOMAIN.to_string());
65    let lease_id = require_nonempty_string(UPDATE_METADATA_BUILTIN, &dict, "lease_id")?;
66    let metadata = string_map(UPDATE_METADATA_BUILTIN, dict.get("metadata"))?;
67    let receipt = HostLeaseStore::from_env()
68        .and_then(|store| {
69            store.update_metadata_for_domain(&host, resource_class, &domain, &lease_id, metadata)
70        })
71        .map_err(|error| backend(UPDATE_METADATA_BUILTIN, error))?;
72    metadata_update_to_value(&receipt)
73}
74
75fn handle_status(args: &[VmValue]) -> Result<VmValue, HostlibError> {
76    let dict = dict_arg(STATUS_BUILTIN, args)?;
77    let host = require_nonempty_string(STATUS_BUILTIN, &dict, "host")?;
78    let resource_class = resource_class(STATUS_BUILTIN, dict.get("resource_class"))?;
79    let domain = optional_string(STATUS_BUILTIN, &dict, "domain")?
80        .unwrap_or_else(|| DEFAULT_HOST_LEASE_DOMAIN.to_string());
81    let state = HostLeaseStore::from_env()
82        .and_then(|store| store.status_for_domain(&host, resource_class, &domain))
83        .map_err(|error| HostlibError::Backend {
84            builtin: STATUS_BUILTIN,
85            message: error.to_string(),
86        })?;
87    state_to_value(&state)
88}
89
90fn handle_acquire(args: &[VmValue]) -> Result<VmValue, HostlibError> {
91    let dict = dict_arg(ACQUIRE_BUILTIN, args)?;
92    let host = match optional_string(ACQUIRE_BUILTIN, &dict, "host")? {
93        Some(host) if !host.trim().is_empty() => host,
94        Some(_) => {
95            return Err(HostlibError::InvalidParameter {
96                builtin: ACQUIRE_BUILTIN,
97                param: "host",
98                message: "must be a non-empty string when provided".to_string(),
99            });
100        }
101        None => HostLeaseStore::default_host(),
102    };
103    let owner = require_nonempty_string(ACQUIRE_BUILTIN, &dict, "owner")?;
104    let resource_class = resource_class(ACQUIRE_BUILTIN, dict.get("resource_class"))?;
105    let domain = optional_string(ACQUIRE_BUILTIN, &dict, "domain")?
106        .unwrap_or_else(|| DEFAULT_HOST_LEASE_DOMAIN.to_string());
107    let priority_class = priority_class(ACQUIRE_BUILTIN, dict.get("priority_class"))?;
108    let ttl_ms = optional_positive_u64(ACQUIRE_BUILTIN, &dict, "ttl_ms")?;
109    let wait_slice_ms = optional_int(ACQUIRE_BUILTIN, &dict, "wait_slice_ms", 0)?;
110    if !(0..=MAX_WAIT_SLICE_MS).contains(&wait_slice_ms) {
111        return Err(HostlibError::InvalidParameter {
112            builtin: ACQUIRE_BUILTIN,
113            param: "wait_slice_ms",
114            message: format!("must be between 0 and {MAX_WAIT_SLICE_MS}"),
115        });
116    }
117    let reason = optional_string(ACQUIRE_BUILTIN, &dict, "reason")?;
118    let metadata = string_map(ACQUIRE_BUILTIN, dict.get("metadata"))?;
119    let request = HostLeaseRequest {
120        host,
121        resource_class,
122        domain,
123        execution_context: None,
124        owner,
125        priority_class,
126        ttl_ms,
127        owner_pid: Some(std::process::id()),
128        reason,
129        metadata,
130    };
131    let store = HostLeaseStore::from_env().map_err(|error| backend(ACQUIRE_BUILTIN, error))?;
132    let receipt = if wait_slice_ms == 0 {
133        store.try_acquire(request)
134    } else {
135        store.acquire_wait(request, Duration::from_millis(wait_slice_ms as u64))
136    }
137    .map_err(|error| backend(ACQUIRE_BUILTIN, error))?;
138    acquire_to_value(store, receipt)
139}
140
141fn handle_release(args: &[VmValue]) -> Result<VmValue, HostlibError> {
142    let dict = dict_arg(RELEASE_BUILTIN, args)?;
143    let Some(VmValue::ResourceGuard(guard)) = dict.get("guard") else {
144        return Err(HostlibError::InvalidParameter {
145            builtin: RELEASE_BUILTIN,
146            param: "guard",
147            message: "must be a resource_guard returned by host_lease.acquire".to_string(),
148        });
149    };
150    guard
151        .release()
152        .map_err(|error| backend(RELEASE_BUILTIN, error))
153}
154
155fn backend(builtin: &'static str, error: impl std::fmt::Display) -> HostlibError {
156    HostlibError::Backend {
157        builtin,
158        message: error.to_string(),
159    }
160}
161
162fn resource_class(
163    builtin: &'static str,
164    value: Option<&VmValue>,
165) -> Result<HostLeaseResourceClass, HostlibError> {
166    match value {
167        None | Some(VmValue::Nil) => Ok(HostLeaseResourceClass::WholeMachine),
168        Some(VmValue::String(value)) if value.as_str() == "whole-machine" => {
169            Ok(HostLeaseResourceClass::WholeMachine)
170        }
171        Some(VmValue::String(value)) if value.as_str() == "rust-heavy" => {
172            Ok(HostLeaseResourceClass::RustHeavy)
173        }
174        _ => Err(HostlibError::InvalidParameter {
175            builtin,
176            param: "resource_class",
177            message: "must be whole-machine or rust-heavy".to_string(),
178        }),
179    }
180}
181
182fn priority_class(
183    builtin: &'static str,
184    value: Option<&VmValue>,
185) -> Result<HostLeasePriorityClass, HostlibError> {
186    match value {
187        None | Some(VmValue::Nil) => Ok(HostLeasePriorityClass::Deferrable),
188        Some(VmValue::String(value)) => match value.as_str() {
189            "interactive" => Ok(HostLeasePriorityClass::Interactive),
190            "measurement" => Ok(HostLeasePriorityClass::Measurement),
191            "ci-verify" => Ok(HostLeasePriorityClass::CiVerify),
192            "deferrable" => Ok(HostLeasePriorityClass::Deferrable),
193            _ => Err(HostlibError::InvalidParameter {
194                builtin,
195                param: "priority_class",
196                message: "must be interactive, measurement, ci-verify, or deferrable".to_string(),
197            }),
198        },
199        _ => Err(HostlibError::InvalidParameter {
200            builtin,
201            param: "priority_class",
202            message: "must be a string".to_string(),
203        }),
204    }
205}
206
207fn optional_positive_u64(
208    builtin: &'static str,
209    dict: &harn_vm::value::DictMap,
210    key: &'static str,
211) -> Result<Option<u64>, HostlibError> {
212    match dict.get(key) {
213        None | Some(VmValue::Nil) => Ok(None),
214        Some(VmValue::Int(value)) if *value > 0 => Ok(Some(*value as u64)),
215        _ => Err(HostlibError::InvalidParameter {
216            builtin,
217            param: key,
218            message: "must be a positive integer or nil".to_string(),
219        }),
220    }
221}
222
223fn string_map(
224    builtin: &'static str,
225    value: Option<&VmValue>,
226) -> Result<BTreeMap<String, String>, HostlibError> {
227    let Some(value) = value else {
228        return Ok(BTreeMap::new());
229    };
230    if matches!(value, VmValue::Nil) {
231        return Ok(BTreeMap::new());
232    }
233    let VmValue::Dict(entries) = value else {
234        return Err(HostlibError::InvalidParameter {
235            builtin,
236            param: "metadata",
237            message: "must be a dict of string values".to_string(),
238        });
239    };
240    entries
241        .iter()
242        .map(|(key, value)| match value {
243            VmValue::String(value) => Ok((key.to_string(), value.to_string())),
244            _ => Err(HostlibError::InvalidParameter {
245                builtin,
246                param: "metadata",
247                message: "must contain only string values".to_string(),
248            }),
249        })
250        .collect()
251}
252
253fn acquire_to_value(
254    store: HostLeaseStore,
255    receipt: HostLeaseAcquireReceipt,
256) -> Result<VmValue, HostlibError> {
257    let handle = match receipt.handle.as_ref() {
258        Some(handle) => handle_to_value(ACQUIRE_BUILTIN, handle)?,
259        None => VmValue::Nil,
260    };
261    let defer = receipt
262        .defer
263        .as_ref()
264        .map(defer_to_value)
265        .transpose()?
266        .unwrap_or(VmValue::Nil);
267    let guard = if receipt.status == HostLeaseAcquireStatus::Acquired {
268        let handle = receipt
269            .handle
270            .as_ref()
271            .ok_or_else(|| HostlibError::Backend {
272                builtin: ACQUIRE_BUILTIN,
273                message: "acquired receipt omitted its lease handle".to_string(),
274            })?;
275        let host = handle.host.clone();
276        let resource_class = handle.resource_class;
277        let domain = handle.domain.clone();
278        let lease_id = handle.lease_id.clone();
279        VmValue::resource_guard(VmResourceGuardHandle::new("host_lease", move || {
280            store
281                .release_for_domain(&host, resource_class, &domain, &lease_id)
282                .map(|receipt| release_to_value(&receipt))
283                .map_err(|error| error.to_string())
284        }))
285    } else {
286        VmValue::Nil
287    };
288    Ok(build_dict([
289        (
290            "schema_version",
291            VmValue::Int(i64::from(receipt.schema_version)),
292        ),
293        (
294            "status",
295            str_value(match receipt.status {
296                HostLeaseAcquireStatus::Acquired => "acquired",
297                HostLeaseAcquireStatus::Deferred => "deferred",
298            }),
299        ),
300        ("observed_at_ms", VmValue::Int(receipt.observed_at_ms)),
301        ("waited_ms", VmValue::Int(receipt.waited_ms as i64)),
302        ("handle", handle),
303        ("defer", defer),
304        ("guard", guard),
305        (
306            "recovered_stale_lease",
307            VmValue::Bool(receipt.recovered_stale_lease),
308        ),
309        (
310            "recovered",
311            receipt
312                .recovered
313                .as_ref()
314                .map(|handle| handle_to_value(ACQUIRE_BUILTIN, handle))
315                .transpose()?
316                .unwrap_or(VmValue::Nil),
317        ),
318    ]))
319}
320
321fn defer_to_value(defer: &HostLeaseDeferReceipt) -> Result<VmValue, HostlibError> {
322    Ok(build_dict([
323        ("host", str_value(&defer.host)),
324        ("resource_class", str_value(defer.resource_class.as_str())),
325        ("domain", str_value(&defer.domain)),
326        ("deferred_reason", str_value(defer.deferred_reason.as_str())),
327        ("observed_at_ms", VmValue::Int(defer.observed_at_ms)),
328        (
329            "next_wake_at_ms",
330            defer
331                .next_wake_at_ms
332                .map(VmValue::Int)
333                .unwrap_or(VmValue::Nil),
334        ),
335        (
336            "deadline_at_ms",
337            defer
338                .deadline_at_ms
339                .map(VmValue::Int)
340                .unwrap_or(VmValue::Nil),
341        ),
342        (
343            "active",
344            defer
345                .active
346                .as_ref()
347                .map(|handle| handle_to_value(ACQUIRE_BUILTIN, handle))
348                .transpose()?
349                .unwrap_or(VmValue::Nil),
350        ),
351    ]))
352}
353
354fn release_to_value(receipt: &HostLeaseReleaseReceipt) -> VmValue {
355    build_dict([
356        (
357            "schema_version",
358            VmValue::Int(i64::from(receipt.schema_version)),
359        ),
360        ("released", VmValue::Bool(receipt.released)),
361        ("host", str_value(&receipt.host)),
362        ("resource_class", str_value(receipt.resource_class.as_str())),
363        ("domain", str_value(&receipt.domain)),
364        ("lease_id", str_value(&receipt.lease_id)),
365        ("observed_at_ms", VmValue::Int(receipt.observed_at_ms)),
366    ])
367}
368
369fn metadata_update_to_value(
370    receipt: &HostLeaseMetadataUpdateReceipt,
371) -> Result<VmValue, HostlibError> {
372    Ok(build_dict([
373        (
374            "schema_version",
375            VmValue::Int(i64::from(receipt.schema_version)),
376        ),
377        ("updated", VmValue::Bool(receipt.updated)),
378        ("observed_at_ms", VmValue::Int(receipt.observed_at_ms)),
379        (
380            "handle",
381            receipt
382                .handle
383                .as_ref()
384                .map(|handle| handle_to_value(UPDATE_METADATA_BUILTIN, handle))
385                .transpose()?
386                .unwrap_or(VmValue::Nil),
387        ),
388    ]))
389}
390
391fn require_nonempty_string(
392    builtin: &'static str,
393    dict: &harn_vm::value::DictMap,
394    key: &'static str,
395) -> Result<String, HostlibError> {
396    let value = require_string(builtin, dict, key)?;
397    if value.trim().is_empty() {
398        return Err(HostlibError::InvalidParameter {
399            builtin,
400            param: key,
401            message: "must be a non-empty string".to_string(),
402        });
403    }
404    Ok(value)
405}
406
407fn state_to_value(state: &HostLeaseState) -> Result<VmValue, HostlibError> {
408    let active = match state.active.as_ref() {
409        Some(handle) => handle_to_value(STATUS_BUILTIN, handle)?,
410        None => VmValue::Nil,
411    };
412    Ok(build_dict([
413        (
414            "schema_version",
415            VmValue::Int(i64::from(state.schema_version)),
416        ),
417        ("host", str_value(&state.host)),
418        ("resource_class", str_value(state.resource_class.as_str())),
419        ("domain", str_value(&state.domain)),
420        ("observed_at_ms", VmValue::Int(state.observed_at_ms)),
421        ("active", active),
422        (
423            "recovered_stale_lease",
424            VmValue::Bool(state.recovered_stale_lease),
425        ),
426        (
427            "recovered",
428            state
429                .recovered
430                .as_ref()
431                .map(|handle| handle_to_value(STATUS_BUILTIN, handle))
432                .transpose()?
433                .unwrap_or(VmValue::Nil),
434        ),
435    ]))
436}
437
438fn handle_to_value(
439    builtin: &'static str,
440    handle: &HostLeaseHandle,
441) -> Result<VmValue, HostlibError> {
442    let owner_process_identity = match handle.owner_process_identity {
443        Some(identity) => {
444            VmValue::Int(i64::try_from(identity).map_err(|_| HostlibError::Backend {
445                builtin,
446                message: "owner process identity exceeds the Harn integer range".to_string(),
447            })?)
448        }
449        None => VmValue::Nil,
450    };
451    let metadata = build_dict(
452        handle
453            .metadata
454            .iter()
455            .map(|(key, value)| (key.clone(), str_value(value))),
456    );
457    Ok(build_dict([
458        (
459            "schema_version",
460            VmValue::Int(i64::from(handle.schema_version)),
461        ),
462        ("host", str_value(&handle.host)),
463        ("resource_class", str_value(handle.resource_class.as_str())),
464        ("domain", str_value(&handle.domain)),
465        ("lease_id", str_value(&handle.lease_id)),
466        ("owner", str_value(&handle.owner)),
467        ("priority_class", str_value(handle.priority_class.as_str())),
468        ("acquired_at_ms", VmValue::Int(handle.acquired_at_ms)),
469        ("updated_at_ms", VmValue::Int(handle.updated_at_ms)),
470        (
471            "expires_at_ms",
472            handle
473                .expires_at_ms
474                .map(VmValue::Int)
475                .unwrap_or(VmValue::Nil),
476        ),
477        (
478            "owner_pid",
479            handle
480                .owner_pid
481                .map(i64::from)
482                .map(VmValue::Int)
483                .unwrap_or(VmValue::Nil),
484        ),
485        ("owner_process_identity", owner_process_identity),
486        (
487            "reason",
488            handle
489                .reason
490                .as_deref()
491                .map(str_value)
492                .unwrap_or(VmValue::Nil),
493        ),
494        ("metadata", metadata),
495    ]))
496}
497
498#[cfg(test)]
499mod tests {
500    use std::collections::BTreeMap;
501
502    use super::*;
503    use crate::host_lease::{HostLeasePriorityClass, HostLeaseResourceClass};
504
505    #[test]
506    fn status_value_preserves_active_and_recovery_evidence() {
507        let state = HostLeaseState {
508            schema_version: 1,
509            host: "mac-local".to_string(),
510            resource_class: HostLeaseResourceClass::WholeMachine,
511            domain: DEFAULT_HOST_LEASE_DOMAIN.to_string(),
512            observed_at_ms: 42,
513            active: None,
514            recovered_stale_lease: true,
515            recovered: Some(HostLeaseHandle {
516                schema_version: 1,
517                host: "mac-local".to_string(),
518                resource_class: HostLeaseResourceClass::WholeMachine,
519                domain: DEFAULT_HOST_LEASE_DOMAIN.to_string(),
520                execution_context: None,
521                lease_id: "lease-1".to_string(),
522                owner: "owner".to_string(),
523                priority_class: HostLeasePriorityClass::Measurement,
524                acquired_at_ms: 10,
525                updated_at_ms: 20,
526                expires_at_ms: Some(30),
527                owner_pid: Some(123),
528                owner_process_identity: Some(456),
529                reason: Some("measurement".to_string()),
530                metadata: BTreeMap::from([("lane".to_string(), "meter".to_string())]),
531            }),
532        };
533
534        let value = state_to_value(&state).expect("state converts");
535        let VmValue::Dict(state) = value else {
536            panic!("expected state dict");
537        };
538        assert!(matches!(
539            state.get("recovered_stale_lease"),
540            Some(VmValue::Bool(true))
541        ));
542        assert!(matches!(state.get("active"), Some(VmValue::Nil)));
543        let Some(VmValue::Dict(recovered)) = state.get("recovered") else {
544            panic!("expected recovered lease");
545        };
546        assert_eq!(
547            recovered.get("priority_class").map(VmValue::display),
548            Some("measurement".to_string())
549        );
550        assert_eq!(
551            recovered
552                .get("owner_process_identity")
553                .map(VmValue::display),
554            Some("456".to_string())
555        );
556    }
557}