8000 crates/sessions: new crate for session primitives + lifecycle hooks primitive by evanspearman · Pull Request #230 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ members = [
"crates/remote-client",
"crates/remote-proto",
"crates/sandbox2",
"crates/sessions",
"crates/stdlib",
]
resolver = "3"
Expand Down
14 changes: 14 additions & 0 deletions crates/sessions/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "sessions"
version = "0.0.1"
edition.workspace = true
publish.workspace = true

[dependencies]
serde.workspace = true

[dev-dependencies]
toml.workspace = true

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
5 changes: 5 additions & 0 deletions crates/sessions/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//! Session primitives: lifecycle hooks and loadouts that describe the runtime
//! shape of a Minimal session.

pub mod lifecyclehook;
pub mod loadout;
315 changes: 315 additions & 0 deletions crates/sessions/src/lifecyclehook.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
//! Lifecycle hooks: scripts run on activation, destruction, or failure of a host resource.
//!
//! A [`LifecycleHook`] groups up to three scripts (one per transition point) and
//! guarantees that at least one is present — an empty hook is a no-op and is
//! rejected at construction time. Build hooks programmatically through
//! [`LifecycleHook::builder`] or deserialize them from configuration; both paths
//! enforce the same invariant.

use core::fmt;
use std::path::PathBuf;

/// Errors produced when constructing a [`LifecycleHook`].
#[non_exhaustive]
#[derive(Debug)]
pub enum Error {
/// No hook script was provided.
///
/// Returned by [`LifecycleHookBuilder::build`] (and therefore by
/// deserialization) when none of `on_activate`, `on_destroy`, or
/// `on_failure` is set.
EmptyLifecycleHook,
}

impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyLifecycleHook => write!(
f,
"At least one of on_activate, on_destroy, or on_failure must be specified"
),
}
}
}

impl std::error::Error for Error {}

/// A script executed by a lifecycle hook.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats the constraints aroud what a valid script is? Do we want to just say 'bash v4 or greater compatible?' (I would say POSIX shell except everyone ignores when thats the constraint and uses modern features lol)

@evanspearman evanspearman May 26, 2026 •
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it depends on exactly when these run. If it's something like:

Install packages -> Activate Hook -> ... -> Destroy Hook -> Packages are gone
                                     !!! -> Failure Hook -> Destroy Hook -> Packages are Gone

Then we don't really need to constrain it. It could be fish, python, ruby, whatever you want as long as you have the package to run it. Just put in the right shebang.

If they're run when you don't have your packages available then I think some specific version of bash (as long as we can grantee that's available) makes sense.

@twitchyliquid64 twitchyliquid64 May 26, 2026 •
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, at this stage i realize im not fully sure what the context is for lifecycle hooks. Are these basically just activation scripts for a session?

If theres one activation script then it makes sense to have a configurable shell like bash/fish etc. But if we are composing multiple scripts we need the env vars that get exported in one + the background tasks/jobs that get run to stay alive as all the activation scripts run (i.e. Karl's use case of launching postgres, we dont want postgres to die when the activation script finishes), in which case what shell is chosen for interpreting all those scripts?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right. In that case I think we just do bash unless we have a good reason not to. I don't think that needs to be encoded here though.

///
/// Either the script body provided directly ([`Inline`](Self::Inline)) or a
/// path to a script file on disk ([`External`](Self::External)).
///
/// Serialized with adjacent tagging — the wire form is a table with `type` and
/// `value` keys:
///
/// ```toml
/// on_activate = { type = "inline", value = "echo hi" }
/// on_failure = { type = "external", value = "./run.sh" }
/// ```
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "lowercase")]
pub enum HookScript {
/// Script body stored inline.
Inline(String),
/// Path to a script file on disk.
External(PathBuf),
@twitchyliquid64 twitchyliquid64 May 26, 2026 •
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over the time of building minimal Ive started to dislike PathBuf, because it leads to confusion/invariants/bugs based on:

  1. Is this a path relative to the user system, the sandbox fs, or the minimald (in the future) ?
  2. Is this meant to be interpreted relative to the cwd, if so which of the above contexts?
  3. More of a grumble, but if you join an absolute path with cwd, it overwrites with the absolute path. I can feel a bug coming from this one day (i.e. if a user specifies an absolute path in the config).

Not sure what the fix is here, but we may want some newtype around paths. Food for thought.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, newtypes sound like the right way to go here. I'm using them for paths in the patches stuff I'm working on, where it becomes even more important to keep track of this stuff.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, they're probably not strictly newtypes as they'll probably need to contain a bit more context in some cases.

}

/// A set of scripts to run at lifecycle transition points of a host resource.
///
/// At least one of `on_activate`, `on_destroy`, or `on_failure` must be set.
/// This invariant is enforced both by [`LifecycleHookBuilder::build`] and by
/// `serde` deserialization — there is no way to construct a `LifecycleHook`
/// with all three fields empty.
///
/// # Example — programmatic construction
///
/// ```
/// use sessions::lifecyclehook::{HookScript, LifecycleHook};
///
/// let hook = LifecycleHook::builder()
/// .with_on_activate(HookScript::Inline("echo activated".into()))
/// .build()
/// .expect("on_activate is set");
///
/// assert!(hook.on_activate().is_some());
/// assert!(hook.on_destroy().is_none());
/// assert!(hook.on_failure().is_none());
///
/// // An empty hook is rejected:
/// assert!(LifecycleHook::builder().build().is_err());
/// ```
///
/// # Example — TOML deserialization
///
/// ```
/// use sessions::lifecyclehook::{HookScript, LifecycleHook};
///
/// let src = r#"
/// on_activate = { type = "inline", value = "echo hello" }
/// on_failure = { type = "external", value = "./cleanup.sh" }
/// "#;
///
/// let hook: LifecycleHook = toml::from_str(src).unwrap();
/// assert!(matches!(hook.on_activate(), Some(HookScript::Inline(s)) if s == "echo hello"));
/// assert!(matches!(hook.on_failure(), Some(HookScript::External(_))));
/// assert!(hook.on_destroy().is_none());
///
/// // Deserializing an empty hook fails:
/// assert!(toml::from_str::<LifecycleHook>("").is_err());
/// ```
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "LifecycleHookBuilder", into = "LifecycleHookBuilder")]
#[expect(
clippy::struct_field_names,
reason = "field prefixes mirror lifecycle event names"
)]
pub struct LifecycleHook {
on_activate: Option<HookScript>,
on_destroy: Option<HookScript>,
on_failure: Option<HookScript>,
}

impl LifecycleHook {
/// Returns a fresh [`LifecycleHookBuilder`].
#[must_use]
pub fn builder() -> LifecycleHookBuilder {
LifecycleHookBuilder::new()
}

/// The script to run on activation, if any.
#[must_use]
pub fn on_activate(&self) -> Option<&HookScript> {
self.on_activate.as_ref()
}

/// The script to run on destruction, if any.
#[must_use]
pub fn on_destroy(&self) -> Option<&HookScript> {
self.on_destroy.as_ref()
}

/// The script to run when activation fails, if any.
#[must_use]
pub fn on_failure(&self) -> Option<&HookScript> {
self.on_failure.as_ref()
}
}

impl TryFrom<LifecycleHookBuilder> for LifecycleHook {
type Error = Error;
fn try_from(builder: LifecycleHookBuilder) -> Result<Self, Self::Error> {
builder.build()
}
}

/// Builder for [`LifecycleHook`].
///
/// Doubles as the on-the-wire deserialization shape for `LifecycleHook` — every
/// field is optional, and validation runs at [`build`](Self::build) time.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[expect(
clippy::struct_field_names,
reason = "field prefixes mirror lifecycle event names"
)]
pub struct LifecycleHookBuilder {
#[serde(skip_serializing_if = "Option::is_none")]
on_activate: Option<HookScript>,
#[serde(skip_serializing_if = "Option::is_none")]
on_destroy: Option<HookScript>,
#[serde(skip_serializing_if = "Option::is_none")]
on_failure: Option<HookScript>,
}

impl LifecycleHookBuilder {
/// Creates a new builder with all fields unset.
#[must_use]
pub fn new() -> Self {
Self {
..Default::default()
}
}

/// Sets the script to run on activation.
#[must_use]
pub fn with_on_activate(self, on_activate: HookScript) -> Self {
Self {
on_activate: Some(on_activate),
..self
}
}

/// Sets the script to run when activation fails.
#[must_use]
pub fn with_on_failure(self, on_failure: HookScript) -> Self {
Self {
on_failure: Some(on_failure),
..self
}
}

/// Sets the script to run on destruction.
#[must_use]
pub fn with_on_destroy(self, on_destroy: HookScript) -> Self {
Self {
on_destroy: Some(on_destroy),
..self
}
}

/// Consumes the builder and produces a [`LifecycleHook`].
///
/// # Errors
///
/// Returns [`Error::EmptyLifecycleHook`] if none of `on_activate`,
/// `on_destroy`, or `on_failure` has been set.
pub fn build(self) -> Result<LifecycleHook, Error> {
if self.on_activate.is_none() && self.on_destroy.is_none() && self.on_failure.is_none() {
return Err(Error::EmptyLifecycleHook);
}
Ok(LifecycleHook {
on_activate: self.on_activate,
on_destroy: self.on_destroy,
on_failure: self.on_failure,
})
}
}

impl From<LifecycleHook> for LifecycleHookBuilder {
fn from(value: LifecycleHook) -> Self {
< 10BC8 /td> Self {
on_activate: value.on_activate,
on_destroy: value.on_destroy,
on_failure: value.on_failure,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn builder_rejects_all_none() {
assert!(matches!(
LifecycleHook::builder().build(),
Err(Error::EmptyLifecycleHook),
));
}

#[test]
fn builder_accepts_only_on_activate() {
let hook = LifecycleHook::builder()
.with_on_activate(HookScript::Inline("a".into()))
.build()
.unwrap();
assert!(hook.on_activate().is_some());
assert!(hook.on_destroy().is_none());
assert!(hook.on_failure().is_none());
}

#[test]
fn toml_deserializes_full_hook() {
let src = r#"
on_activate = { type = "inline", value = "echo activated" }
on_destroy = { type = "external", value = "./teardown.sh" }
on_failure = { type = "inline", value = "echo failed" }
"#;
let hook: LifecycleHook = toml::from_str(src).unwrap();
assert!(matches!(hook.on_activate(), Some(HookScript::Inline(s)) if s == "echo activated"));
assert!(
matches!(hook.on_destroy(), Some(HookScript::External(p)) if p == &PathBuf::from("./teardown.sh"))
);
assert!(matches!(hook.on_failure(), Some(HookScript::Inline(s)) if s == "echo failed"));
}

#[test]
fn toml_deserializes_partial_hook() {
let src = r#"
on_activate = { type = "inline", value = "echo hi" }
"#;
let hook: LifecycleHook = toml::from_str(src).unwrap();
assert!(hook.on_activate().is_some());
assert!(hook.on_destroy().is_none());
assert!(hook.on_failure().is_none());
}

#[test]
fn toml_rejects_empty_hook() {
let err = toml::from_str::<LifecycleHook>("").unwrap_err();
assert!(
err.to_string().contains("on_activate"),
"expected validation error, got: {err}",
);
}

#[test]
fn serde_round_trip_through_toml() {
let original = LifecycleHook::builder()
.with_on_activate(HookScript::Inline("echo hi".into()))
.with_on_failure(HookScript::External(PathBuf::from("./fail.sh")))
.build()
.unwrap();

let serialized = toml::to_string(&original).unwrap();
let parsed: LifecycleHook = toml::from_str(&serialized).unwrap();

assert!(matches!(parsed.on_activate(), Some(HookScript::Inline(s)) if s == "echo hi"));
assert!(
matches!(parsed.on_failure(), Some(HookScript::External(p)) if p == &PathBuf::from("./fail.sh"))
);
assert!(parsed.on_destroy().is_none());
}

#[test]
fn serde_omits_unset_fields() {
let hook = LifecycleHook::builder()
.with_on_activate(HookScript::Inline("x".into()))
.build()
.unwrap();
let serialized = toml::to_string(&hook).unwrap();
assert!(serialized.contains("on_activate"));
assert!(!serialized.contains("on_destroy"));
assert!(!serialized.contains("on_failure"));
}
}
10 changes: 10 additions & 0 deletions crates/sessions/src/loadout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
use std::collections::HashMap;

use crate::lifecyclehook::LifecycleHook;

#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Loadout {
env_vars: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
lifecycle_hooks: Vec<LifecycleHook>,
}
Loading
0