1use 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#[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}