Repository navigation
crates/sessions: new crate for session primitives + lifecycle hooks primitive #230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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 } |
| 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; |
| 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. | ||
| /// | ||
| /// 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), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Over the time of building minimal Ive started to dislike
Not sure what the fix is here, but we may want some newtype around paths. Food for thought.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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")); | ||
| } | ||
| } | ||
| 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>, | ||
| } |
There was a problem hiding this comment.
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)
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.